UNPKG

storyblok

Version:
1 lines 438 kB
{"version":3,"file":"index.mjs","sources":["../src/constants.ts","../src/utils/fetch.ts","../src/utils/error/api-error.ts","../src/utils/error/command-error.ts","../src/utils/error/filesystem-error.ts","../src/utils/error/error.ts","../src/utils/auth.ts","../src/utils/format.ts","../src/utils/konsola.ts","../src/utils/index.ts","../src/program.ts","../src/utils/api-routes.ts","../src/commands/login/actions.ts","../src/utils/filesystem.ts","../src/creds.ts","../src/session.ts","../src/commands/login/index.ts","../src/commands/logout/index.ts","../src/commands/signup/actions.ts","../src/commands/signup/index.ts","../src/commands/user/actions.ts","../src/commands/user/index.ts","../src/utils/region.ts","../src/commands/components/command.ts","../src/api.ts","../src/commands/components/pull/actions.ts","../src/commands/components/push/actions.ts","../src/commands/components/pull/index.ts","../src/commands/components/push/graph-operations/dependency-graph.ts","../src/commands/components/push/utils.ts","../src/commands/components/push/progress-display.ts","../src/commands/components/push/graph-operations/resource-processor.ts","../src/commands/components/push/graph-operations/index.ts","../src/commands/components/push/index.ts","../src/commands/languages/actions.ts","../src/commands/languages/index.ts","../src/commands/migrations/command.ts","../src/commands/migrations/generate/actions.ts","../src/commands/migrations/generate/index.ts","../src/commands/stories/actions.ts","../src/commands/migrations/run/actions.ts","../src/commands/migrations/rollback/actions.ts","../src/commands/migrations/run/operations.ts","../src/commands/stories/utils.ts","../src/commands/migrations/run/index.ts","../src/commands/migrations/rollback/index.ts","../src/commands/types/command.ts","../src/utils/storyblok-schemas.ts","../src/commands/types/generate/actions.ts","../src/commands/types/generate/index.ts","../src/commands/datasources/command.ts","../src/commands/datasources/pull/actions.ts","../src/commands/datasources/pull/index.ts","../src/commands/datasources/push/actions.ts","../src/commands/datasources/push/index.ts","../src/commands/datasources/delete/actions.ts","../src/commands/datasources/delete/index.ts","../src/github.ts","../src/commands/create/actions.ts","../src/commands/spaces/actions.ts","../src/commands/create/index.ts","../src/index.ts"],"sourcesContent":["// Please do not change the casing of the commands, it's used for the CLI commands definition\nexport const commands = {\n LOGIN: 'login',\n LOGOUT: 'logout',\n SIGNUP: 'signup',\n USER: 'user',\n COMPONENTS: 'components',\n LANGUAGES: 'languages',\n MIGRATIONS: 'migrations',\n TYPES: 'types',\n DATASOURCES: 'datasources',\n CREATE: 'create',\n} as const;\n\nexport const colorPalette = {\n PRIMARY: '#8d60ff',\n LOGIN: '#dad4ff',\n LOGOUT: '#6d6d6d',\n SIGNUP: '#b6ff6d',\n USER: '#71d300',\n COMPONENTS: '#a185ff',\n LANGUAGES: '#f5c003',\n MIGRATIONS: '#8CE2FF',\n TYPES: '#3178C6',\n CREATE: '#ffb3ba',\n GROUPS: '#4ade80',\n TAGS: '#fbbf24',\n PRESETS: '#a855f7',\n DATASOURCES: '#4ade80',\n} as const;\n\nexport interface ReadonlyArray<T> {\n includes: (searchElement: any, fromIndex?: number) => searchElement is T;\n}\nexport const regionCodes = ['eu', 'us', 'cn', 'ca', 'ap'] as const;\nexport type RegionCode = typeof regionCodes[number];\n\nexport const regions: Record<Uppercase<RegionCode>, RegionCode> = {\n EU: 'eu',\n US: 'us',\n CN: 'cn',\n CA: 'ca',\n AP: 'ap',\n} as const;\n\nexport const regionsDomain: Record<RegionCode, string> = {\n eu: 'api.storyblok.com',\n us: 'api-us.storyblok.com',\n cn: 'app.storyblokchina.cn',\n ca: 'api-ca.storyblok.com',\n ap: 'api-ap.storyblok.com',\n} as const;\n\nexport const managementApiRegions: Record<RegionCode, string> = {\n eu: 'mapi.storyblok.com',\n us: 'mapi-us.storyblok.com',\n cn: 'mapi.storyblokchina.cn',\n ca: 'mapi-ca.storyblok.com',\n ap: 'mapi-ap.storyblok.com',\n} as const;\n\nexport const appDomains: Record<RegionCode, string> = {\n eu: 'app.storyblok.com',\n us: 'app-us.storyblok.com',\n cn: 'app.storyblokchina.cn',\n ca: 'app-ca.storyblok.com',\n ap: 'app-ap.storyblok.com',\n} as const;\n\nexport const regionNames: Record<RegionCode, string> = {\n eu: 'Europe',\n us: 'United States',\n cn: 'China',\n ca: 'Canada',\n ap: 'Australia',\n} as const;\n\nexport const DEFAULT_AGENT = {\n SB_Agent: 'SB-CLI',\n SB_Agent_Version: process.env.npm_package_version || '4.x',\n} as const;\n\nexport interface SpaceOptions {\n spaceId: string;\n}\n","export class FetchError extends Error {\n response: {\n status: number;\n statusText: string;\n data?: Record<string, unknown> | null;\n };\n\n constructor(message: string, response: { status: number; statusText: string; data?: Record<string, unknown> | null }) {\n super(message);\n this.name = 'FetchError';\n this.response = response;\n }\n}\n\nexport const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));\n\nexport interface FetchOptions {\n headers?: Record<string, string>;\n method?: string;\n body?: any;\n maxRetries?: number;\n baseDelay?: number;\n}\n\nexport async function customFetch<T>(url: string, options: FetchOptions = {}): Promise<T & { perPage: number; total: number }> {\n const maxRetries = options.maxRetries ?? 3;\n const baseDelay = options.baseDelay ?? 500; // 500ms base delay\n let attempt = 0;\n\n while (attempt <= maxRetries) {\n try {\n const headers = {\n 'Content-Type': 'application/json',\n ...options.headers,\n };\n\n // Handle JSON body\n const fetchOptions: FetchOptions = {\n ...options,\n headers,\n };\n\n if (options.body) {\n fetchOptions.body = typeof options.body === 'string'\n ? options.body\n : JSON.stringify(options.body);\n }\n\n const response = await fetch(url, fetchOptions);\n let data;\n try {\n // We try to parse the response as JSON\n data = await response.json();\n }\n catch {\n // If it fails, we throw an error\n throw new FetchError(`Non-JSON response`, {\n status: response.status,\n statusText: response.statusText,\n data: null,\n });\n }\n\n if (!response.ok) {\n // If we hit rate limit and have retries left\n if ((response.status === 429) && (attempt < maxRetries)) {\n const waitTime = baseDelay * 2 ** attempt;\n await delay(waitTime);\n attempt++;\n continue;\n }\n\n throw new FetchError(`HTTP error! status: ${response.status}`, {\n status: response.status,\n statusText: response.statusText,\n data,\n });\n }\n\n return {\n ...data,\n perPage: Number(response.headers.get('Per-Page')),\n total: Number(response.headers.get('Total')),\n };\n }\n catch (error) {\n if (error instanceof FetchError) {\n throw error;\n }\n // For network errors or other non-HTTP errors, create a FetchError\n throw new FetchError(error instanceof Error ? error.message : String(error), {\n status: 0,\n statusText: 'Network Error',\n data: null,\n });\n }\n }\n\n throw new FetchError('Max retries exceeded', {\n status: 429,\n statusText: 'Rate Limit Exceeded',\n data: null,\n });\n}\n","import { FetchError } from '../fetch';\n\nexport const API_ACTIONS = {\n login: 'login',\n login_with_token: 'Failed to log in with token',\n login_with_otp: 'Failed to log in with email, password and otp',\n login_email_password: 'Failed to log in with email and password',\n get_user: 'Failed to get user',\n pull_languages: 'Failed to pull languages',\n pull_components: 'Failed to pull components',\n pull_component_groups: 'Failed to pull component groups',\n pull_component_presets: 'Failed to pull component presets',\n pull_component_internal_tags: 'Failed to pull component internal tags',\n push_component: 'Failed to push component',\n push_component_group: 'Failed to push component group',\n push_component_preset: 'Failed to push component preset',\n push_component_internal_tag: 'Failed to push component internal tag',\n update_component: 'Failed to update component',\n update_component_internal_tag: 'Failed to update component internal tag',\n update_component_group: 'Failed to update component group',\n update_component_preset: 'Failed to update component preset',\n pull_stories: 'Failed to pull stories',\n pull_story: 'Failed to pull story',\n update_story: 'Failed to update story',\n pull_datasources: 'Failed to pull datasources',\n push_datasource: 'Failed to push datasource',\n update_datasource: 'Failed to update datasource',\n delete_datasource: 'Failed to delete datasource',\n create_space: 'Failed to create space',\n fetch_blueprints: 'Failed to fetch blueprints from GitHub',\n} as const;\n\nexport const API_ERRORS = {\n unauthorized: 'The user is not authorized to access the API',\n network_error: 'No response from server, please check if you are correctly connected to internet',\n invalid_credentials: 'The provided credentials are invalid',\n timeout: 'The API request timed out',\n generic: 'Error fetching data from the API',\n not_found: 'The requested resource was not found',\n unprocessable_entity: 'The request was well-formed but was unable to be followed due to semantic errors',\n\n} as const;\n\nexport function handleAPIError(action: keyof typeof API_ACTIONS, error: unknown, customMessage?: string): void {\n if (error instanceof FetchError) {\n const status = error.response.status;\n\n switch (status) {\n case 401:\n throw new APIError('unauthorized', action, error, customMessage);\n case 404:\n throw new APIError('not_found', action, error, customMessage);\n case 422:\n throw new APIError('unprocessable_entity', action, error, customMessage);\n default:\n throw new APIError('network_error', action, error, customMessage);\n }\n }\n throw new APIError('generic', action, error as FetchError, customMessage);\n}\n\nexport class APIError extends Error {\n errorId: string;\n cause: string;\n code: number;\n messageStack: string[];\n error: FetchError | undefined;\n response: FetchError['response'] | undefined;\n constructor(errorId: keyof typeof API_ERRORS, action: keyof typeof API_ACTIONS, error?: FetchError, customMessage?: string) {\n super(customMessage || API_ERRORS[errorId]);\n this.name = 'API Error';\n this.errorId = errorId;\n this.cause = API_ERRORS[errorId];\n this.code = error?.response?.status || 0;\n this.messageStack = [];\n this.error = error;\n this.response = error?.response;\n\n if (!customMessage) {\n this.messageStack.push(API_ACTIONS[action]);\n }\n this.messageStack.push(customMessage || API_ERRORS[errorId]);\n\n if (this.code === 422) {\n const responseData = this.response?.data as { [key: string]: string[] } | undefined;\n if (responseData?.name?.[0] === 'has already been taken') {\n this.message = 'A component with this name already exists';\n }\n Object.entries(responseData || {}).forEach(([key, errors]) => {\n if (Array.isArray(errors)) {\n errors.forEach((e) => {\n this.messageStack.push(`${key}: ${e}`);\n });\n }\n });\n }\n }\n\n getInfo() {\n return {\n name: this.name,\n message: this.message,\n httpCode: this.code,\n cause: this.cause,\n errorId: this.errorId,\n stack: this.stack,\n responseData: this.response?.data,\n };\n }\n}\n","export class CommandError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'Command Error';\n }\n\n getInfo() {\n return {\n name: this.name,\n message: this.message,\n stack: this.stack,\n };\n }\n}\n","const FS_ERRORS = {\n file_not_found: 'The file requested was not found',\n permission_denied: 'Permission denied while accessing the file',\n operation_on_directory: 'The operation is not allowed on a directory',\n not_a_directory: 'The path provided is not a directory',\n file_already_exists: 'The file already exists',\n directory_not_empty: 'The directory is not empty',\n too_many_open_files: 'Too many open files',\n no_space_left: 'No space left on the device',\n invalid_argument: 'An invalid argument was provided',\n unknown_error: 'An unknown error occurred',\n} as const;\n\nconst FS_ACTIONS = {\n read: 'Failed to read/parse file:',\n write: 'Writing file',\n delete: 'Deleting file',\n mkdir: 'Creating directory',\n rmdir: 'Removing directory',\n authorization_check: 'Failed to check authorization in .netrc file:',\n} as const;\n\nexport function handleFileSystemError(action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException): void {\n if (error.code) {\n switch (error.code) {\n case 'ENOENT':\n throw new FileSystemError('file_not_found', action, error);\n case 'EACCES':\n case 'EPERM':\n throw new FileSystemError('permission_denied', action, error);\n case 'EISDIR':\n throw new FileSystemError('operation_on_directory', action, error);\n case 'ENOTDIR':\n throw new FileSystemError('not_a_directory', action, error);\n case 'EEXIST':\n throw new FileSystemError('file_already_exists', action, error);\n case 'ENOTEMPTY':\n throw new FileSystemError('directory_not_empty', action, error);\n case 'EMFILE':\n throw new FileSystemError('too_many_open_files', action, error);\n case 'ENOSPC':\n throw new FileSystemError('no_space_left', action, error);\n case 'EINVAL':\n throw new FileSystemError('invalid_argument', action, error);\n default:\n throw new FileSystemError('unknown_error', action, error);\n }\n }\n else {\n // In case the error does not have a known `fs` error code, throw a general error\n throw new FileSystemError('unknown_error', action, error);\n }\n}\n\nexport class FileSystemError extends Error {\n errorId: string;\n cause: string;\n code: string | undefined;\n messageStack: string[];\n error: NodeJS.ErrnoException | undefined;\n\n constructor(errorId: keyof typeof FS_ERRORS, action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException, customMessage?: string) {\n super(customMessage || FS_ERRORS[errorId]);\n this.name = 'File System Error';\n this.errorId = errorId;\n this.cause = FS_ERRORS[errorId];\n this.code = error.code;\n this.messageStack = [];\n this.error = error;\n\n if (!customMessage) {\n this.messageStack.push(FS_ACTIONS[action]);\n }\n this.messageStack.push(customMessage || FS_ERRORS[errorId]);\n }\n\n getInfo() {\n return {\n name: this.name,\n message: this.message,\n code: this.code,\n cause: this.cause,\n errorId: this.errorId,\n stack: this.stack,\n };\n }\n}\n","import { konsola } from '..';\nimport type { FetchError } from '../fetch';\nimport { APIError } from './api-error';\nimport { CommandError } from './command-error';\nimport { FileSystemError } from './filesystem-error';\n\nfunction handleVerboseError(error: unknown): void {\n if (error instanceof CommandError || error instanceof APIError || error instanceof FileSystemError) {\n const errorDetails = 'getInfo' in error ? error.getInfo() : {};\n if (error instanceof CommandError) {\n konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails);\n }\n else if (error instanceof APIError) {\n konsola.error(`API Error: ${error.getInfo().cause}`, errorDetails);\n }\n else if (error instanceof FileSystemError) {\n konsola.error(`File System Error: ${error.getInfo().cause}`, errorDetails);\n }\n else {\n konsola.error(`Unexpected Error: ${error}`, errorDetails);\n }\n }\n else {\n konsola.error(`Unexpected Error`, error);\n }\n}\n\nexport function handleError(error: Error | FetchError, verbose = false): void {\n // Print the message stack if it exists\n if (error instanceof APIError || error instanceof FileSystemError) {\n const messageStack = (error).messageStack;\n messageStack.forEach((message: string, index: number) => {\n konsola.error(message, null, {\n header: index === 0,\n margin: false,\n });\n });\n }\n else {\n konsola.error(error.message, null, {\n header: true,\n });\n }\n if (verbose) {\n handleVerboseError(error);\n }\n else {\n konsola.br();\n konsola.info('For more information about the error, run the command with the `--verbose` flag');\n }\n\n if (!process.env.VITEST) {\n console.log(''); // Add a line break for readability\n // process.exit(1) // Exit process if not in a test environment\n }\n}\n","import { colorPalette } from '../constants';\nimport type { SessionState } from '../session';\nimport { CommandError, handleError } from './error';\nimport chalk from 'chalk';\n\ntype AuthenticatedSessionState = SessionState & {\n isLoggedIn: true;\n password: NonNullable<SessionState['password']>;\n region: NonNullable<SessionState['region']>;\n};\n\n/**\n * Check if user is authenticated and handle error if not\n * @param state - Session state object\n * @param verbose - Whether to show verbose error output\n * @returns true if authenticated, false if not (and error is handled)\n */\nexport function requireAuthentication(state: SessionState, verbose = false): state is AuthenticatedSessionState {\n if (!state.isLoggedIn || !state.password || !state.region) {\n handleError(\n new CommandError(`You are currently not logged in. Please run ${chalk.hex(colorPalette.PRIMARY)('storyblok login')} to authenticate, or ${chalk.hex(colorPalette.PRIMARY)('storyblok signup')} to sign up.`),\n verbose,\n );\n return false;\n }\n return true;\n}\n","export const toPascalCase = (str: string) => {\n return str.replace(/(?:^|_)(\\w)/g, (_, char) => char.toUpperCase());\n};\n\nexport const toCamelCase = (str: string) => {\n return str\n // First replace snake_case\n .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())\n .replace(/_/g, '')\n // Then replace kebab-case\n .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())\n // Capitalize letters after special characters\n .replace(/[^a-z0-9]([a-z])/gi, (_, letter) => letter.toUpperCase())\n // Remove special characters\n .replace(/[^a-z0-9]/gi, '');\n};\n\nexport const toSnakeCase = (str: string) => {\n return str\n .replace(/([A-Z])/g, (_, char) => `_${char.toLowerCase()}`)\n .replace(/^_/, '');\n};\n\nexport const capitalize = (str: string) => {\n return str.charAt(0).toUpperCase() + str.slice(1);\n};\n\n/**\n * Converts kebab-case, snake_case, or camelCase strings to human-readable title case\n * @param str - The string to convert (e.g., \"my-blog-website\", \"my_blog_website\", \"myBlogWebsite\")\n * @returns A human-readable string (e.g., \"My Blog Website\")\n *\n * @example\n * toHumanReadable(\"my-blog-website\") // \"My Blog Website\"\n * toHumanReadable(\"my_react_app\") // \"My React App\"\n * toHumanReadable(\"myVueProject\") // \"My Vue Project\"\n * toHumanReadable(\"simple\") // \"Simple\"\n */\nexport const toHumanReadable = (str: string): string => {\n return str\n // Replace kebab-case and snake_case with spaces\n .replace(/[-_]/g, ' ')\n // Insert space before uppercase letters (for camelCase)\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n // Split into words and capitalize each word\n .split(' ')\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n // Clean up any extra spaces\n .replace(/\\s+/g, ' ')\n .trim();\n};\n\nexport function maskToken(token: string): string {\n // Show only the first 4 characters and replace the rest with asterisks\n if (token.length <= 4) {\n // If the token is too short, just return it as is\n return token;\n }\n const visiblePart = token.slice(0, 4);\n const maskedPart = '*'.repeat(token.length - 4);\n return `${visiblePart}${maskedPart}`;\n}\n\nexport const slugify = (text: string): string =>\n text\n .toString()\n .toLowerCase()\n .replace(/\\s+/g, '-') // Replace spaces with -\n .replace(/[^\\w-]+/g, '') // Remove all non-word chars\n .replace(/-{2,}/g, '-') // Replace multiple - with single -\n .replace(/^-+/, '') // Trim - from start of text\n .replace(/-+$/, '');\n\nexport const removePropertyRecursively = (obj: Record<string, any>, property: string): Record<string, any> => {\n if (typeof obj !== 'object' || obj === null) {\n return obj;\n }\n\n if (Array.isArray(obj)) {\n return obj.map(item => removePropertyRecursively(item, property));\n }\n\n const result: Record<string, any> = {};\n for (const [key, value] of Object.entries(obj)) {\n if (key !== property) {\n result[key] = removePropertyRecursively(value, property);\n }\n }\n return result;\n};\n\n/**\n * Converts an object with potential non-string values to an object with string values\n * for use with URLSearchParams\n *\n * @param obj - The object to convert\n * @returns An object with all values converted to strings\n */\nexport const objectToStringParams = (obj: Record<string, any>): Record<string, string> => {\n return Object.entries(obj).reduce((acc, [key, value]) => {\n // Skip undefined values\n if (value === undefined) {\n return acc;\n }\n\n // Convert objects/arrays to JSON strings\n if (typeof value === 'object' && value !== null) {\n acc[key] = JSON.stringify(value);\n }\n else {\n // Convert other types to strings\n acc[key] = String(value);\n }\n return acc;\n }, {} as Record<string, string>);\n};\n\n/**\n * Creates a regex pattern from a glob pattern\n * @param pattern - The glob pattern to convert\n * @returns A regex that matches the glob pattern\n */\nexport function createRegexFromGlob(pattern: string): RegExp {\n // Add ^ and $ to ensure exact match, escape the pattern to handle special characters\n return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\\\\\*/g, '.*')}$`);\n}\n","import chalk from 'chalk';\nimport { capitalize } from './format';\n\nexport interface KonsolaFormatOptions {\n header?: boolean;\n margin?: boolean;\n}\n\nexport function formatHeader(title: string) {\n return `${title}`;\n}\nexport const konsola = {\n title: (message: string, color: string, subtitle?: string) => {\n if (subtitle) {\n console.log(`${formatHeader(chalk.bgHex(color).bold(` ${capitalize(message)} `))} ${subtitle}`);\n }\n else {\n console.log(formatHeader(chalk.bgHex(color).bold(` ${capitalize(message)} `)));\n }\n console.log(''); // Add a line break\n console.log(''); // Add a line break\n },\n br: () => {\n console.log(''); // Add a line break\n },\n ok: (message?: string, header: boolean = false) => {\n if (header) {\n console.log(''); // Add a line break\n const successHeader = chalk.bgGreen.bold.white(` Success `);\n console.log(formatHeader(successHeader));\n console.log(''); // Add a line break\n }\n\n console.log(message ? `${chalk.green('✔')} ${message}` : '');\n },\n info: (message: string, options: KonsolaFormatOptions = {\n header: false,\n margin: true,\n }) => {\n if (options.header) {\n console.log(''); // Add a line break\n const infoHeader = chalk.bgBlue.bold.white(` Info `);\n console.log(formatHeader(infoHeader));\n }\n\n console.log(message ? `${chalk.blue('ℹ')} ${message}` : '');\n if (options.margin) {\n console.error(''); // Add a line break\n }\n },\n warn: (message?: string, header: boolean = false) => {\n if (header) {\n console.log(''); // Add a line break\n const warnHeader = chalk.bgYellow.bold.black(` Warning `);\n console.warn(formatHeader(warnHeader));\n }\n\n console.warn(message ? `${chalk.yellow('⚠️ ')} ${message}` : '');\n },\n error: (message: string, info?: unknown, options?: KonsolaFormatOptions) => {\n if (options?.header) {\n const errorHeader = chalk.bgRed.bold.white(` Error `);\n console.error(formatHeader(errorHeader));\n console.log(''); // Add a line break\n }\n\n console.error(`${chalk.red.bold('▲ error')} ${message}`, info || '');\n if (options?.margin) {\n console.error(''); // Add a line break\n }\n },\n};\n","import { fileURLToPath } from 'node:url';\nimport { dirname } from 'pathe';\nimport type { RegionCode } from '../constants';\nimport { regions } from '../constants';\n\nexport * from './auth';\nexport * from './error/';\nexport * from './format';\nexport * from './konsola';\n\nexport const __filename = fileURLToPath(import.meta.url);\nexport const __dirname = dirname(__filename);\n\nexport function isRegion(value: RegionCode): value is RegionCode {\n return Object.values(regions).includes(value);\n}\n\nexport function isEmptyObject(obj: object): boolean {\n return Object.keys(obj).length === 0;\n}\n\nexport const isVitest = process.env.VITEST === 'true';\n","// program.ts\nimport { Command } from 'commander';\nimport { __dirname, handleError } from './utils';\nimport type { NormalizedPackageJson } from 'read-package-up';\nimport { readPackageUp } from 'read-package-up';\n\nlet packageJson: NormalizedPackageJson;\n// Read package.json for metadata\nconst result = await readPackageUp({\n cwd: __dirname,\n});\n\nif (!result) {\n console.debug('Metadata not found');\n packageJson = {\n name: 'storyblok',\n description: 'Storyblok CLI',\n version: '0.0.0',\n } as NormalizedPackageJson;\n}\nelse {\n packageJson = result.packageJson;\n}\n\n// Declare a variable to hold the singleton instance\nlet programInstance: Command | null = null;\n\n// Singleton function to get the instance of program\n/**\n * Get the shared program singleton instance\n *\n * @export getProgram\n * @return {*} {Command}\n */\nexport function getProgram(): Command {\n if (!programInstance) {\n programInstance = new Command();\n programInstance\n .name(packageJson.name)\n .description(packageJson.description || '')\n .version(packageJson.version);\n\n // Global error handling\n programInstance.configureOutput({\n writeErr: str => handleError(new Error(str)),\n });\n }\n return programInstance;\n}\n","import type { RegionCode } from '../constants';\nimport { regionsDomain } from '../constants';\n\nconst API_VERSION = 'v1';\n\nexport const getStoryblokUrl = (region: RegionCode = 'eu') => {\n return `https://${regionsDomain[region]}/${API_VERSION}`;\n};\n","import chalk from 'chalk';\nimport type { RegionCode } from '../../constants';\nimport { customFetch, FetchError } from '../../utils/fetch';\nimport { APIError, handleAPIError, maskToken } from '../../utils';\nimport { getStoryblokUrl } from '../../utils/api-routes';\nimport type { StoryblokLoginResponse, StoryblokLoginWithOtpResponse, StoryblokUser } from '../../types';\n\nexport const loginWithToken = async (token: string, region: RegionCode) => {\n try {\n const url = getStoryblokUrl(region);\n return await customFetch<{\n user: StoryblokUser;\n }>(`${url}/users/me`, {\n headers: {\n Authorization: token,\n },\n });\n }\n catch (error) {\n if (error instanceof FetchError) {\n const status = error.response.status;\n\n switch (status) {\n case 401:\n throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid.\n Please make sure you are using the correct token and try again.`);\n default:\n throw new APIError('network_error', 'login_with_token', error);\n }\n }\n throw new APIError('generic', 'login_with_token', error as FetchError, 'The provided credentials are invalid');\n }\n};\n\nexport const loginWithEmailAndPassword = async (email: string, password: string, region: RegionCode) => {\n try {\n const url = getStoryblokUrl(region);\n return await customFetch<StoryblokLoginResponse>(`${url}/users/login`, {\n method: 'POST',\n body: { email, password },\n });\n }\n catch (error) {\n handleAPIError('login_email_password', error, 'The provided credentials are invalid');\n }\n};\n\nexport const loginWithOtp = async (email: string, password: string, otp: string, region: RegionCode) => {\n try {\n const url = getStoryblokUrl(region);\n return await customFetch<StoryblokLoginWithOtpResponse>(`${url}/users/login`, {\n method: 'POST',\n body: { email, password, otp_attempt: otp },\n });\n }\n catch (error) {\n handleAPIError('login_with_otp', error as Error);\n }\n};\n","import { join, parse, resolve } from 'node:path';\nimport { mkdir, readFile as readFileImpl, writeFile } from 'node:fs/promises';\nimport { handleFileSystemError } from './error/filesystem-error';\nimport type { FileReaderResult } from '../types';\nimport filenamify from 'filenamify';\n\nexport interface FileOptions {\n mode?: number;\n}\n\nexport const getStoryblokGlobalPath = () => {\n const homeDirectory = process.env[\n process.platform.startsWith('win') ? 'USERPROFILE' : 'HOME'\n ] || process.cwd();\n\n return join(homeDirectory, '.storyblok');\n};\n\nexport const saveToFile = async (filePath: string, data: string, options?: FileOptions) => {\n // Get the directory path\n const resolvedPath = parse(filePath).dir;\n\n // Ensure the directory exists\n try {\n await mkdir(resolvedPath, { recursive: true });\n }\n catch (mkdirError) {\n handleFileSystemError('mkdir', mkdirError as Error);\n return; // Exit early if the directory creation fails\n }\n\n // Write the file\n try {\n await writeFile(filePath, data, options);\n }\n catch (writeError) {\n handleFileSystemError('write', writeError as Error);\n }\n};\n\nexport const readFile = async (filePath: string) => {\n try {\n return await readFileImpl(filePath, 'utf8');\n }\n catch (error) {\n handleFileSystemError('read', error as Error);\n return '';\n }\n};\n\nexport const resolvePath = (path: string | undefined, folder: string) => {\n // If a custom path is provided, append the folder structure to it\n if (path) {\n return resolve(process.cwd(), path, folder);\n }\n // Otherwise use the default .storyblok path\n return resolve(resolve(process.cwd(), '.storyblok'), folder);\n};\n\n/**\n * Extracts the component name from a migration filename\n * @param filename - The migration filename (e.g., \"simple_component.js\")\n * @returns The component name (e.g., \"simple_component\")\n */\nexport const getComponentNameFromFilename = (filename: string): string => {\n // Remove the .js extension\n return filename.replace(/\\.js$/, '');\n};\n\n/**\n * Sanitizes a string to be safe for use as a filename by removing/replacing problematic characters\n * https://github.com/parshap/node-sanitize-filename/blob/master/index.js\n * @param filename - The filename to sanitize\n * @returns A safe filename string\n */\nexport const sanitizeFilename = (filename: string): string => {\n return filenamify(filename, {\n replacement: '_',\n });\n};\n\nexport async function readJsonFile<T>(filePath: string): Promise<FileReaderResult<T>> {\n try {\n const content = (await readFile(filePath)).toString();\n if (!content) {\n return { data: [] };\n }\n const parsed = JSON.parse(content);\n return { data: Array.isArray(parsed) ? parsed : [parsed] };\n }\n catch (error) {\n return { data: [], error: error as Error };\n }\n}\n","import { access } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { FileSystemError, handleFileSystemError } from './utils';\nimport { getStoryblokGlobalPath, readFile, saveToFile } from './utils/filesystem';\nimport type { StoryblokCredentials } from './types';\n\nexport const getCredentials = async (filePath = join(getStoryblokGlobalPath(), 'credentials.json')): Promise<StoryblokCredentials | null> => {\n try {\n await access(filePath);\n const content = await readFile(filePath);\n const parsedContent = JSON.parse(content);\n\n // Return null if the parsed content is an empty object\n if (Object.keys(parsedContent).length === 0) {\n return null;\n }\n\n return parsedContent;\n }\n catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n // File doesn't exist, create it with empty credentials\n await saveToFile(filePath, JSON.stringify({}, null, 2), { mode: 0o600 });\n return null;\n }\n handleFileSystemError('read', error as NodeJS.ErrnoException);\n return null;\n }\n};\n\nexport const addCredentials = async ({\n filePath = join(getStoryblokGlobalPath(), 'credentials.json'),\n machineName,\n login,\n password,\n region,\n}: Record<string, string>) => {\n const credentials = {\n ...await getCredentials(filePath),\n [machineName]: {\n login,\n password,\n region,\n },\n };\n\n try {\n await saveToFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 });\n }\n catch (error) {\n throw new FileSystemError('invalid_argument', 'write', error as NodeJS.ErrnoException, `Error adding/updating entry for machine ${machineName} in credentials.json file`);\n }\n};\n\nexport const removeAllCredentials = async (filepath: string = getStoryblokGlobalPath()) => {\n const filePath = join(filepath, 'credentials.json');\n await saveToFile(filePath, JSON.stringify({}, null, 2), { mode: 0o600 });\n};\n","// session.ts\nimport { type RegionCode, regionsDomain } from './constants';\nimport { addCredentials, getCredentials } from './creds';\n\nexport interface SessionState {\n isLoggedIn: boolean;\n login?: string;\n password?: string;\n region?: RegionCode;\n envLogin?: boolean;\n}\n\nlet sessionInstance: ReturnType<typeof createSession> | null = null;\n\nfunction createSession() {\n const state: SessionState = {\n isLoggedIn: false,\n };\n\n async function initializeSession() {\n // First, check for environment variables\n const envCredentials = getEnvCredentials();\n if (envCredentials) {\n state.isLoggedIn = true;\n state.login = envCredentials.login;\n state.password = envCredentials.password;\n state.region = envCredentials.region as RegionCode;\n state.envLogin = true;\n return;\n }\n\n // If no environment variables, fall back to .storyblok/credentials.json\n const credentials = await getCredentials();\n if (credentials) {\n // Todo: evaluate this in future when we want to support multiple regions\n const creds = Object.values(credentials)[0];\n state.isLoggedIn = true;\n state.login = creds.login;\n state.password = creds.password;\n state.region = creds.region as RegionCode;\n }\n else {\n // No credentials found; set state to logged out\n state.isLoggedIn = false;\n state.login = undefined;\n state.password = undefined;\n state.region = undefined;\n }\n state.envLogin = false;\n }\n\n function getEnvCredentials() {\n const envLogin = process.env.STORYBLOK_LOGIN || process.env.TRAVIS_STORYBLOK_LOGIN;\n const envPassword = process.env.STORYBLOK_TOKEN || process.env.TRAVIS_STORYBLOK_TOKEN;\n const envRegion = process.env.STORYBLOK_REGION || process.env.TRAVIS_STORYBLOK_REGION;\n\n if (envLogin && envPassword && envRegion) {\n return {\n login: envLogin,\n password: envPassword,\n region: envRegion,\n };\n }\n return null;\n }\n\n async function persistCredentials(region: RegionCode) {\n if (state.isLoggedIn && state.login && state.password && state.region) {\n await addCredentials({\n machineName: regionsDomain[region] || 'api.storyblok.com',\n login: state.login,\n password: state.password,\n region: state.region,\n });\n }\n else {\n throw new Error('No credentials to save.');\n }\n }\n\n function updateSession(login: string, password: string, region: RegionCode) {\n state.isLoggedIn = true;\n state.login = login;\n state.password = password;\n state.region = region;\n }\n\n function logout() {\n state.isLoggedIn = false;\n state.login = undefined;\n state.password = undefined;\n state.region = undefined;\n }\n\n return {\n state,\n initializeSession,\n updateSession,\n persistCredentials,\n logout,\n };\n}\n\nexport function session() {\n if (!sessionInstance) {\n sessionInstance = createSession();\n }\n return sessionInstance;\n}\n","import { Spinner } from '@topcli/spinner';\nimport chalk from 'chalk';\nimport { input, password, select } from '@inquirer/prompts';\nimport type { RegionCode } from '../../constants';\nimport { colorPalette, commands, regionNames, regions } from '../../constants';\nimport { getProgram } from '../../program';\nimport { CommandError, handleError, isRegion, isVitest, konsola } from '../../utils';\nimport { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions';\nimport { session } from '../../session';\n\nconst program = getProgram(); // Get the shared singleton instance\n\nconst allRegionsText = Object.values(regions).join(',');\nconst loginStrategy = {\n message: 'How would you like to login?',\n choices: [\n {\n name: 'With email',\n value: 'login-with-email',\n short: 'Email',\n },\n {\n name: 'With Token (SSO)',\n value: 'login-with-token',\n short: 'Token',\n },\n ],\n};\n\nexport const loginCommand = program\n .command(commands.LOGIN)\n .description('Login to the Storyblok CLI')\n .option('-t, --token <token>', 'Token to login directly without questions, like for CI environments')\n .option(\n '-r, --region <region>',\n `The region you would like to work in. Please keep in mind that the region must match the region of your space. This region flag will be used for the other cli's commands. You can use the values: ${allRegionsText}.`,\n )\n .action(async (options: {\n token: string;\n region: RegionCode;\n }) => {\n konsola.title(`${commands.LOGIN}`, colorPalette.LOGIN);\n // Global options\n const verbose = program.opts().verbose;\n // Command options\n const { token, region } = options;\n\n const { state, updateSession, persistCredentials, initializeSession } = session();\n\n await initializeSession();\n\n if (state.isLoggedIn && !state.envLogin) {\n konsola.ok(`You are already logged in. If you want to login with a different account, please logout first.`);\n return;\n }\n\n if (region && !isRegion(region)) {\n handleError(new CommandError(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`));\n return;\n }\n\n if (token) {\n const spinner = new Spinner({\n verbose: !isVitest,\n });\n try {\n let userRegion = region;\n if (!userRegion) {\n userRegion = await select({\n message: 'Please select the region you would like to work in:',\n choices: Object.values(regions).map((region: RegionCode) => ({\n name: regionNames[region],\n value: region,\n })),\n default: regions.EU,\n });\n }\n spinner.start(`Logging in with token`);\n const { user } = await loginWithToken(token, userRegion);\n updateSession(user.email, token, userRegion);\n await persistCredentials(userRegion);\n spinner.succeed();\n\n konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(user.friendly_name)}.`, true);\n }\n catch (error) {\n spinner.failed();\n konsola.br();\n handleError(error as Error, verbose);\n }\n }\n else {\n const spinner = new Spinner({\n verbose: !isVitest,\n });\n try {\n const strategy = await select(loginStrategy);\n if (strategy === 'login-with-token') {\n const userToken = await password({\n message: 'Please enter your token:',\n validate: (value: string) => {\n return value.length > 0;\n },\n });\n\n let userRegion = region;\n if (!userRegion) {\n userRegion = await select({\n message: 'Please select the region you would like to work in:',\n choices: Object.values(regions).map((region: RegionCode) => ({\n name: regionNames[region],\n value: region,\n })),\n default: regions.EU,\n });\n }\n spinner.start(`Logging in with token`);\n const { user } = await loginWithToken(userToken, userRegion);\n spinner.succeed();\n updateSession(user.email, userToken, userRegion);\n await persistCredentials(userRegion);\n\n konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(user.friendly_name)}.`, true);\n }\n\n else {\n const userEmail = await input({\n message: 'Please enter your email address:',\n required: true,\n validate: (value: string) => {\n const emailRegex = /^[^\\s@]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/;\n return emailRegex.test(value);\n },\n });\n const userPassword = await password({\n message: 'Please enter your password:',\n });\n\n let userRegion = region;\n if (!userRegion) {\n userRegion = await select({\n message: 'Please select the region you would like to work in:',\n choices: Object.values(regions).map((region: RegionCode) => ({\n name: regionNames[region],\n value: region,\n })),\n default: regions.EU,\n });\n }\n\n spinner.start(`Logging in with email`);\n spinner.succeed();\n const response = await loginWithEmailAndPassword(userEmail, userPassword, userRegion);\n\n if (response?.otp_required) {\n const otp = await input({\n message: 'Add the code from your Authenticator app, or the one we sent to your e-mail / phone:',\n required: true,\n });\n\n const otpResponse = await loginWithOtp(userEmail, userPassword, otp, userRegion);\n if (otpResponse?.access_token) {\n updateSession(userEmail, otpResponse?.access_token, userRegion);\n }\n }\n else if (response?.access_token) {\n updateSession(userEmail, response.access_token, userRegion);\n }\n await persistCredentials(region);\n\n konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(userEmail)}.`, true);\n }\n }\n catch (error) {\n spinner.failed();\n konsola.br();\n handleError(error as Error, verbose);\n }\n }\n\n konsola.br();\n });\n","import { removeAllCredentials } from '../../creds';\nimport { colorPalette, commands } from '../../constants';\nimport { getProgram } from '../../program';\nimport { handleError, konsola } from '../../utils';\nimport { session } from '../../session';\n\nconst program = getProgram(); // Get the shared singleton instance\n\nexport const logoutCommand = program\n .command(commands.LOGOUT)\n .description('Logout from the Storyblok CLI')\n .action(async () => {\n konsola.title(`${commands.LOGOUT}`, colorPalette.LOGOUT);\n\n const verbose = program.opts().verbose;\n try {\n const { state, initializeSession } = session();\n await initializeSession();\n if (!state.isLoggedIn || !state.password || !state.region) {\n konsola.warn(`You are already logged out. If you want to login, please use the login command.`);\n konsola.br();\n return;\n }\n await removeAllCredentials();\n\n konsola.ok(`Successfully logged out.`, true);\n konsola.br();\n }\n catch (error) {\n handleError(error as Error, verbose);\n }\n konsola.br();\n });\n","import { exec } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst execAsync = promisify(exec);\n\n/**\n * Build the signup URL with UTM parameters\n */\nexport function buildSignupUrl(): string {\n const baseUrl = 'https://app.storyblok.com';\n\n const utmParams = new URLSearchParams({\n utm_source: 'storyblok-cli',\n utm_medium: 'cli',\n utm_campaign: 'signup',\n });\n\n return `${baseUrl}/#/signup?${utmParams.toString()}`;\n}\n\n/**\n * Open the signup URL in the default browser\n */\nexport async function openSignupInBrowser(url: string): Promise<void> {\n let command: string;\n\n switch (process.platform) {\n case 'darwin':\n command = `open \"${url}\"`;\n break;\n case 'win32':\n command = `start \"\" \"${url}\"`;\n break;\n default:\n command = `xdg-open \"${url}\"`;\n break;\n }\n\n try {\n await execAsync(command);\n }\n catch (error) {\n throw new Error(`Failed to open browser: ${error}`);\n }\n}\n","import chalk from 'chalk';\nimport { colorPalette, commands } from '../../constants';\nimport { getProgram } from '../../program';\nimport { handleError, konsola } from '../../utils';\nimport { buildSignupUrl, openSignupInBrowser } from './actions';\nimport { session } from '../../session';\n\nconst program = getProgram(); // Get the shared singleton instance\n\nexport const signupCommand = program\n .command(commands.SIGNUP)\n .description('Sign up for Storyblok')\n .action(async () => {\n konsola.title(`${commands.SIGNUP}`, colorPalette.SIGNUP);\n // Global options\n const verbose = program.opts().verbose;\n\n const { state, initializeSession } = session();\n\n await initializeSession();\n\n if (state.isLoggedIn && !state.envLogin) {\n konsola.ok(`You are already logged in. If you want to signup with a different account, please logout first.`);\n return;\n }\n\n try {\n // Build the signup URL with UTM parameters\n const signupUrl = buildSignupUrl();\n\n konsola.info(`Opening Storyblok signup page...`);\n konsola.info(`URL: ${chalk.dim(signupUrl)}`);\n\n // Open the browser\n await openSignupInBrowser(signupUrl);\n\n konsola.ok(`Browser opened! Please complete the signup process.`);\n konsola.br();\n konsola.info(`Once you've completed signup, run ${chalk.hex(colorPalette.PRIMARY)('storyblok login')} to authenticate with the CLI.`);\n }\n catch (error) {\n konsola.br();\n handleError(error as Error, verbose);\n }\n\n konsola.br();\n });\n","import chalk from 'chalk';\nimport type { RegionCode } from '../../constants';\nimport { customFetch, FetchError } from '../../utils/fetch';\nimport { APIError, maskToken } from '../../utils';\nimport { getStoryblokUrl } from '../../utils/api-routes';\nimport type { StoryblokUser } from '../../types';\n\nexport const getUser = async (token: string, region: RegionCode) => {\n try {\n const url = getStoryblokUrl(region);\n const response = await customFetch<{\n user: StoryblokUser;\n }>(`${url}/users/me`, {\n headers: {\n Authorization: token,\n },\n });\n return response;\n }\n catch (error) {\n if (error instanceof FetchError) {\n const status = error.response.status;\n\n switch (status) {\n case 401:\n throw new APIError('unauthorized', 'get_user', error, `The token provided ${chalk.bold(maskToken(token))} is invalid.\n Please make sure you are using the correct token and try again.`);\n default:\n throw new APIError('network_error', 'get_user', error);\n }\n }\n throw new APIError('generic', 'get_user', error as FetchError);\n }\n};\n","import chalk from 'chalk';\nimport { colorPalette, commands } from '../../constants';\nimport { getProgram } from '../../program';\nimport { handleError, isVitest, konsola, requireAuthentication } from '../../utils';\nimport { getUser } from './actions';\nimport { session } from '../../session';\nimport { Spinner } from '@topcli/spinner';\n\nconst program = getProgram(); // Get the shared singleton instance\n\nexport const userCommand = program\n .command(commands.USER)\n .description('Get the current user')\n .action(async () => {\n konsola.title(`${commands.USER}`, colorPalette.USER);\n const { state, initializeSession } = session();\n await initializeSession();\n\n if (!requireAuthentication(state)) {\n return;\n }\n const spinner = new Spinner({\n verbose: !isVitest,\n }).start(`Fetching user info`);\n try {\n const { password, region } = state;\n if (!password || !region) {\n throw new Error('No password or region found');\n }\n const { user } = await getUser(password, region);\n spinner.succeed();\n konsola.ok(`Hi ${chalk.bold(user.friendly_name)}, you are currently logged in with ${chalk.hex(colorPalette.PRIMARY)(user.email)} on ${chalk.bold(region)} region`, true);\n }\n catch (error) {\n