UNPKG

storyblok

Version:
1,512 lines (1,482 loc) 171 kB
#!/usr/bin/env node import dotenv from 'dotenv'; import { fileURLToPath } from 'node:url'; import { dirname } from 'pathe'; import chalk from 'chalk'; import { Command } from 'commander'; import { readPackageUp } from 'read-package-up'; import { Spinner } from '@topcli/spinner'; import { select, password, input, confirm } from '@inquirer/prompts'; import fs, { mkdir, writeFile, readFile as readFile$1, access, readdir } from 'node:fs/promises'; import path, { join, parse, resolve } from 'node:path'; import filenamify from 'filenamify'; import { exec, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { getRegion } from '@storyblok/region-helper'; import { minimatch } from 'minimatch'; import { hash } from 'ohash'; import { compile } from 'json-schema-to-typescript'; import { readFileSync } from 'node:fs'; import open from 'open'; import { Octokit } from 'octokit'; const commands = { LOGIN: "login", LOGOUT: "logout", SIGNUP: "signup", USER: "user", COMPONENTS: "components", LANGUAGES: "languages", MIGRATIONS: "migrations", TYPES: "types", DATASOURCES: "datasources", CREATE: "create" }; const colorPalette = { PRIMARY: "#8d60ff", LOGIN: "#dad4ff", LOGOUT: "#6d6d6d", SIGNUP: "#b6ff6d", USER: "#71d300", COMPONENTS: "#a185ff", LANGUAGES: "#f5c003", MIGRATIONS: "#8CE2FF", TYPES: "#3178C6", CREATE: "#ffb3ba", GROUPS: "#4ade80", TAGS: "#fbbf24", PRESETS: "#a855f7", DATASOURCES: "#4ade80" }; const regions = { EU: "eu", US: "us", CN: "cn", CA: "ca", AP: "ap" }; const regionsDomain = { eu: "api.storyblok.com", us: "api-us.storyblok.com", cn: "app.storyblokchina.cn", ca: "api-ca.storyblok.com", ap: "api-ap.storyblok.com" }; const appDomains = { eu: "app.storyblok.com", us: "app-us.storyblok.com", cn: "app.storyblokchina.cn", ca: "app-ca.storyblok.com", ap: "app-ap.storyblok.com" }; const regionNames = { eu: "Europe", us: "United States", cn: "China", ca: "Canada", ap: "Australia" }; ({ SB_Agent_Version: process.env.npm_package_version || "4.x" }); class FetchError extends Error { response; constructor(message, response) { super(message); this.name = "FetchError"; this.response = response; } } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function customFetch(url, options = {}) { const maxRetries = options.maxRetries ?? 3; const baseDelay = options.baseDelay ?? 500; let attempt = 0; while (attempt <= maxRetries) { try { const headers = { "Content-Type": "application/json", ...options.headers }; const fetchOptions = { ...options, headers }; if (options.body) { fetchOptions.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body); } const response = await fetch(url, fetchOptions); let data; try { data = await response.json(); } catch { throw new FetchError(`Non-JSON response`, { status: response.status, statusText: response.statusText, data: null }); } if (!response.ok) { if (response.status === 429 && attempt < maxRetries) { const waitTime = baseDelay * 2 ** attempt; await delay(waitTime); attempt++; continue; } throw new FetchError(`HTTP error! status: ${response.status}`, { status: response.status, statusText: response.statusText, data }); } return { ...data, perPage: Number(response.headers.get("Per-Page")), total: Number(response.headers.get("Total")) }; } catch (error) { if (error instanceof FetchError) { throw error; } throw new FetchError(error instanceof Error ? error.message : String(error), { status: 0, statusText: "Network Error", data: null }); } } throw new FetchError("Max retries exceeded", { status: 429, statusText: "Rate Limit Exceeded", data: null }); } const API_ACTIONS = { login: "login", login_with_token: "Failed to log in with token", login_with_otp: "Failed to log in with email, password and otp", login_email_password: "Failed to log in with email and password", get_user: "Failed to get user", pull_languages: "Failed to pull languages", pull_components: "Failed to pull components", pull_component_groups: "Failed to pull component groups", pull_component_presets: "Failed to pull component presets", pull_component_internal_tags: "Failed to pull component internal tags", push_component: "Failed to push component", push_component_group: "Failed to push component group", push_component_preset: "Failed to push component preset", push_component_internal_tag: "Failed to push component internal tag", update_component: "Failed to update component", update_component_internal_tag: "Failed to update component internal tag", update_component_group: "Failed to update component group", update_component_preset: "Failed to update component preset", pull_stories: "Failed to pull stories", pull_story: "Failed to pull story", update_story: "Failed to update story", pull_datasources: "Failed to pull datasources", push_datasource: "Failed to push datasource", update_datasource: "Failed to update datasource", delete_datasource: "Failed to delete datasource", create_space: "Failed to create space", fetch_blueprints: "Failed to fetch blueprints from GitHub" }; const API_ERRORS = { unauthorized: "The user is not authorized to access the API", network_error: "No response from server, please check if you are correctly connected to internet", invalid_credentials: "The provided credentials are invalid", timeout: "The API request timed out", generic: "Error fetching data from the API", not_found: "The requested resource was not found", unprocessable_entity: "The request was well-formed but was unable to be followed due to semantic errors" }; function handleAPIError(action, error, customMessage) { if (error instanceof FetchError) { const status = error.response.status; switch (status) { case 401: throw new APIError("unauthorized", action, error, customMessage); case 404: throw new APIError("not_found", action, error, customMessage); case 422: throw new APIError("unprocessable_entity", action, error, customMessage); default: throw new APIError("network_error", action, error, customMessage); } } throw new APIError("generic", action, error, customMessage); } class APIError extends Error { errorId; cause; code; messageStack; error; response; constructor(errorId, action, error, customMessage) { super(customMessage || API_ERRORS[errorId]); this.name = "API Error"; this.errorId = errorId; this.cause = API_ERRORS[errorId]; this.code = error?.response?.status || 0; this.messageStack = []; this.error = error; this.response = error?.response; if (!customMessage) { this.messageStack.push(API_ACTIONS[action]); } this.messageStack.push(customMessage || API_ERRORS[errorId]); if (this.code === 422) { const responseData = this.response?.data; if (responseData?.name?.[0] === "has already been taken") { this.message = "A component with this name already exists"; } Object.entries(responseData || {}).forEach(([key, errors]) => { if (Array.isArray(errors)) { errors.forEach((e) => { this.messageStack.push(`${key}: ${e}`); }); } }); } } getInfo() { return { name: this.name, message: this.message, httpCode: this.code, cause: this.cause, errorId: this.errorId, stack: this.stack, responseData: this.response?.data }; } } class CommandError extends Error { constructor(message) { super(message); this.name = "Command Error"; } getInfo() { return { name: this.name, message: this.message, stack: this.stack }; } } const FS_ERRORS = { file_not_found: "The file requested was not found", permission_denied: "Permission denied while accessing the file", operation_on_directory: "The operation is not allowed on a directory", not_a_directory: "The path provided is not a directory", file_already_exists: "The file already exists", directory_not_empty: "The directory is not empty", too_many_open_files: "Too many open files", no_space_left: "No space left on the device", invalid_argument: "An invalid argument was provided", unknown_error: "An unknown error occurred" }; const FS_ACTIONS = { read: "Failed to read/parse file:", write: "Writing file", delete: "Deleting file", mkdir: "Creating directory", rmdir: "Removing directory", authorization_check: "Failed to check authorization in .netrc file:" }; function handleFileSystemError(action, error) { if (error.code) { switch (error.code) { case "ENOENT": throw new FileSystemError("file_not_found", action, error); case "EACCES": case "EPERM": throw new FileSystemError("permission_denied", action, error); case "EISDIR": throw new FileSystemError("operation_on_directory", action, error); case "ENOTDIR": throw new FileSystemError("not_a_directory", action, error); case "EEXIST": throw new FileSystemError("file_already_exists", action, error); case "ENOTEMPTY": throw new FileSystemError("directory_not_empty", action, error); case "EMFILE": throw new FileSystemError("too_many_open_files", action, error); case "ENOSPC": throw new FileSystemError("no_space_left", action, error); case "EINVAL": throw new FileSystemError("invalid_argument", action, error); default: throw new FileSystemError("unknown_error", action, error); } } else { throw new FileSystemError("unknown_error", action, error); } } class FileSystemError extends Error { errorId; cause; code; messageStack; error; constructor(errorId, action, error, customMessage) { super(customMessage || FS_ERRORS[errorId]); this.name = "File System Error"; this.errorId = errorId; this.cause = FS_ERRORS[errorId]; this.code = error.code; this.messageStack = []; this.error = error; if (!customMessage) { this.messageStack.push(FS_ACTIONS[action]); } this.messageStack.push(customMessage || FS_ERRORS[errorId]); } getInfo() { return { name: this.name, message: this.message, code: this.code, cause: this.cause, errorId: this.errorId, stack: this.stack }; } } function handleVerboseError(error) { if (error instanceof CommandError || error instanceof APIError || error instanceof FileSystemError) { const errorDetails = "getInfo" in error ? error.getInfo() : {}; if (error instanceof CommandError) { konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails); } else if (error instanceof APIError) { konsola.error(`API Error: ${error.getInfo().cause}`, errorDetails); } else if (error instanceof FileSystemError) { konsola.error(`File System Error: ${error.getInfo().cause}`, errorDetails); } else { konsola.error(`Unexpected Error: ${error}`, errorDetails); } } else { konsola.error(`Unexpected Error`, error); } } function handleError(error, verbose = false) { if (error instanceof APIError || error instanceof FileSystemError) { const messageStack = error.messageStack; messageStack.forEach((message, index) => { konsola.error(message, null, { header: index === 0, margin: false }); }); } else { konsola.error(error.message, null, { header: true }); } if (verbose) { handleVerboseError(error); } else { konsola.br(); konsola.info("For more information about the error, run the command with the `--verbose` flag"); } if (!process.env.VITEST) { console.log(""); } } function requireAuthentication(state, verbose = false) { if (!state.isLoggedIn || !state.password || !state.region) { handleError( 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.`), verbose ); return false; } return true; } const toPascalCase = (str) => { return str.replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase()); }; const toCamelCase = (str) => { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()).replace(/_/g, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()).replace(/[^a-z0-9]([a-z])/gi, (_, letter) => letter.toUpperCase()).replace(/[^a-z0-9]/gi, ""); }; const capitalize = (str) => { return str.charAt(0).toUpperCase() + str.slice(1); }; const toHumanReadable = (str) => { return str.replace(/[-_]/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ").replace(/\s+/g, " ").trim(); }; function maskToken(token) { if (token.length <= 4) { return token; } const visiblePart = token.slice(0, 4); const maskedPart = "*".repeat(token.length - 4); return `${visiblePart}${maskedPart}`; } const objectToStringParams = (obj) => { return Object.entries(obj).reduce((acc, [key, value]) => { if (value === void 0) { return acc; } if (typeof value === "object" && value !== null) { acc[key] = JSON.stringify(value); } else { acc[key] = String(value); } return acc; }, {}); }; function createRegexFromGlob(pattern) { return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`); } function formatHeader(title) { return `${title}`; } const konsola = { title: (message, color, subtitle) => { if (subtitle) { console.log(`${formatHeader(chalk.bgHex(color).bold(` ${capitalize(message)} `))} ${subtitle}`); } else { console.log(formatHeader(chalk.bgHex(color).bold(` ${capitalize(message)} `))); } console.log(""); console.log(""); }, br: () => { console.log(""); }, ok: (message, header = false) => { if (header) { console.log(""); const successHeader = chalk.bgGreen.bold.white(` Success `); console.log(formatHeader(successHeader)); console.log(""); } console.log(message ? `${chalk.green("\u2714")} ${message}` : ""); }, info: (message, options = { header: false, margin: true }) => { if (options.header) { console.log(""); const infoHeader = chalk.bgBlue.bold.white(` Info `); console.log(formatHeader(infoHeader)); } console.log(message ? `${chalk.blue("\u2139")} ${message}` : ""); if (options.margin) { console.error(""); } }, warn: (message, header = false) => { if (header) { console.log(""); const warnHeader = chalk.bgYellow.bold.black(` Warning `); console.warn(formatHeader(warnHeader)); } console.warn(message ? `${chalk.yellow("\u26A0\uFE0F ")} ${message}` : ""); }, error: (message, info, options) => { if (options?.header) { const errorHeader = chalk.bgRed.bold.white(` Error `); console.error(formatHeader(errorHeader)); console.log(""); } console.error(`${chalk.red.bold("\u25B2 error")} ${message}`, info || ""); if (options?.margin) { console.error(""); } } }; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); function isRegion(value) { return Object.values(regions).includes(value); } const isVitest = process.env.VITEST === "true"; let packageJson; const result = await readPackageUp({ cwd: __dirname }); if (!result) { console.debug("Metadata not found"); packageJson = { name: "storyblok", description: "Storyblok CLI", version: "0.0.0" }; } else { packageJson = result.packageJson; } let programInstance = null; function getProgram() { if (!programInstance) { programInstance = new Command(); programInstance.name(packageJson.name).description(packageJson.description || "").version(packageJson.version); programInstance.configureOutput({ writeErr: (str) => handleError(new Error(str)) }); } return programInstance; } const API_VERSION = "v1"; const getStoryblokUrl = (region = "eu") => { return `https://${regionsDomain[region]}/${API_VERSION}`; }; const loginWithToken = async (token, region) => { try { const url = getStoryblokUrl(region); return await customFetch(`${url}/users/me`, { headers: { Authorization: token } }); } catch (error) { if (error instanceof FetchError) { const status = error.response.status; switch (status) { case 401: throw new APIError("unauthorized", "login_with_token", error, `The token provided ${chalk.bold(maskToken(token))} is invalid. Please make sure you are using the correct token and try again.`); default: throw new APIError("network_error", "login_with_token", error); } } throw new APIError("generic", "login_with_token", error, "The provided credentials are invalid"); } }; const loginWithEmailAndPassword = async (email, password, region) => { try { const url = getStoryblokUrl(region); return await customFetch(`${url}/users/login`, { method: "POST", body: { email, password } }); } catch (error) { handleAPIError("login_email_password", error, "The provided credentials are invalid"); } }; const loginWithOtp = async (email, password, otp, region) => { try { const url = getStoryblokUrl(region); return await customFetch(`${url}/users/login`, { method: "POST", body: { email, password, otp_attempt: otp } }); } catch (error) { handleAPIError("login_with_otp", error); } }; const getStoryblokGlobalPath = () => { const homeDirectory = process.env[process.platform.startsWith("win") ? "USERPROFILE" : "HOME"] || process.cwd(); return join(homeDirectory, ".storyblok"); }; const saveToFile = async (filePath, data, options) => { const resolvedPath = parse(filePath).dir; try { await mkdir(resolvedPath, { recursive: true }); } catch (mkdirError) { handleFileSystemError("mkdir", mkdirError); return; } try { await writeFile(filePath, data, options); } catch (writeError) { handleFileSystemError("write", writeError); } }; const readFile = async (filePath) => { try { return await readFile$1(filePath, "utf8"); } catch (error) { handleFileSystemError("read", error); return ""; } }; const resolvePath = (path, folder) => { if (path) { return resolve(process.cwd(), path, folder); } return resolve(resolve(process.cwd(), ".storyblok"), folder); }; const getComponentNameFromFilename = (filename) => { return filename.replace(/\.js$/, ""); }; const sanitizeFilename = (filename) => { return filenamify(filename, { replacement: "_" }); }; async function readJsonFile(filePath) { try { const content = (await readFile(filePath)).toString(); if (!content) { return { data: [] }; } const parsed = JSON.parse(content); return { data: Array.isArray(parsed) ? parsed : [parsed] }; } catch (error) { return { data: [], error }; } } const getCredentials = async (filePath = join(getStoryblokGlobalPath(), "credentials.json")) => { try { await access(filePath); const content = await readFile(filePath); const parsedContent = JSON.parse(content); if (Object.keys(parsedContent).length === 0) { return null; } return parsedContent; } catch (error) { if (error.code === "ENOENT") { await saveToFile(filePath, JSON.stringify({}, null, 2), { mode: 384 }); return null; } handleFileSystemError("read", error); return null; } }; const addCredentials = async ({ filePath = join(getStoryblokGlobalPath(), "credentials.json"), machineName, login, password, region }) => { const credentials = { ...await getCredentials(filePath), [machineName]: { login, password, region } }; try { await saveToFile(filePath, JSON.stringify(credentials, null, 2), { mode: 384 }); } catch (error) { throw new FileSystemError("invalid_argument", "write", error, `Error adding/updating entry for machine ${machineName} in credentials.json file`); } }; const removeAllCredentials = async (filepath = getStoryblokGlobalPath()) => { const filePath = join(filepath, "credentials.json"); await saveToFile(filePath, JSON.stringify({}, null, 2), { mode: 384 }); }; let sessionInstance = null; function createSession() { const state = { isLoggedIn: false }; async function initializeSession() { const envCredentials = getEnvCredentials(); if (envCredentials) { state.isLoggedIn = true; state.login = envCredentials.login; state.password = envCredentials.password; state.region = envCredentials.region; state.envLogin = true; return; } const credentials = await getCredentials(); if (credentials) { const creds = Object.values(credentials)[0]; state.isLoggedIn = true; state.login = creds.login; state.password = creds.password; state.region = creds.region; } else { state.isLoggedIn = false; state.login = void 0; state.password = void 0; state.region = void 0; } state.envLogin = false; } function getEnvCredentials() { const envLogin = process.env.STORYBLOK_LOGIN || process.env.TRAVIS_STORYBLOK_LOGIN; const envPassword = process.env.STORYBLOK_TOKEN || process.env.TRAVIS_STORYBLOK_TOKEN; const envRegion = process.env.STORYBLOK_REGION || process.env.TRAVIS_STORYBLOK_REGION; if (envLogin && envPassword && envRegion) { return { login: envLogin, password: envPassword, region: envRegion }; } return null; } async function persistCredentials(region) { if (state.isLoggedIn && state.login && state.password && state.region) { await addCredentials({ machineName: regionsDomain[region] || "api.storyblok.com", login: state.login, password: state.password, region: state.region }); } else { throw new Error("No credentials to save."); } } function updateSession(login, password, region) { state.isLoggedIn = true; state.login = login; state.password = password; state.region = region; } function logout() { state.isLoggedIn = false; state.login = void 0; state.password = void 0; state.region = void 0; } return { state, initializeSession, updateSession, persistCredentials, logout }; } function session() { if (!sessionInstance) { sessionInstance = createSession(); } return sessionInstance; } const program$i = getProgram(); const allRegionsText = Object.values(regions).join(","); const loginStrategy = { message: "How would you like to login?", choices: [ { name: "With email", value: "login-with-email", short: "Email" }, { name: "With Token (SSO)", value: "login-with-token", short: "Token" } ] }; program$i.command(commands.LOGIN).description("Login to the Storyblok CLI").option("-t, --token <token>", "Token to login directly without questions, like for CI environments").option( "-r, --region <region>", `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}.` ).action(async (options) => { konsola.title(`${commands.LOGIN}`, colorPalette.LOGIN); const verbose = program$i.opts().verbose; const { token, region } = options; const { state, updateSession, persistCredentials, initializeSession } = session(); await initializeSession(); if (state.isLoggedIn && !state.envLogin) { konsola.ok(`You are already logged in. If you want to login with a different account, please logout first.`); return; } if (region && !isRegion(region)) { handleError(new CommandError(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(" | ")}`)); return; } if (token) { const spinner = new Spinner({ verbose: !isVitest }); try { let userRegion = region; if (!userRegion) { userRegion = await select({ message: "Please select the region you would like to work in:", choices: Object.values(regions).map((region2) => ({ name: regionNames[region2], value: region2 })), default: regions.EU }); } spinner.start(`Logging in with token`); const { user } = await loginWithToken(token, userRegion); updateSession(user.email, token, userRegion); await persistCredentials(userRegion); spinner.succeed(); konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(user.friendly_name)}.`, true); } catch (error) { spinner.failed(); konsola.br(); handleError(error, verbose); } } else { const spinner = new Spinner({ verbose: !isVitest }); try { const strategy = await select(loginStrategy); if (strategy === "login-with-token") { const userToken = await password({ message: "Please enter your token:", validate: (value) => { return value.length > 0; } }); let userRegion = region; if (!userRegion) { userRegion = await select({ message: "Please select the region you would like to work in:", choices: Object.values(regions).map((region2) => ({ name: regionNames[region2], value: region2 })), default: regions.EU }); } spinner.start(`Logging in with token`); const { user } = await loginWithToken(userToken, userRegion); spinner.succeed(); updateSession(user.email, userToken, userRegion); await persistCredentials(userRegion); konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(user.friendly_name)}.`, true); } else { const userEmail = await input({ message: "Please enter your email address:", required: true, validate: (value) => { const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/; return emailRegex.test(value); } }); const userPassword = await password({ message: "Please enter your password:" }); let userRegion = region; if (!userRegion) { userRegion = await select({ message: "Please select the region you would like to work in:", choices: Object.values(regions).map((region2) => ({ name: regionNames[region2], value: region2 })), default: regions.EU }); } spinner.start(`Logging in with email`); spinner.succeed(); const response = await loginWithEmailAndPassword(userEmail, userPassword, userRegion); if (response?.otp_required) { const otp = await input({ message: "Add the code from your Authenticator app, or the one we sent to your e-mail / phone:", required: true }); const otpResponse = await loginWithOtp(userEmail, userPassword, otp, userRegion); if (otpResponse?.access_token) { updateSession(userEmail, otpResponse?.access_token, userRegion); } } else if (response?.access_token) { updateSession(userEmail, response.access_token, userRegion); } await persistCredentials(region); konsola.ok(`Successfully logged in to region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[userRegion]} (${userRegion})`)}. Welcome ${chalk.hex(colorPalette.PRIMARY)(userEmail)}.`, true); } } catch (error) { spinner.failed(); konsola.br(); handleError(error, verbose); } } konsola.br(); }); const program$h = getProgram(); program$h.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => { konsola.title(`${commands.LOGOUT}`, colorPalette.LOGOUT); const verbose = program$h.opts().verbose; try { const { state, initializeSession } = session(); await initializeSession(); if (!state.isLoggedIn || !state.password || !state.region) { konsola.warn(`You are already logged out. If you want to login, please use the login command.`); konsola.br(); return; } await removeAllCredentials(); konsola.ok(`Successfully logged out.`, true); konsola.br(); } catch (error) { handleError(error, verbose); } konsola.br(); }); const execAsync = promisify(exec); function buildSignupUrl() { const baseUrl = "https://app.storyblok.com"; const utmParams = new URLSearchParams({ utm_source: "storyblok-cli", utm_medium: "cli", utm_campaign: "signup" }); return `${baseUrl}/#/signup?${utmParams.toString()}`; } async function openSignupInBrowser(url) { let command; switch (process.platform) { case "darwin": command = `open "${url}"`; break; case "win32": command = `start "" "${url}"`; break; default: command = `xdg-open "${url}"`; break; } try { await execAsync(command); } catch (error) { throw new Error(`Failed to open browser: ${error}`); } } const program$g = getProgram(); program$g.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => { konsola.title(`${commands.SIGNUP}`, colorPalette.SIGNUP); const verbose = program$g.opts().verbose; const { state, initializeSession } = session(); await initializeSession(); if (state.isLoggedIn && !state.envLogin) { konsola.ok(`You are already logged in. If you want to signup with a different account, please logout first.`); return; } try { const signupUrl = buildSignupUrl(); konsola.info(`Opening Storyblok signup page...`); konsola.info(`URL: ${chalk.dim(signupUrl)}`); await openSignupInBrowser(signupUrl); konsola.ok(`Browser opened! Please complete the signup process.`); konsola.br(); konsola.info(`Once you've completed signup, run ${chalk.hex(colorPalette.PRIMARY)("storyblok login")} to authenticate with the CLI.`); } catch (error) { konsola.br(); handleError(error, verbose); } konsola.br(); }); const getUser = async (token, region) => { try { const url = getStoryblokUrl(region); const response = await customFetch(`${url}/users/me`, { headers: { Authorization: token } }); return response; } catch (error) { if (error instanceof FetchError) { const status = error.response.status; switch (status) { case 401: throw new APIError("unauthorized", "get_user", error, `The token provided ${chalk.bold(maskToken(token))} is invalid. Please make sure you are using the correct token and try again.`); default: throw new APIError("network_error", "get_user", error); } } throw new APIError("generic", "get_user", error); } }; const program$f = getProgram(); program$f.command(commands.USER).description("Get the current user").action(async () => { konsola.title(`${commands.USER}`, colorPalette.USER); const { state, initializeSession } = session(); await initializeSession(); if (!requireAuthentication(state)) { return; } const spinner = new Spinner({ verbose: !isVitest }).start(`Fetching user info`); try { const { password, region } = state; if (!password || !region) { throw new Error("No password or region found"); } const { user } = await getUser(password, region); spinner.succeed(); 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); } catch (error) { spinner.failed(); handleError(error, true); } konsola.br(); }); function getRegionFromSpaceId(spaceId) { try { const region = getRegion(spaceId); return region; } catch (error) { console.warn(`Failed to determine region from space ID: ${error}`); return void 0; } } const resolveRegion = async (thisCommand) => { const options = thisCommand.opts(); const spaceId = options.space; if (spaceId) { const { state, initializeSession } = session(); await initializeSession(); const detectedRegion = getRegionFromSpaceId(spaceId); if (detectedRegion) { state.region = detectedRegion; } } }; const program$e = getProgram(); const componentsCommand = program$e.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`).option("-s, --space <space>", "space ID").option("-p, --path <path>", "path to save the file. Default is .storyblok/components").hook("preAction", resolveRegion); let instance = null; const createMapiClient = (options) => { const baseHeaders = { "Content-Type": "application/json", "Authorization": options.token }; const state = { uuid: `mapi-client-${Math.random().toString(36).substring(2, 15)}`, baseHeaders, url: options.url || getStoryblokUrl(options.region), maxRetries: options.maxRetries ?? 6, baseDelay: options.baseDelay ?? 500, freeze: false }; const request = async (path, fetchOptions, attempt = 0, isRateLimitOwner = false) => { if (state.freeze && !isRateLimitOwner) { if (options?.verbose) { console.log(`\u23F3 ${path} - Waiting for rate limit to be resolved`); } await new Promise((resolve) => { const checkFreeze = setInterval(() => { if (!state.freeze) { clearInterval(checkFreeze); resolve(); } }, 50); }); await delay(100 + Math.random() * 400); return request(path, fetchOptions, attempt); } try { if (options?.verbose) { console.log(`${state.url}/${path} - Attempt ${attempt}`); } const requestData = { path, method: fetchOptions?.method || "GET", headers: { ...state.baseHeaders, ...fetchOptions?.headers }, body: fetchOptions?.body }; options?.onRequest?.(requestData); const res = await fetch(`${state.url}/${path}`, { headers: requestData.headers, ...fetchOptions }); let data; if (res.status === 204 || res.headers.get("content-length") === "0") { data = null; } else { try { data = await res.json(); } catch { throw new FetchError("Non-JSON response", { status: res.status, statusText: res.statusText, data: null }); } } options?.onResponse?.({ path, method: requestData.method, status: res.status, data, attempt }); if (res.ok) { if (options?.verbose) { console.log(`\u2705 ${path}`); } return { data, attempt }; } else { throw new FetchError("Request failed", { status: res.status, statusText: res.statusText, data }); } } catch (error) { if (error instanceof FetchError) { if (error.response.status === 429 && attempt < state.maxRetries) { if (options?.verbose) { console.log(`\u274C ${path} - Rate limit exceeded`); } let isOwner = isRateLimitOwner; if (!state.freeze) { state.freeze = true; isOwner = true; } const waitTime = state.baseDelay * 2 ** attempt + Math.random() * 100; await delay(waitTime); try { const result = await request(path, fetchOptions, attempt + 1, isOwner); return result; } finally { if (isOwner && state.freeze) { state.freeze = false; } } } throw error; } if (state.freeze && isRateLimitOwner) { state.freeze = false; } throw new FetchError(error instanceof Error ? error.message : String(error), { status: 0, statusText: "Network Error", data: null }); } }; const get = async (path, fetchOptions) => { return request(path, fetchOptions); }; const post = async (path, fetchOptions) => { return request(path, { ...fetchOptions, method: "POST" }); }; const put = async (path, fetchOptions) => { return request(path, { ...fetchOptions, method: "PUT" }); }; const _delete = async (path, fetchOptions) => { return request(path, { ...fetchOptions, method: "DELETE" }); }; instance = { uuid: state.uuid, get, post, put, delete: _delete, dispose: () => { instance = null; } }; return instance; }; function mapiClient(options) { if (!instance) { instance = createMapiClient(options ?? {}); } return instance; } const fetchComponents = async (space) => { try { const client = mapiClient(); const { data } = await client.get(`spaces/${space}/components`, {}); return data.components; } catch (error) { handleAPIError("pull_components", error); } }; const fetchComponent = async (space, componentName) => { try { const client = mapiClient(); const { data } = await client.get(`spaces/${space}/components?search=${encodeURIComponent(componentName)}`, {}); return data.components?.find((c) => c.name === componentName); } catch (error) { handleAPIError("pull_components", error, `Failed to fetch component ${componentName}`); } }; const fetchComponentGroups = async (space) => { try { const client = mapiClient(); const { data } = await client.get(`spaces/${space}/component_groups`); return data.component_groups; } catch (error) { handleAPIError("pull_component_groups", error); } }; const fetchComponentPresets = async (space) => { try { const client = mapiClient(); const { data } = await client.get(`spaces/${space}/presets`); return data.presets; } catch (error) { handleAPIError("pull_component_presets", error); } }; const fetchComponentInternalTags = async (space) => { try { const client = mapiClient(); const { data } = await client.get(`spaces/${space}/internal_tags`, {}); return data.internal_tags.filter((tag) => tag.object_type === "component"); } catch (error) { handleAPIError("pull_component_internal_tags", error); } }; const saveComponentsToFiles = async (space, spaceData, options) => { const { components = [], groups = [], presets = [], internalTags = [] } = spaceData; const { filename = "components", suffix, path, separateFiles } = options; const resolvedPath = path ? resolve(process.cwd(), path, "components", space) : resolvePath(path, `components/${space}`); try { if (separateFiles) { for (const component of components) { const sanitizedName = sanitizeFilename(component.name); const componentFilePath = join(resolvedPath, suffix ? `${sanitizedName}.${suffix}.json` : `${sanitizedName}.json`); await saveToFile(componentFilePath, JSON.stringify(component, null, 2)); const componentPresets = presets.filter((preset) => preset.component_id === component.id); if (componentPresets.length > 0) { const presetsFilePath = join(resolvedPath, suffix ? `${sanitizedName}.presets.${suffix}.json` : `${sanitizedName}.presets.json`); await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2)); } const groupsFilePath = join(resolvedPath, suffix ? `groups.${suffix}.json` : `groups.json`); await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2)); const internalTagsFilePath = join(resolvedPath, suffix ? `tags.${suffix}.json` : `tags.json`); await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2)); } return; } const componentsFilePath = join(resolvedPath, suffix ? `${filename}.${suffix}.json` : `${filename}.json`); await saveToFile(componentsFilePath, JSON.stringify(components, null, 2)); if (groups.length > 0) { const groupsFilePath = join(resolvedPath, suffix ? `groups.${suffix}.json` : `groups.json`); await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2)); } if (presets.length > 0) { const presetsFilePath = join(resolvedPath, suffix ? `presets.${suffix}.json` : `presets.json`); await saveToFile(presetsFilePath, JSON.stringify(presets, null, 2)); } if (internalTags.length > 0) { const internalTagsFilePath = join(resolvedPath, suffix ? `tags.${suffix}.json` : `tags.json`); await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2)); } } catch (error) { handleFileSystemError("write", error); } }; const pushComponent = async (space, component) => { try { const client = mapiClient(); const { data } = await client.post(`spaces/${space}/components`, { body: JSON.stringify(component) }); return data.component; } catch (error) { handleAPIError("push_component", error, `Failed to push component ${component.name}`); } }; const updateComponent = async (space, componentId, component) => { try { const client = mapiClient(); const { data } = await client.put(`spaces/${space}/components/${componentId}`, { body: JSON.stringify(component) }); return data.component; } catch (error) { handleAPIError("update_component", error, `Failed to update component ${component.name}`); } }; const upsertComponent = async (space, component, existingId) => { if (existingId) { return await updateComponent(space, existingId, component); } else { return await pushComponent(space, component); } }; const pushComponentGroup = async (space, componentGroup) => { try { const client = mapiClient(); const { data } = await client.post(`spaces/${space}/component_groups`, { body: JSON.stringify(componentGroup) }); return data.component_group; } catch (error) { handleAPIError("push_component_group", error, `Failed to push component group ${componentGroup.name}`); } }; const updateComponentGroup = async (space, groupId, componentGroup) => { try { const client = mapiClient(); const { data } = await client.put(`spaces/${space}/component_groups/${groupId}`, { body: JSON.stringify(componentGroup) }); return data.component_group; } catch (error) { handleAPIError("update_component_group", error, `Failed to update component group ${componentGroup.name}`); } }; const upsertComponentGroup = async (space, group, existingId) => { if (existingId) { return await updateComponentGroup(space, existingId, group); } else { return await pushComponentGroup(space, group); } }; const pushComponentPreset = async (space, componentPreset) => { try { const client = mapiClient(); const { data } = await client.post(`spaces/${space}/presets`, { body: JSON.stringify(componentPreset) }); return data.preset; } catch (error) { handleAPIError("push_component_preset", error, `Failed to push component preset ${componentPreset.preset.name}`); } }; const updateComponentPreset = async (space, presetId, componentPreset) => { try { const client = mapiClient(); const { data } = await client.put(`spaces/${space}/presets/${presetId}`, { body: JSON.stringify(componentPreset) }); return data.preset; } catch (error) { handleAPIError("update_component_preset", error, `Failed to update component preset ${componentPreset.preset.name}`); } }; const upsertComponentPreset = async (space, preset, existingId) => { if (existingId) { return await updateComponentPreset(space, existingId, { preset }); } else { return await pushComponentPreset(space, { preset }); } }; const pushComponentInternalTag = async (space, componentInternalTag) => { try { const client = mapiClient(); const { data } = await client.post(`spaces/${space}/internal_tags`, { method: "POST", body: JSON.stringify(componentInternalTag) }); return data.internal_tag; } catch (error) { handleAPIError("push_component_internal_tag", error, `Failed to push component internal tag ${componentInternalTag.name}`); } }; const updateComponentInternalTag = async (space, tagId, componentInternalTag) => { try { const client = mapiClient(); const { data } = await client.put(`spaces/${space}/internal_tags/${tagId}`, { method: "PUT", body: JSON.stringify(componentInternalTag) }); return data.internal_tag; } catch (error) { handleAPIError("update_component_internal_tag", error, `Failed to update component internal tag ${componentInternalTag.name}`); } }; const upsertComponentInternalTag = async (space, tag, existingId) => { if (existingId) { return await updateComponentInternalTag(space, existingId, tag); } else { return await pushComponentInternalTag(space, tag); } }; const readComponentsFiles = async (options) => { const { from, path, separateFiles = false, suffix } = options; const resolvedPath = resolvePath(path, `components/${from}`); try { await readdir(resolvedPath); } catch (error) { const message = `No local components found for space ${chalk.bold(from)}. To push components, you need to pull them first: 1. Pull the components from your source space: ${chalk.cyan(`storyblok components pull --space ${from}`)} 2. Then try pushing again: ${chalk.cyan(`storyblok components push --space <target_space> --from ${from}`)}`; throw new FileSystemError( "file_not_found", "read", error, message ); } if (separateFiles) { return await readSeparateFiles$1(resolvedPath, suffix); } return await readConsolidatedFiles$1(resolvedPath, suffix); }; async function readSeparateFiles$1(resolvedPath, suffix) { const files = await readdir(resolvedPath); const components = []; const presets = []; let groups = []; let internalTags = []; const filteredFiles = files.filter((file) => { if (suffix) { return file.endsWith(`.${suffix}.json`); } else { return !/\.\w+\.json$/.test(file) || file.endsWith(".presets.json"); } }); for (const file of filteredFiles) { const filePath = join(resolvedPath, file); if (file === "groups.json" || file === `groups.${suffix}.json`) { const result = await readJsonFile(filePath); if (result.error) { handleFileSystemError("read", result.error); continue; } groups = result.data; } else if (file === "tags.json" || file === `tags.${suffix}.json`) { const result = await readJsonFile(filePath); if (result.error) { handleFileSystemError("read", result.error); continue; } internalTags = result.data; } else if (file.endsWith(".presets.json") || file.endsWith(`.presets.${suffix}.json`)) { const result = await readJsonFile(filePath); if (result.error) { handleFileSystemError("read", result.error); continue; } presets.push(...result.data); } else if (file.endsWith(".json") || file.endsWith(`${suffix}.json`)) { if (file === "components.json" || file === `components.${suffix}.json`) { continue; } const result = await readJsonFile(filePath); if (result.error) { handleFileSystemError("read", result.error); continue; } components.push(...result.data); } } return { components, groups, presets, internalTags }; } async function readConsolidatedFiles$1(resolvedPath, suffix) { const componentsPath = join(resolvedPath, suffix ? `components.${suffix}.json` : "components.json"); const componentsResult = await readJsonFile(componentsPath); if (componentsResult.error || !componentsResult.data.length) { throw new FileSystemError( "file_not_found", "read", componentsResult.error || new Error("Components file is empty"), `No components found in ${componentsPath}. Please make sure you have pulled the components first.` ); } const [groupsResult, presetsResult, tagsResult] = await Promise.all([ readJsonFile(join(resolvedPath, suffix ? `groups.${suffix}.json` : "groups.json")), readJsonFile(join(resolvedPath, suffix ? `presets.${suffix}.json` : "presets.json")), readJsonFile(join(resolvedPath, suffix ? `tags.${suffix}.json` : "tags.json")) ]); return { components: componentsResult.data, groups: groupsResult.data, presets: presetsResult.data, internalTags: tagsResult.data }; } const program$d = getProgram(); componentsCommand.command("pull [componentName]").option("-f, --filename <filename>", "custom name to be used in file(s) name instead of space id").option("--sf, --separate-files", "Argument to create a single file for each component").opt