Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ CONTENTFUL_CONTENT_TYPE=
BLUESTONE_CLIENT_ID=
BLUESTONE_CLIENT_SECRET=
BLUESTONE_AUTH_URL=https://idp.test.bluestonepim.com/op/token
BLUESTONE_GET_ATTRIBUTES_URL=
BLUESTONE_MANAGEMENT_API_URL=
BLUESTONE_ATTRIBUTE_GROUPS_URL=
BLUESTONE_DEFINITIONS_URL=
BLUESTONE_API_KEY=
BLUESTONE_API_CONTEXT=


ALGOLIA_APP_ID=
ALGOLIA_API_KEY=
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lint-staged
npx lint-staged
171 changes: 164 additions & 7 deletions src/clients/bluestone.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,180 @@
import { environment } from '../environment'
import type {
BluestoneApiResponse,
BluestoneAttributeCreateRequest,
BluestoneAttributeGroupRequest,
} from '../types/bluestone/bluestone.types'

const getToken = async () => {
const getToken = async (): Promise<{
access_token: string
token_type: string
}> => {
const response = await fetch(environment.bluestone.authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
client_id: environment.bluestone.clientId,
client_secret: environment.bluestone.clientSecret,
grant_type: 'client_credentials',
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
})

if (!response.ok) {
throw new Error(`Failed to get token: ${response.status} ${response.statusText}`)
}

return response.json() as Promise<{
access_token: string
token_type: string
}>
}

