storyblok
Version:
Storyblok CLI
1 lines • 827 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","sources":["../src/constants.ts","../src/utils/array.ts","../src/utils/object.ts","../src/lib/config/defaults.ts","../src/lib/config/loader.ts","../src/lib/config/helpers.ts","../src/lib/config/commander.ts","../src/lib/config/options.ts","../src/lib/config/resolver.ts","../src/lib/config/store.ts","../src/utils/fetch.ts","../src/utils/error/api-error.ts","../src/utils/error/command-error.ts","../src/lib/logger/logger.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/package.ts","../src/utils/index.ts","../src/utils/ui.ts","../src/utils/filesystem.ts","../src/lib/reporter/reporter.ts","../src/lib/logger/logger-transport-file.ts","../src/lib/logger/logger-transport-console.ts","../src/creds.ts","../src/session.ts","../src/api.ts","../src/program.ts","../src/utils/api-routes.ts","../src/commands/user/actions.ts","../src/commands/login/actions.ts","../src/commands/login/helpers.ts","../src/commands/login/index.ts","../src/commands/logout/index.ts","../src/commands/signup/actions.ts","../src/commands/signup/index.ts","../src/commands/user/index.ts","../src/commands/components/command.ts","../src/commands/components/loader.ts","../src/commands/components/constants.ts","../src/utils/pagination.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/constants.ts","../src/commands/spaces/actions.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/constants.ts","../src/commands/stories/actions.ts","../src/commands/migrations/run/constants.ts","../src/commands/migrations/run/streams/stories-stream.ts","../src/commands/migrations/run/actions.ts","../src/commands/migrations/rollback/actions.ts","../src/commands/migrations/run/streams/migrations-transform.ts","../src/commands/stories/utils.ts","../src/commands/migrations/run/streams/update-stream.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/utils.ts","../src/commands/types/generate/actions.ts","../src/commands/datasources/push/actions.ts","../src/commands/types/generate/index.ts","../src/commands/datasources/command.ts","../src/commands/datasources/constants.ts","../src/commands/datasources/pull/actions.ts","../src/commands/datasources/pull/index.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/constants.ts","../src/commands/create/actions.ts","../src/commands/create/index.ts","../src/commands/logs/command.ts","../src/commands/logs/list/index.ts","../src/commands/logs/prune/index.ts","../src/commands/reports/command.ts","../src/commands/reports/list/index.ts","../src/commands/reports/prune/index.ts","../src/commands/assets/command.ts","../src/commands/assets/actions.ts","../src/commands/assets/utils.ts","../src/commands/assets/streams.ts","../src/commands/assets/pull/index.ts","../src/commands/stories/ref-mapper.ts","../src/commands/stories/streams.ts","../src/commands/stories/richtext.ts","../src/commands/stories/validate-story.ts","../src/commands/assets/pipelines.ts","../src/commands/assets/push/index.ts","../src/commands/stories/command.ts","../src/commands/stories/pull/index.ts","../src/commands/stories/push/failure-report.ts","../src/commands/stories/push/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 LOGS: 'logs',\n REPORTS: 'reports',\n ASSETS: 'assets',\n STORIES: 'stories',\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 LOGS: '#4ade80',\n REPORTS: '#4ade80',\n ASSETS: '#f97316',\n STORIES: '#a185ff',\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: '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 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\n/**\n * Supported asset file extensions based on Storyblok's accepted MIME types.\n * @see https://www.storyblok.com/docs/concepts/assets\n * @see https://www.storyblok.com/faq/are-there-asset-type-upload-limitations\n */\nexport const SUPPORTED_ASSET_EXTENSIONS = new Set([\n // Images: image/png, image/x-png, image/gif, image/jpeg, image/avif, image/svg+xml, image/webp\n '.jpg',\n '.jpeg',\n '.png',\n '.gif',\n '.webp',\n '.avif',\n '.svg',\n // Video: video/*, application/mp4, application/x-mpegurl, application/vnd.apple.mpegurl\n '.mp4',\n '.mov',\n '.avi',\n '.webm',\n '.wmv',\n '.mkv',\n '.flv',\n '.ogv',\n '.3gp',\n '.m4v',\n '.mpg',\n '.mpeg',\n '.m3u8',\n // Audio: audio/*\n '.mp3',\n '.wav',\n '.ogg',\n '.aac',\n '.flac',\n '.wma',\n '.m4a',\n '.opus',\n // Documents: application/msword, text/plain, application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document\n '.pdf',\n '.doc',\n '.docx',\n '.txt',\n]);\n\nexport const directories = {\n assets: 'assets',\n components: 'components',\n datasources: 'datasources',\n logs: 'logs',\n reports: 'reports',\n stories: 'stories',\n} as const;\n","/**\n * Splits an iterable into batches of at most `size` items, preserving order.\n * Returns `[]` for empty input. If `size <= 0`, returns the full input as a single batch.\n */\nexport const chunk = <T>(items: Iterable<T>, size: number): T[][] => {\n const all = Array.from(items);\n if (all.length === 0) {\n return [];\n }\n if (size <= 0) {\n return [all];\n }\n const chunks: T[][] = [];\n for (let i = 0; i < all.length; i += size) {\n chunks.push(all.slice(i, i + size));\n }\n return chunks;\n};\n","export type PlainObject = Record<string, any>;\n\nexport function isPlainObject(value: unknown): value is PlainObject {\n return Boolean(value) && typeof value === 'object' && !Array.isArray(value);\n}\n\nexport function mergeDeep<T extends PlainObject>(target: T, source?: PlainObject): T {\n if (!isPlainObject(source)) {\n return target;\n }\n\n const targetRecord = target as PlainObject;\n\n for (const [key, value] of Object.entries(source)) {\n if (isPlainObject(value)) {\n const existing = targetRecord[key];\n const base = isPlainObject(existing) ? existing : {};\n targetRecord[key] = mergeDeep(base, value);\n }\n else {\n targetRecord[key] = value;\n }\n }\n\n return target;\n}\n\nexport function isEmptyObject(obj: object): boolean {\n return Object.keys(obj).length === 0;\n}\n","import type { GlobalConfig, ResolvedCliConfig } from './types';\n\nconst BASE_GLOBAL_CONFIG: GlobalConfig = {\n region: undefined,\n space: undefined,\n path: undefined,\n api: {\n maxRetries: 3,\n maxConcurrency: 6,\n },\n log: {\n console: {\n enabled: false,\n level: 'info',\n },\n file: {\n enabled: true,\n level: 'info',\n maxFiles: 10,\n },\n },\n report: {\n enabled: true,\n maxFiles: 10,\n },\n ui: {\n enabled: true,\n },\n verbose: false,\n};\n\n/**\n * Shared immutable default global config for read-only operations.\n * Use this when you only need to read default values without mutation.\n *\n * @export\n * @constant\n */\nexport const DEFAULT_GLOBAL_CONFIG: Readonly<ResolvedCliConfig> = Object.freeze(\n structuredClone(BASE_GLOBAL_CONFIG),\n) as Readonly<ResolvedCliConfig>;\n\n/**\n * Create a new mutable resolved config with default values.\n * Use this when you need to mutate the config.\n *\n * @export\n * @return {ResolvedCliConfig}\n */\nexport function createDefaultResolvedConfig(): ResolvedCliConfig {\n return structuredClone(BASE_GLOBAL_CONFIG) as ResolvedCliConfig;\n}\n","import { loadConfig as c12LoadConfig, SUPPORTED_EXTENSIONS } from 'c12';\n\nexport { SUPPORTED_EXTENSIONS };\n\n/**\n * Load configuration using c12\n * This encapsulates the c12 dependency so it's only referenced within lib/config\n */\nexport async function loadConfig(options: {\n name: string;\n cwd?: string;\n configFile?: string;\n defaults?: Record<string, any>;\n}): Promise<{ config: Record<string, any> | null }> {\n return c12LoadConfig({\n name: options.name,\n cwd: options.cwd,\n configFile: options.configFile,\n defaults: options.defaults || {},\n rcFile: false,\n globalRc: false,\n dotenv: false,\n packageJson: false,\n });\n}\n","import { resolve as resolvePath } from 'pathe';\nimport { existsSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { isPlainObject } from '../../utils/object';\n\nimport { DEFAULT_GLOBAL_CONFIG } from './defaults';\nimport { loadConfig, SUPPORTED_EXTENSIONS } from './loader';\nimport type {\n ApiConfig,\n CommanderCommand,\n CommanderOption,\n ConfigLocation,\n GlobalConfig,\n LogConfig,\n LogConsoleConfig,\n LogFileConfig,\n PlainObject,\n ReportConfig,\n ResolvedCliConfig,\n} from './types';\n\n// Type representing any level of the config hierarchy during path traversal\ntype ConfigLevel = GlobalConfig | ApiConfig | LogConfig | LogConsoleConfig | LogFileConfig | ReportConfig | Record<string, unknown>;\n\nexport const CONFIG_FILE_NAME = 'storyblok.config';\nexport const HIDDEN_CONFIG_DIR = '.storyblok';\nexport const HIDDEN_CONFIG_FILE_NAME = 'config';\n\n// Writes a value into a nested object by following the option path (creating objects along the way).\nexport function setValueAtPath(target: PlainObject, path: string[], value: unknown): void {\n if (!path.length) {\n return;\n }\n let current: PlainObject = target;\n path.forEach((key, index) => {\n if (index === path.length - 1) {\n current[key] = value;\n return;\n }\n if (!isPlainObject(current[key])) {\n current[key] = {};\n }\n current = current[key];\n });\n}\n\n// Reads a nested value described by the option path. Returns undefined when any segment is missing.\nexport function getValueAtPath(source: Record<string, any>, path: string[]): unknown {\n return path.reduce<unknown>((accumulator, key) => {\n if (accumulator === null || typeof accumulator !== 'object') {\n return undefined;\n }\n return (accumulator as Record<string, any>)[key];\n }, source);\n}\n\n// Pick only the direct scalar fields at a module level (nested objects belong to child commands).\nexport function extractDirectValues(input: PlainObject): PlainObject {\n const direct: PlainObject = {};\n for (const [key, value] of Object.entries(input)) {\n if (isPlainObject(value)) {\n continue;\n }\n direct[key] = value;\n }\n return direct;\n}\n\n// Builds the chain of commands from root to the current command (used for scoping defaults/overrides).\nexport function getCommandAncestry(command: CommanderCommand): CommanderCommand[] {\n const chain: CommanderCommand[] = [];\n let current: CommanderCommand | null = command;\n while (current) {\n chain.unshift(current);\n current = current.parent;\n }\n return chain;\n}\n\nexport function getOptionPath(option: CommanderOption): string[] {\n const longFlag = option.long || option.flags.split(',').pop()?.trim();\n if (!longFlag) {\n return [option.attributeName()];\n }\n\n // Remove the -- prefix and check for --no-* negation prefix\n let normalized = longFlag.replace(/^--/, '');\n const isNegated = normalized.startsWith('no-');\n\n // Remove the no- prefix if present (Commander uses --no-* for boolean negation)\n if (isNegated) {\n normalized = normalized.replace(/^no-/, '');\n }\n\n // Split on hyphens to get all segments\n const segments = normalized.split('-');\n const path: string[] = [];\n\n // Dynamically determine path vs property by checking if partial path is an object in DEFAULT_GLOBAL_CONFIG\n // For example, for --log-file-max-files:\n // - Check if 'log' is an object → yes, add to path\n // - Check if 'log.file' is an object → yes, add to path\n // - Check if 'log.file.max' is an object → no, so 'max-files' becomes property 'maxFiles'\n let currentConfig: ConfigLevel = DEFAULT_GLOBAL_CONFIG as ConfigLevel;\n let i = 0;\n\n while (i < segments.length) {\n const segment = segments[i];\n\n // Check if this segment exists as an object in the current config level\n const currentAsRecord = currentConfig as Record<string, unknown>;\n if (currentConfig && isPlainObject(currentAsRecord[segment])) {\n path.push(segment);\n currentConfig = currentAsRecord[segment] as ConfigLevel;\n i++;\n }\n else {\n // Remaining segments form the property name - convert to camelCase\n const remainingSegments = segments.slice(i);\n const camelCased = remainingSegments\n .map((seg, idx) => idx === 0 ? seg : seg.charAt(0).toUpperCase() + seg.slice(1))\n .join('');\n path.push(camelCased);\n break;\n }\n }\n\n return path;\n}\n\nfunction resolveConfigFilePath(cwd: string, configFile: string): string | null {\n for (const ext of SUPPORTED_EXTENSIONS) {\n const candidate = resolvePath(cwd, `${configFile}${ext}`);\n if (existsSync(candidate)) {\n return candidate;\n }\n }\n return null;\n}\n\nasync function loadConfigLayer({ cwd, configFile }: ConfigLocation): Promise<Record<string, any> | null> {\n if (!existsSync(cwd)) {\n return null;\n }\n const filePath = resolveConfigFilePath(cwd, configFile);\n if (!filePath) {\n return null;\n }\n\n // Config loading happens before logger initialization, so we skip logging here\n // The resolved config will be logged later in the process\n\n const { config } = await loadConfig({\n name: 'storyblok',\n cwd,\n configFile,\n });\n return config ?? null;\n}\n\nexport async function loadConfigLayers(): Promise<Record<string, any>[]> {\n const cwd = process.cwd();\n\n // Order: most general (home) first so workspace configs override during merging.\n const locations: ConfigLocation[] = [\n {\n cwd: resolvePath(homedir(), HIDDEN_CONFIG_DIR),\n configFile: HIDDEN_CONFIG_FILE_NAME,\n },\n {\n cwd: resolvePath(cwd, HIDDEN_CONFIG_DIR),\n configFile: HIDDEN_CONFIG_FILE_NAME,\n },\n {\n cwd,\n configFile: CONFIG_FILE_NAME,\n },\n ];\n\n const layers: Record<string, any>[] = [];\n for (const location of locations) {\n const layer = await loadConfigLayer(location);\n if (layer) {\n layers.push(layer);\n }\n }\n // Config loading happens before logger initialization, so we skip logging here\n // The resolved config will be logged later in the process\n return layers;\n}\n\nexport function formatConfigForDisplay(config: ResolvedCliConfig): string {\n return JSON.stringify(config, null, 2);\n}\n","import type { CommanderCommand, PlainObject, ResolvedCliConfig } from './types';\nimport { getOptionPath, getValueAtPath, setValueAtPath } from './helpers';\n\n// Sets defaults. If, at Commander flag-level, some options has defauls, they'll override\n// the ones from the config file defaults\nexport function collectGlobalDefaults(root: CommanderCommand, baseDefaults: PlainObject): PlainObject {\n const defaults = baseDefaults;\n for (const option of root.options) {\n if (option.defaultValue === undefined) {\n continue;\n }\n // Nested global options (api.*, log.*, etc.) are projected into the config tree via path segments.\n setValueAtPath(defaults, getOptionPath(option), option.defaultValue);\n }\n return defaults;\n}\n\nexport function collectLocalDefaults(commands: CommanderCommand[]): PlainObject {\n const defaults: PlainObject = {};\n for (const command of commands) {\n for (const option of command.options) {\n if (option.defaultValue === undefined) {\n continue;\n }\n const attrName = option.attributeName();\n if (!(attrName in defaults)) {\n defaults[attrName] = option.defaultValue;\n }\n }\n }\n return defaults;\n}\n\n// Translate explicit CLI flags into config objects: root options mutate the global tree,\n// nested commands only patch their own local option bag (no deep paths involved).\nexport function applyCliOverrides(commandChain: CommanderCommand[], globalResolved: PlainObject, localResolved: PlainObject): void {\n const [root] = commandChain;\n for (const command of commandChain) {\n const isRoot = command === root;\n for (const option of command.options) {\n const attrName = option.attributeName();\n const source = command.getOptionValueSource(attrName);\n // Skip commander defaults/config hydration so we only react to explicit CLI input.\n if (!source || source === 'default' || source === 'config') {\n continue;\n }\n const value = command.getOptionValue(attrName);\n if (isRoot) {\n setValueAtPath(globalResolved, getOptionPath(option), value);\n // Global CLI overrides must also win over module-level config that may have\n // set the same key in localResolved (e.g. --path overriding modules.*.path).\n delete localResolved[attrName];\n }\n else {\n localResolved[attrName] = value;\n }\n }\n }\n}\n\n// Hydrate Commander options with resolved config so downstream logic can rely on option values.\nexport function applyConfigToCommander(commandChain: CommanderCommand[], resolved: ResolvedCliConfig): void {\n for (const command of commandChain) {\n for (const option of command.options) {\n const attrName = option.attributeName();\n const source = command.getOptionValueSource(attrName);\n // Never overwrite explicit CLI flags; only hydrate values that still come from defaults.\n if (source && source !== 'default' && source !== 'config') {\n continue;\n }\n const value = getValueAtPath(resolved, getOptionPath(option));\n if (value === undefined) {\n continue;\n }\n command.setOptionValueWithSource(attrName, value, 'config');\n }\n }\n}\n","import { DEFAULT_GLOBAL_CONFIG } from './defaults';\nimport type { GlobalOptionDefinition } from './types';\n\nexport function parseNumber(value: string): number {\n const parsed = Number.parseInt(value, 10);\n if (Number.isNaN(parsed)) {\n throw new TypeError(`Invalid number value \"${value}\".`);\n }\n return parsed;\n}\n\nexport const GLOBAL_OPTION_DEFINITIONS: GlobalOptionDefinition[] = [\n {\n flags: '-p, --path <path>',\n description: 'Base directory for file storage (default: .storyblok)',\n defaultValue: DEFAULT_GLOBAL_CONFIG.path,\n },\n {\n flags: '--verbose',\n description: 'Enable verbose output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.verbose,\n },\n {\n flags: '--region <region>',\n description: 'Storyblok region used for API requests',\n defaultValue: DEFAULT_GLOBAL_CONFIG.region,\n },\n {\n flags: '--api-max-retries <number>',\n description: 'Maximum retry attempts for HTTP requests',\n defaultValue: DEFAULT_GLOBAL_CONFIG.api.maxRetries,\n parser: parseNumber,\n },\n {\n flags: '--api-max-concurrency <number>',\n description: 'Maximum concurrent API requests executed by the CLI',\n defaultValue: DEFAULT_GLOBAL_CONFIG.api.maxConcurrency,\n parser: parseNumber,\n },\n // Boolean flags that default to true need both positive and negative forms\n {\n flags: '--log-console-enabled',\n description: 'Enable console logging output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.console.enabled,\n },\n {\n flags: '--no-log-console-enabled',\n description: 'Disable console logging output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.console.enabled,\n },\n {\n flags: '--log-console-level <level>',\n description: 'Console log level threshold',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.console.level,\n },\n {\n flags: '--log-file-enabled',\n description: 'Enable file logging output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.file.enabled,\n },\n {\n flags: '--no-log-file-enabled',\n description: 'Disable file logging output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.file.enabled,\n },\n {\n flags: '--log-file-level <level>',\n description: 'File log level threshold',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.file.level,\n },\n {\n flags: '--log-file-max-files <number>',\n description: 'Maximum amount of log files to keep on disk',\n defaultValue: DEFAULT_GLOBAL_CONFIG.log.file.maxFiles,\n parser: parseNumber,\n },\n {\n flags: '--ui-enabled',\n description: 'Enable UI output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.ui.enabled,\n },\n {\n flags: '--no-ui-enabled',\n description: 'Disable UI output',\n defaultValue: DEFAULT_GLOBAL_CONFIG.ui.enabled,\n },\n {\n flags: '--report-enabled',\n description: 'Enable report generation after command execution',\n defaultValue: DEFAULT_GLOBAL_CONFIG.report.enabled,\n },\n {\n flags: '--no-report-enabled',\n description: 'Disable report generation after command execution',\n defaultValue: DEFAULT_GLOBAL_CONFIG.report.enabled,\n },\n {\n flags: '--report-max-files <number>',\n description: 'Maximum number of report files to keep',\n defaultValue: DEFAULT_GLOBAL_CONFIG.report.maxFiles,\n parser: parseNumber,\n },\n];\n","import type { CommanderCommand, PlainObject, ResolvedCliConfig } from './types';\nimport { createDefaultResolvedConfig } from './defaults';\nimport {\n applyCliOverrides,\n collectGlobalDefaults,\n collectLocalDefaults,\n} from './commander';\nimport {\n extractDirectValues,\n getCommandAncestry,\n loadConfigLayers,\n} from './helpers';\nimport { isPlainObject, mergeDeep } from '../../utils/object';\n\n// Modules are root subcommands that have their own subcommands (e.g. \"components\" has \"pull\"/\"push\").\n// Leaf root commands like \"login\" or \"logout\" are not modules.\nfunction getModuleNames(root: CommanderCommand): Set<string> {\n return new Set(\n root.commands\n .filter(cmd => cmd.commands.length > 0)\n .map(cmd => cmd.name()),\n );\n}\n\nfunction warnUnknownModuleKeys(modules: Record<string, any>, knownKeys: Set<string>): void {\n for (const key of Object.keys(modules)) {\n if (!knownKeys.has(key)) {\n console.warn(`[storyblok] Unknown module \"${key}\" in config file. Known modules: ${[...knownKeys].join(', ')}`);\n }\n }\n}\n\n// Walks the command chain (excluding root) and applies module-specific overrides at each level.\nfunction mergeModuleConfig(target: PlainObject, modulesConfig: Record<string, any>, commands: CommanderCommand[]): void {\n let currentLevel: any = modulesConfig;\n for (const command of commands) {\n if (!isPlainObject(currentLevel)) {\n return;\n }\n const segment = currentLevel[command.name()];\n if (segment === undefined) {\n return;\n }\n if (isPlainObject(segment)) {\n Object.assign(target, extractDirectValues(segment));\n currentLevel = segment;\n }\n else {\n Object.assign(target, { [command.name()]: segment });\n return;\n }\n }\n}\n\nexport async function resolveConfig(\n thisCommand: CommanderCommand,\n ancestry?: CommanderCommand[] | CommanderCommand,\n): Promise<ResolvedCliConfig> {\n // Build the command hierarchy so we can split global vs local defaults and apply overrides in order.\n let commandChain: CommanderCommand[];\n if (Array.isArray(ancestry)) {\n commandChain = ancestry;\n }\n else if (ancestry) {\n commandChain = getCommandAncestry(ancestry);\n }\n else {\n commandChain = getCommandAncestry(thisCommand);\n }\n const [root, ...rest] = commandChain;\n\n // Create default config once and reuse it\n const defaultConfig = createDefaultResolvedConfig();\n const globalResolved = collectGlobalDefaults(root, defaultConfig);\n const localResolved = collectLocalDefaults(rest);\n\n const layers = await loadConfigLayers();\n const knownModuleKeys = getModuleNames(root);\n for (const layer of layers) {\n const { modules, ...globalLayer } = layer;\n // Later layers overwrite earlier ones because loadConfigLayers orders them from general to specific.\n mergeDeep(globalResolved, globalLayer);\n if (modules && isPlainObject(modules)) {\n warnUnknownModuleKeys(modules, knownModuleKeys);\n mergeModuleConfig(localResolved, modules, rest);\n }\n }\n\n applyCliOverrides(commandChain, globalResolved, localResolved);\n\n const resolved = structuredClone(defaultConfig);\n mergeDeep(resolved as PlainObject, globalResolved);\n Object.assign(resolved, localResolved);\n\n if (resolved.space != null) {\n resolved.space = String(resolved.space);\n }\n\n return resolved;\n}\n","import type { ResolvedCliConfig } from './types';\nimport { createDefaultResolvedConfig } from './defaults';\n\n// Singleton snapshot that exposes the last resolved config\nlet activeConfig: ResolvedCliConfig = createDefaultResolvedConfig();\n\nexport function getActiveConfig(): ResolvedCliConfig {\n return activeConfig;\n}\n\nexport function setActiveConfig(config: ResolvedCliConfig): void {\n activeConfig = structuredClone(config);\n}\n\nexport function resetActiveConfig(): void {\n activeConfig = createDefaultResolvedConfig();\n}\n","import { getActiveConfig } from '../lib/config';\n\nexport interface FetchErrorRequest {\n url?: string;\n method?: string;\n}\n\nexport class FetchError extends Error {\n response: {\n status: number;\n statusText: string;\n data?: Record<string, unknown> | null;\n };\n\n request: FetchErrorRequest;\n\n constructor(\n message: string,\n response: { status: number; statusText: string; data?: Record<string, unknown> | null },\n request: FetchErrorRequest = {},\n ) {\n super(message);\n this.name = 'FetchError';\n this.response = response;\n this.request = request;\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 { api } = getActiveConfig(); // live config includes CLI overrides (maxRetries, maxConcurrency, etc.)\n const maxRetries = options.maxRetries ?? api.maxRetries;\n const baseDelay = options.baseDelay ?? 500; // 500ms base delay\n const requestContext: FetchErrorRequest = { url, method: options.method ?? 'GET' };\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 }, requestContext);\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 }, requestContext);\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 }, requestContext);\n }\n }\n\n throw new FetchError('Max retries exceeded', {\n status: 429,\n statusText: 'Rate Limit Exceeded',\n data: null,\n }, requestContext);\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 delete_component_preset: 'Failed to delete component preset',\n pull_stories: 'Failed to pull stories',\n pull_story: 'Failed to pull story',\n create_story: 'Failed to create story',\n update_story: 'Failed to update story',\n pull_asset: 'Failed to pull asset',\n pull_assets: 'Failed to pull assets',\n pull_asset_folder: 'Failed to pull asset folder',\n pull_asset_folders: 'Failed to pull asset folders',\n push_asset_folder: 'Failed to push asset folder',\n push_asset_create: 'Failed to create asset',\n push_asset_update: 'Failed to update asset',\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 delete_datasource_entry: 'Failed to delete datasource entry',\n create_space: 'Failed to create space',\n pull_spaces: 'Failed to pull spaces',\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 server_error: 'The server returned an error',\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} as const;\n\nfunction getErrorId(status: number): keyof typeof API_ERRORS {\n switch (status) {\n case 401:\n return 'unauthorized';\n case 404:\n return 'not_found';\n case 422:\n return 'unprocessable_entity';\n default:\n return status >= 500 ? 'server_error' : 'generic';\n }\n}\n\nexport function handleAPIError(action: keyof typeof API_ACTIONS, error: unknown, customMessage?: string): never {\n if (error instanceof FetchError) {\n const errorId = getErrorId(error.response.status);\n throw new APIError(errorId, action, error, customMessage);\n }\n\n // Handle non-FetchError objects that have a response property (e.g. mapi-client ClientError).\n // ClientError itself doesn't carry request context, but some wrappers attach\n // a `request` field — forward it best-effort so verbose output can show it.\n const response = (error as any)?.response;\n if (response?.status) {\n const reqCandidate = (error as any)?.request;\n const wrappedError = new FetchError(\n response.statusText ?? (error as Error).message,\n { status: response.status, statusText: response.statusText ?? '', data: response.data },\n {\n url: typeof reqCandidate?.url === 'string' ? reqCandidate.url : undefined,\n method: typeof reqCandidate?.method === 'string' ? reqCandidate.method : undefined,\n },\n );\n const errorId = getErrorId(response.status);\n throw new APIError(errorId, action, wrappedError, 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 const request = this.error?.request;\n const hasRequestContext = Boolean(request && (request.url || request.method));\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 ...(hasRequestContext ? { request: { url: request!.url, method: request!.method } } : {}),\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","export type LogLevel = 'error' | 'warn' | 'info' | 'debug';\n\nexport type LogContextValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Error\n | LogContextValue[]\n | { [key: string]: LogContextValue };\n\nexport interface LogContext {\n [key: string]: LogContextValue;\n}\n\nexport interface LogRecord {\n timestamp?: Date;\n level: LogLevel;\n message: string;\n context?: LogContext;\n}\n\nexport interface LogTransport {\n enabled?: boolean;\n level?: LogLevel;\n log: (record: LogRecord) => void;\n}\n\nexport interface LoggerOptions {\n context?: LogContext;\n transports?: LogTransport[];\n}\n\nexport class Logger {\n public transports: LogTransport[] = [];\n public context: LogContext = {};\n\n constructor(options?: LoggerOptions) {\n if (options?.transports) { this.transports = options.transports; }\n if (options?.context) { this.context = options.context; }\n }\n\n public log(level: LogLevel, message: string, context?: LogContext): void {\n const timestamp = new Date();\n const mergedContext = context\n ? { ...this.context, ...context }\n : this.context;\n\n const record: LogRecord = {\n timestamp,\n level,\n message,\n context: Object.keys(mergedContext).length ? mergedContext : undefined,\n };\n\n for (const transport of this.transports) {\n transport.log(record);\n }\n }\n\n public error(message: string, context?: LogContext): void {\n this.log('error', message, context);\n }\n\n public warn(message: string, context?: LogContext): void {\n this.log('warn', message, context);\n }\n\n public info(message: string, context?: LogContext): void {\n this.log('info', message, context);\n }\n\n public debug(message: string, context?: LogContext): void {\n this.log('debug', message, context);\n }\n}\n\nlet loggerInstance: Logger | null = null;\n\nexport function getLogger(options?: LoggerOptions) {\n if (!loggerInstance) {\n loggerInstance = new Logger(options);\n }\n\n return loggerInstance;\n}\n\nexport function setLoggerTransports(transports: LogTransport[]) {\n if (loggerInstance) {\n loggerInstance.transports = transports;\n }\n}\n\nexport function resetLogger() {\n loggerInstance = null;\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 type { LogContext } from '../../lib/logger/logger';\nimport { getLogger } from '../../lib/logger/logger';\nimport { konsola } from '..';\nimport type { FetchError } from '../fetch';\nimport { APIError } from './api-error';\nimport { CommandError } from './command-error';\nimport { FileSystemError } from './filesystem-error';\n\ninterface ErrorWithMessage {\n message: string;\n}\n\nfunction hasMessage(error: unknown): error is ErrorWithMessage {\n return (\n typeof error === 'object'\n && error !== null\n && 'message' in error\n && typeof (error as Record<string, unknown>).message === 'string'\n );\n}\n\nexport function toError(maybeError: unknown) {\n if (maybeError instanceof Error) { return maybeError; }\n if (typeof maybeError === 'string') { return new Error(maybeError); }\n if (hasMessage(maybeError)) { return new Error(maybeError.message); }\n\n try {\n return new Error(JSON.stringify(maybeError));\n }\n catch {\n // fallback in case there's an error stringifying the maybeError\n // like with circular references for example.\n return new Error(String(maybeError));\n }\n}\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, context?: LogContext): 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 }\n getLogger().error(error.message, { error, errorCode: 'code' in error ? String(error.code) : 'UNKNOWN_ERROR', context });\n}\n\nexport function logOnlyError(error: Error | FetchError, context?: LogContext): void {\n getLogger().error(error.message, { error, errorCode: 'code' in error ? String(error.code) : 'UNKNOWN_ERROR', context });\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 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 toPascalCase = (str: string) => {\n const camelCase = toCamelCase(str);\n return camelCase ? camelCase[0].toUpperCase() + camelCase.slice(1) : camelCase;\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>