const getAuthHeaders = async () => {
const token = await getToken()
return {
Authorization: `${token.token_type} ${token.access_token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
}
}

const getAttributes = async () => {
const items: unknown[] = []
const itemsOnPage = 50
let pageNo = 1

while (true) {
const response = await fetch(
`${environment.bluestone.getAttributesUrl}?pageNo=${pageNo}&itemsOnPage=${itemsOnPage}`,
{
headers: {
'x-api-key': environment.bluestone.apiKey,
Accept: 'application/json',
context: environment.bluestone.context,
},
},
)

const json = await response.json()
if (!response.ok) throw new Error(`API error: ${JSON.stringify(json)}`)

const data = json as { results?: unknown[] }
if (!data.results) throw new Error('Unexpected response shape')

if (data.results.length === 0) break

items.push(...data.results)
if (data.results.length < itemsOnPage) break
pageNo++
}

return items
}

const getAttributeGroups = async () => {
const groups: Array<{ id: string; name: string; number: string }> = []
const pageSize = 1000
let page = 0
let hasMore = true

while (hasMore) {
const headers = await getAuthHeaders()
const url = `${environment.bluestone.attributeGroupsUrl}?page=${page}&pageSize=${pageSize}`
const response = await fetch(url, {
method: 'GET',
headers: { ...headers, context: environment.bluestone.context },
})

if (!response.ok) {
const text = await response.text()
throw new Error(`Failed to get groups: ${response.status} - ${text}`)
}

const json = (await response.json()) as { data?: unknown[] }
const pageGroups = (json.data || []) as typeof groups

groups.push(...pageGroups)
page++

hasMore = pageGroups.length >= pageSize
}

return groups
}

const createAttributeGroup = async (group: BluestoneAttributeGroupRequest): Promise<BluestoneApiResponse> => {
const headers = await getAuthHeaders()
const response = await fetch(environment.bluestone.attributeGroupsUrl, {
method: 'POST',
headers,
body: JSON.stringify(group),
})

const data = await response.json()
return data
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as {
conflictingEntities?: Array<{ entityId: string }>
}

// Handle 409 Conflict - group already exists
if (response.status === 409) {
const existingId = error.conflictingEntities?.[0]?.entityId || 'unknown'
console.log(` ⚠️ Group "${group.name}" already exists (${existingId})`)
return {
success: true,
resourceId: existingId,
data: { alreadyExists: true },
}
}

throw new Error(`Failed to create group: ${response.status} - ${JSON.stringify(error)}`)
}

return {
success: true,
resourceId: response.headers.get('resource-id'),
data: await response.json().catch(() => ({})),
}
}

const createAttribute = async (attribute: BluestoneAttributeCreateRequest): Promise<BluestoneApiResponse> => {
const headers = await getAuthHeaders()
const response = await fetch(environment.bluestone.definitionsUrl, {
method: 'POST',
headers,
body: JSON.stringify(attribute),
})

if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as {
conflictingEntities?: Array<{ entityId: string }>
}

// Handle 409 Conflict
if (response.status === 409) {
return {
success: true,
resourceId: error.conflictingEntities?.[0]?.entityId || 'unknown',
data: { alreadyExists: true },
}
}

throw new Error(`Failed to create attribute: ${response.status} - ${JSON.stringify(error)}`)
}

return {
success: true,
resourceId: response.headers.get('resource-id'),
data: await response.json().catch(() => ({})),
}
}

export const bluestoneClient = {
getToken,
getAttributes,
getAttributeGroups,
createAttributeGroup,
createAttribute,
}
2 changes: 1 addition & 1 deletion src/clients/conscia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ const importEnvironment = async ({
preserveSecrets: boolean
preserveEnvironmentVariables: boolean
}) => {
const fullPath = resolve(__dirname, '../../outputs/conscia/export-environment.json')
const fullPath = resolve(__dirname, '../outputs/conscia/export-environment.json')
const config = (await import(fullPath)) as unknown as ConsciaEnvironmentConfig

const jsonBody = {
Expand Down
9 changes: 8 additions & 1 deletion src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ export const environment = {
bluestone: {
clientId: process.env.BLUESTONE_CLIENT_ID ?? '',
clientSecret: process.env.BLUESTONE_CLIENT_SECRET ?? '',
authUrl: process.env.BLUESTONE_AUTH_URL ?? 'https://idp.test.bluestonepim.com/op/token',
authUrl: process.env.BLUESTONE_AUTH_URL ?? 'https://idp-us.bluestonepim.com/op/token',
getAttributesUrl: process.env.BLUESTONE_GET_ATTRIBUTES_URL ?? 'https://idp-us.bluestonepim.com/v1/attributes',
managementApiUrl: process.env.BLUESTONE_MANAGEMENT_API_URL ?? 'https://api-us.bluestonepim.com/pim',
attributeGroupsUrl:
process.env.BLUESTONE_ATTRIBUTE_GROUPS_URL ?? 'https://api-us.bluestonepim.com/pim/attributeGroups',
definitionsUrl: process.env.BLUESTONE_DEFINITIONS_URL ?? 'https://api-us.bluestonepim.com/pim/definitions',
apiKey: process.env.BLUESTONE_API_KEY ?? '',
context: process.env.BLUESTONE_API_CONTEXT ?? 'en',
},
algolia: {
appId: process.env.ALGOLIA_APP_ID ?? '',
Expand Down
38 changes: 29 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFile } from 'node:fs'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { select } from '@inquirer/prompts'
import { deepReadDir } from './utils'
Expand All @@ -12,38 +12,58 @@ const bootstrap = async () => {

const service = await select({
message: 'Select the service you want to use',
choices: [...services].map(service => ({ name: service, value: service })),
choices: [...services].map(service => ({
name: service,
value: service,
})),
})

const availableScripts = sanitizedScripts.filter(script => script.includes(service))
const availableScriptsNoExt = availableScripts.map(script => script.split('.ts')[0].split(`${service}/`)[1])

const script = await select({
message: 'Select the script you want to run',
choices: availableScriptsNoExt.map(script => ({ name: script, value: script })),
choices: availableScriptsNoExt.map(script => ({
name: script,
value: script,
})),
})

const scriptPath = scriptPaths.find(scriptPath => scriptPath.includes(script))

try {
const scriptFunction = await import(`${scriptPath}`)

const result = await scriptFunction.default()

if (typeof result !== 'undefined') {
console.log(result)
if (typeof result !== 'undefined' && result !== null) {
const dataToSave = result.__skipLog && result.data !== undefined ? result.data : result

if (!result.__skipLog) {
const isLargeArray = Array.isArray(result) && result.length > 20
if (isLargeArray) {
console.log(`\n📦 Result: ${result.length} items`)
} else {
console.log(result)
}
}

const path = `./src/outputs/${service}/${script}.json`
const folderPath = path.split('/').slice(0, -1).join('/')

if (!existsSync(folderPath)) mkdirSync(folderPath, { recursive: true })

writeFile(path, JSON.stringify(result, null, 2), err => {
if (err) console.log(err)
})
try {
writeFileSync(path, JSON.stringify(dataToSave, null, 2))
console.log(`\n✅ Saved: ${path}`)
} catch (err) {
console.error('\n❌ Failed to save:', err)
}
}

process.exit(0)
} catch (err) {
console.error(err)
process.exit(1)
}
}
bootstrap()
31 changes: 31 additions & 0 deletions src/scripts/bluestone/export-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { bluestoneClient } from '../../clients/bluestone'

export default async () => {
try {
console.log('\n🔷 Bluestone Attributes Export')
console.log('==============================\n')

const attributes = await bluestoneClient.getAttributes()

console.log(`✅ Exported ${attributes.length} attributes`)

// Show sample (first 10)
if (attributes.length > 0) {
console.log('\n📋 Sample:')
for (const a of (attributes as Array<{ name?: string }>).slice(0, 10)) {
console.log(` - ${a.name}`)
}
if (attributes.length > 10) {
console.log(` ... and ${attributes.length - 10} more`)
}
}

console.log('\n💾 Saved to: outputs/bluestone/export-attributes.json')

// Return without logging full content
return { __skipLog: true, data: attributes }
} catch (error) {
console.error('\n💥 Export failed:', error)
return null
}
}
Loading