storyblok 4.17.2 → 4.18.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { loadConfig as loadConfig$1, SUPPORTED_EXTENSIONS } from 'c12';
8
8
  import chalk from 'chalk';
9
9
  import { readPackageUp } from 'read-package-up';
10
- import { Command } from 'commander';
10
+ import { Command, Option } from 'commander';
11
11
  import { MultiBar, Presets } from 'cli-progress';
12
12
  import { Spinner } from '@topcli/spinner';
13
13
  import fs, { mkdir, writeFile, access, constants, readFile as readFile$1, appendFile, readdir, unlink } from 'node:fs/promises';
@@ -26,6 +26,7 @@ import { Octokit } from 'octokit';
26
26
  import { pipeline as pipeline$1 } from 'node:stream/promises';
27
27
  import { Buffer } from 'node:buffer';
28
28
  import Storyblok from 'storyblok-js-client';
29
+ import { createTwoFilesPatch } from 'diff';
29
30
 
30
31
  const commands = {
31
32
  LOGIN: "login",
@@ -41,7 +42,8 @@ const commands = {
41
42
  LOGS: "logs",
42
43
  REPORTS: "reports",
43
44
  ASSETS: "assets",
44
- STORIES: "stories"
45
+ STORIES: "stories",
46
+ SCHEMA: "schema"
45
47
  };
46
48
  const colorPalette = {
47
49
  PRIMARY: "#8d60ff",
@@ -61,7 +63,8 @@ const colorPalette = {
61
63
  LOGS: "#4ade80",
62
64
  REPORTS: "#4ade80",
63
65
  ASSETS: "#f97316",
64
- STORIES: "#a185ff"
66
+ STORIES: "#a185ff",
67
+ SCHEMA: "#e91e63"
65
68
  };
66
69
  const regions = {
67
70
  EU: "eu",
@@ -1005,11 +1008,11 @@ function requireAuthentication(state, verbose = false) {
1005
1008
  return true;
1006
1009
  }
1007
1010
 
1008
- const toCamelCase = (str) => {
1011
+ const toCamelCase$1 = (str) => {
1009
1012
  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, "");
1010
1013
  };
1011
1014
  const toPascalCase = (str) => {
1012
- const camelCase = toCamelCase(str);
1015
+ const camelCase = toCamelCase$1(str);
1013
1016
  return camelCase ? camelCase[0].toUpperCase() + camelCase.slice(1) : camelCase;
1014
1017
  };
1015
1018
  const capitalize = (str) => {
@@ -1107,6 +1110,25 @@ function getPackageJson() {
1107
1110
  return packageJson$1;
1108
1111
  }
1109
1112
 
1113
+ async function fetchAllPages(fetchFunction, extractDataFunction) {
1114
+ const items = [];
1115
+ let page = 1;
1116
+ while (true) {
1117
+ const { data, response } = await fetchFunction(page);
1118
+ const totalHeader = response.headers.get("total");
1119
+ const fetchedItems = extractDataFunction(data);
1120
+ items.push(...fetchedItems);
1121
+ if (!totalHeader) {
1122
+ return items;
1123
+ }
1124
+ const total = Number(totalHeader);
1125
+ if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
1126
+ return items;
1127
+ }
1128
+ page++;
1129
+ }
1130
+ }
1131
+
1110
1132
  const __filename$1 = fileURLToPath(import.meta.url);
1111
1133
  const __dirname$1 = dirname(__filename$1);
1112
1134
  function isRegion(value) {
@@ -1195,6 +1217,10 @@ class UI {
1195
1217
  this.br();
1196
1218
  }
1197
1219
  }
1220
+ /** Plain console.log passthrough — use for preformatted or multi-line text. */
1221
+ log(message) {
1222
+ this.console?.log(message);
1223
+ }
1198
1224
  list(items) {
1199
1225
  for (const item of items) {
1200
1226
  this.console?.log(` ${item}`);
@@ -2101,14 +2127,14 @@ async function performInteractiveLogin(options) {
2101
2127
  }
2102
2128
  }
2103
2129
 
2104
- const program$e = getProgram();
2130
+ const program$f = getProgram();
2105
2131
  const allRegionsText = Object.values(regions).join(",");
2106
- program$e.command(commands.LOGIN).description("Login to the Storyblok CLI").option("-t, --token <token>", "Token to login directly without questions, like for CI environments").option(
2132
+ program$f.command(commands.LOGIN).description("Login to the Storyblok CLI").option("-t, --token <token>", "Token to login directly without questions, like for CI environments").option(
2107
2133
  "-r, --region <region>",
2108
2134
  `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}.`
2109
2135
  ).action(async (options) => {
2110
2136
  konsola.title(`${commands.LOGIN}`, colorPalette.LOGIN);
2111
- const verbose = program$e.opts().verbose;
2137
+ const verbose = program$f.opts().verbose;
2112
2138
  const { token, region } = options;
2113
2139
  const { state, updateSession, persistCredentials } = session();
2114
2140
  if (state.isLoggedIn && !state.envLogin) {
@@ -2166,10 +2192,10 @@ program$e.command(commands.LOGIN).description("Login to the Storyblok CLI").opti
2166
2192
  konsola.br();
2167
2193
  });
2168
2194
 
2169
- const program$d = getProgram();
2170
- program$d.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2195
+ const program$e = getProgram();
2196
+ program$e.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2171
2197
  konsola.title(`${commands.LOGOUT}`, colorPalette.LOGOUT);
2172
- const verbose = program$d.opts().verbose;
2198
+ const verbose = program$e.opts().verbose;
2173
2199
  try {
2174
2200
  const { state } = session();
2175
2201
  if (!state.isLoggedIn || !state.password || !state.region) {
@@ -2216,10 +2242,10 @@ async function openSignupInBrowser(url) {
2216
2242
  }
2217
2243
  }
2218
2244
 
2219
- const program$c = getProgram();
2220
- program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2245
+ const program$d = getProgram();
2246
+ program$d.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2221
2247
  konsola.title(`${commands.SIGNUP}`, colorPalette.SIGNUP);
2222
- const verbose = program$c.opts().verbose;
2248
+ const verbose = program$d.opts().verbose;
2223
2249
  const { state } = session();
2224
2250
  if (state.isLoggedIn && !state.envLogin) {
2225
2251
  konsola.ok(`You are already logged in. If you want to signup with a different account, please logout first.`);
@@ -2240,10 +2266,10 @@ program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(a
2240
2266
  konsola.br();
2241
2267
  });
2242
2268
 
2243
- const program$b = getProgram();
2244
- program$b.command(commands.USER).description("Get the current user").action(async () => {
2269
+ const program$c = getProgram();
2270
+ program$c.command(commands.USER).description("Get the current user").action(async () => {
2245
2271
  konsola.title(`${commands.USER}`, colorPalette.USER);
2246
- const verbose = program$b.opts().verbose;
2272
+ const verbose = program$c.opts().verbose;
2247
2273
  const { state } = session();
2248
2274
  if (!requireAuthentication(state)) {
2249
2275
  return;
@@ -2271,48 +2297,24 @@ program$b.command(commands.USER).description("Get the current user").action(asyn
2271
2297
  konsola.br();
2272
2298
  });
2273
2299
 
2274
- const program$a = getProgram();
2275
- const componentsCommand = program$a.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2300
+ const program$b = getProgram();
2301
+ const componentsCommand = program$b.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2276
2302
 
2277
2303
  const DEFAULT_COMPONENTS_FILENAME = "components";
2278
- const DEFAULT_GROUPS_FILENAME = "groups";
2304
+ const DEFAULT_GROUPS_FILENAME$1 = "groups";
2279
2305
  const DEFAULT_PRESETS_FILENAME = "presets";
2280
2306
  const DEFAULT_TAGS_FILENAME = "tags";
2281
2307
 
2282
- async function fetchAllPages(fetchFunction, extractDataFunction) {
2283
- const items = [];
2284
- let page = 1;
2285
- while (true) {
2286
- const { data, response } = await fetchFunction(page);
2287
- const totalHeader = response.headers.get("total");
2288
- const fetchedItems = extractDataFunction(data);
2289
- items.push(...fetchedItems);
2290
- if (!totalHeader) {
2291
- return items;
2292
- }
2293
- const total = Number(totalHeader);
2294
- if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
2295
- return items;
2296
- }
2297
- page++;
2298
- }
2299
- }
2300
-
2301
2308
  const fetchComponents = async (spaceId) => {
2302
2309
  try {
2303
2310
  const client = getMapiClient();
2304
- return await fetchAllPages(
2305
- (page) => client.components.list({
2306
- path: {
2307
- space_id: Number(spaceId)
2308
- },
2309
- query: {
2310
- page
2311
- },
2312
- throwOnError: true
2313
- }),
2314
- (data) => data?.components ?? []
2315
- );
2311
+ const { data } = await client.components.list({
2312
+ path: {
2313
+ space_id: Number(spaceId)
2314
+ },
2315
+ throwOnError: true
2316
+ });
2317
+ return data?.components ?? [];
2316
2318
  } catch (error) {
2317
2319
  handleAPIError("pull_components", error);
2318
2320
  }
@@ -2320,19 +2322,16 @@ const fetchComponents = async (spaceId) => {
2320
2322
  const fetchComponent = async (spaceId, componentName) => {
2321
2323
  try {
2322
2324
  const client = getMapiClient();
2323
- const matches = await fetchAllPages(
2324
- (page) => client.components.list({
2325
- path: {
2326
- space_id: Number(spaceId)
2327
- },
2328
- query: {
2329
- page,
2330
- search: componentName
2331
- },
2332
- throwOnError: true
2333
- }),
2334
- (data) => data?.components ?? []
2335
- );
2325
+ const { data } = await client.components.list({
2326
+ path: {
2327
+ space_id: Number(spaceId)
2328
+ },
2329
+ query: {
2330
+ search: componentName
2331
+ },
2332
+ throwOnError: true
2333
+ });
2334
+ const matches = data?.components ?? [];
2336
2335
  return matches.find((c) => c.name === componentName);
2337
2336
  } catch (error) {
2338
2337
  handleAPIError("pull_components", error, `Failed to fetch component ${componentName}`);
@@ -2399,7 +2398,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2399
2398
  const presetsFilePath = join(resolvedPath, suffix ? `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.${suffix}.json` : `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.json`);
2400
2399
  await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2));
2401
2400
  }
2402
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2401
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2403
2402
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2404
2403
  const internalTagsFilePath = join(resolvedPath, suffix ? `${DEFAULT_TAGS_FILENAME}.${suffix}.json` : `${DEFAULT_TAGS_FILENAME}.json`);
2405
2404
  await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2));
@@ -2409,7 +2408,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2409
2408
  const componentsFilePath = join(resolvedPath, suffix ? `${filename}.${suffix}.json` : `${filename}.json`);
2410
2409
  await saveToFile(componentsFilePath, JSON.stringify(components, null, 2));
2411
2410
  if (groups.length > 0) {
2412
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2411
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2413
2412
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2414
2413
  }
2415
2414
  if (presets.length > 0) {
@@ -2425,6 +2424,21 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2425
2424
  }
2426
2425
  };
2427
2426
 
2427
+ function isSchemaField$1(value) {
2428
+ return typeof value === "object" && value !== null && "type" in value;
2429
+ }
2430
+ function toWritableSchema(schema) {
2431
+ if (!schema) {
2432
+ return void 0;
2433
+ }
2434
+ const result = {};
2435
+ for (const [key, value] of Object.entries(schema)) {
2436
+ if (isSchemaField$1(value)) {
2437
+ result[key] = value;
2438
+ }
2439
+ }
2440
+ return result;
2441
+ }
2428
2442
  const pushComponent = async (space, component) => {
2429
2443
  try {
2430
2444
  const client = getMapiClient();
@@ -2459,10 +2473,23 @@ const updateComponent = async (space, componentId, component) => {
2459
2473
  }
2460
2474
  };
2461
2475
  const upsertComponent = async (space, component, existingId) => {
2476
+ const { name, display_name, schema, is_root, is_nestable, component_group_uuid, color, icon, preview_field, internal_tag_ids } = component;
2477
+ const payload = {
2478
+ name,
2479
+ display_name,
2480
+ schema: toWritableSchema(schema),
2481
+ is_root,
2482
+ is_nestable,
2483
+ component_group_uuid: component_group_uuid ?? void 0,
2484
+ color: color ?? void 0,
2485
+ icon: icon ?? void 0,
2486
+ preview_field: preview_field ?? void 0,
2487
+ internal_tag_ids
2488
+ };
2462
2489
  if (existingId) {
2463
- return await updateComponent(space, existingId, component);
2490
+ return await updateComponent(space, existingId, payload);
2464
2491
  } else {
2465
- return await pushComponent(space, component);
2492
+ return await pushComponent(space, payload);
2466
2493
  }
2467
2494
  };
2468
2495
  const pushComponentGroup = async (space, componentGroup) => {
@@ -2565,7 +2592,7 @@ const pushComponentInternalTag = async (space, componentInternalTag) => {
2565
2592
  path: {
2566
2593
  space_id: Number(space)
2567
2594
  },
2568
- body: componentInternalTag,
2595
+ body: { internal_tag: componentInternalTag },
2569
2596
  throwOnError: true
2570
2597
  });
2571
2598
  return data.internal_tag;
@@ -2580,7 +2607,7 @@ const updateComponentInternalTag = async (space, tagId, componentInternalTag) =>
2580
2607
  path: {
2581
2608
  space_id: Number(space)
2582
2609
  },
2583
- body: componentInternalTag,
2610
+ body: { internal_tag: componentInternalTag },
2584
2611
  throwOnError: true
2585
2612
  });
2586
2613
  return data.internal_tag;
@@ -2635,7 +2662,7 @@ async function readSeparateFiles$1(resolvedPath, suffix) {
2635
2662
  });
2636
2663
  for (const file of filteredFiles) {
2637
2664
  const filePath = join(resolvedPath, file);
2638
- if (file === `${DEFAULT_GROUPS_FILENAME}.json` || file === `${DEFAULT_GROUPS_FILENAME}.${suffix}.json`) {
2665
+ if (file === `${DEFAULT_GROUPS_FILENAME$1}.json` || file === `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json`) {
2639
2666
  const result = await readJsonFile(filePath);
2640
2667
  if (result.error) {
2641
2668
  handleFileSystemError("read", result.error);
@@ -2687,7 +2714,7 @@ async function readConsolidatedFiles$1(resolvedPath, suffix) {
2687
2714
  );
2688
2715
  }
2689
2716
  const [groupsResult, presetsResult, tagsResult] = await Promise.all([
2690
- readJsonFile(join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`)),
2717
+ readJsonFile(join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`)),
2691
2718
  readJsonFile(join(resolvedPath, suffix ? `${DEFAULT_PRESETS_FILENAME}.${suffix}.json` : `${DEFAULT_PRESETS_FILENAME}.json`)),
2692
2719
  readJsonFile(join(resolvedPath, suffix ? `${DEFAULT_TAGS_FILENAME}.${suffix}.json` : `${DEFAULT_TAGS_FILENAME}.json`))
2693
2720
  ]);
@@ -3945,8 +3972,8 @@ const saveLanguagesToFile = async (space, internationalizationOptions, options)
3945
3972
  }
3946
3973
  };
3947
3974
 
3948
- const program$9 = getProgram();
3949
- const languagesCommand = program$9.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
3975
+ const program$a = getProgram();
3976
+ const languagesCommand = program$a.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
3950
3977
  const pullCmd$3 = languagesCommand.command("pull").description(`Download your space's languages schema as json`).option("-f, --filename <filename>", "filename to save the file as <filename>.<suffix>.json").option("--su, --suffix <suffix>", "suffix to add to the file name (e.g. languages.<suffix>.json). By default, the space ID is used.").option("-s, --space <space>", "space ID");
3951
3978
  pullCmd$3.action(async (options, command) => {
3952
3979
  konsola.title(`${commands.LANGUAGES}`, colorPalette.LANGUAGES);
@@ -3992,8 +4019,8 @@ pullCmd$3.action(async (options, command) => {
3992
4019
  konsola.br();
3993
4020
  });
3994
4021
 
3995
- const program$8 = getProgram();
3996
- const migrationsCommand = program$8.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
4022
+ const program$9 = getProgram();
4023
+ const migrationsCommand = program$9.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
3997
4024
 
3998
4025
  const getMigrationTemplate = () => {
3999
4026
  return `export default function (block) {
@@ -5020,8 +5047,8 @@ rollbackCmd.action(async (migrationFile, _options, command) => {
5020
5047
  }
5021
5048
  });
5022
5049
 
5023
- const program$7 = getProgram();
5024
- const typesCommand = program$7.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5050
+ const program$8 = getProgram();
5051
+ const typesCommand = program$8.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5025
5052
 
5026
5053
  const getAssetJSONSchema = (title) => ({
5027
5054
  $id: "#/asset",
@@ -5632,7 +5659,7 @@ const getPropertyTypeAnnotation = (property, prefix, suffix) => {
5632
5659
  }
5633
5660
  };
5634
5661
  function getStoryType(property, prefix, suffix) {
5635
- return `${STORY_TYPE}<${prefix ?? ""}${capitalize(toCamelCase(property))}${suffix ?? ""}>`;
5662
+ return `${STORY_TYPE}<${prefix ?? ""}${capitalize(toCamelCase$1(property))}${suffix ?? ""}>`;
5636
5663
  }
5637
5664
  const getComponentType = (componentName, options) => {
5638
5665
  const prefix = options.typePrefix ?? "";
@@ -6157,8 +6184,8 @@ generateCmd.action(async (options, command) => {
6157
6184
  }
6158
6185
  });
6159
6186
 
6160
- const program$6 = getProgram();
6161
- const datasourcesCommand = program$6.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6187
+ const program$7 = getProgram();
6188
+ const datasourcesCommand = program$7.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6162
6189
 
6163
6190
  const fetchDatasourceEntries = async (spaceId, datasourceId) => {
6164
6191
  try {
@@ -6709,13 +6736,13 @@ async function promptForLogin(verbose) {
6709
6736
  return null;
6710
6737
  }
6711
6738
  }
6712
- const program$5 = getProgram();
6713
- program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`Scaffold a new project using Storyblok`).option("-t, --template <template>", "technology starter template").option("-b, --blueprint <blueprint>", "[DEPRECATED] use --template instead").option("--skip-space", "skip space creation").option("--token <token>", "Storyblok access token (skip space creation and use this token)").option(
6739
+ const program$6 = getProgram();
6740
+ program$6.command(`${commands.CREATE} [project-path]`).alias("c").description(`Scaffold a new project using Storyblok`).option("-t, --template <template>", "technology starter template").option("-b, --blueprint <blueprint>", "[DEPRECATED] use --template instead").option("--skip-space", "skip space creation").option("--token <token>", "Storyblok access token (skip space creation and use this token)").option(
6714
6741
  "-r, --region <region>",
6715
6742
  `The region to apply to the generated project template (does not affect space creation).`
6716
6743
  ).action(async (projectPath, options) => {
6717
6744
  konsola.title(`${commands.CREATE}`, colorPalette.CREATE);
6718
- const verbose = program$5.opts().verbose;
6745
+ const verbose = program$6.opts().verbose;
6719
6746
  const { template, blueprint, token } = options;
6720
6747
  if (options.region && !isRegion(options.region)) {
6721
6748
  handleError(new CommandError(`The provided region: ${options.region} is not valid. Please use one of the following values: ${Object.values(regions).join(" | ")}`));
@@ -6934,8 +6961,8 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
6934
6961
  konsola.br();
6935
6962
  });
6936
6963
 
6937
- const program$4 = getProgram();
6938
- const logsCommand = program$4.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
6964
+ const program$5 = getProgram();
6965
+ const logsCommand = program$5.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
6939
6966
 
6940
6967
  const listCmd$1 = logsCommand.command("list").description("List logs").option("-s, --space <space>", "space ID");
6941
6968
  listCmd$1.action(async (_options, command) => {
@@ -6960,8 +6987,8 @@ pruneCmd$1.action(async (options, command) => {
6960
6987
  ui.info(`Deleted ${deletedFilesCount} log file${deletedFilesCount === 1 ? "" : "s"}`);
6961
6988
  });
6962
6989
 
6963
- const program$3 = getProgram();
6964
- const reportsCommand = program$3.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
6990
+ const program$4 = getProgram();
6991
+ const reportsCommand = program$4.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
6965
6992
 
6966
6993
  const listCmd = reportsCommand.command("list").description("List reports").option("-s, --space <space>", "space ID");
6967
6994
  listCmd.action(async (_options, command) => {
@@ -6986,8 +7013,8 @@ pruneCmd.action(async (options, command) => {
6986
7013
  ui.info(`Deleted ${deletedFilesCount} report file${deletedFilesCount === 1 ? "" : "s"}`);
6987
7014
  });
6988
7015
 
6989
- const program$2 = getProgram();
6990
- const assetsCommand = program$2.command(commands.ASSETS).description(`Manage your space's assets`);
7016
+ const program$3 = getProgram();
7017
+ const assetsCommand = program$3.command(commands.ASSETS).description(`Manage your space's assets`);
6991
7018
 
6992
7019
  const fetchAssets = async ({ spaceId, params }) => {
6993
7020
  try {
@@ -7177,7 +7204,7 @@ const isRemoteSource = (assetBinaryPath) => {
7177
7204
  return false;
7178
7205
  }
7179
7206
  };
7180
- const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.old_filename && entry.new_filename);
7207
+ const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.new_filename);
7181
7208
  const loadAssetMap = async (manifestFile) => {
7182
7209
  const manifest = await loadManifest(manifestFile);
7183
7210
  const entries = manifest.filter(isValidManifestEntry).map((e) => [
@@ -7878,8 +7905,8 @@ const traverseAndMapBySchema = (data, {
7878
7905
  const dataNew = { ...data };
7879
7906
  for (const [fieldName, fieldValue] of Object.entries(data)) {
7880
7907
  const fieldSchema = schema[fieldName.replace(/__i18n__.*/, "")];
7881
- const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema && fieldSchema.type;
7882
- const fieldRefMapper = typeof fieldType === "string" && fieldRefMappers2[fieldType];
7908
+ const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema ? fieldSchema.type : void 0;
7909
+ const fieldRefMapper = typeof fieldType === "string" ? fieldRefMappers2[fieldType] : void 0;
7883
7910
  if (fieldRefMapper) {
7884
7911
  dataNew[fieldName] = fieldRefMapper(fieldValue, {
7885
7912
  schema: fieldSchema,
@@ -7944,6 +7971,9 @@ const richtextFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRef
7944
7971
  fieldRefMappers: fieldRefMappers2
7945
7972
  });
7946
7973
  const multilinkFieldRefMapper = (data, { maps }) => {
7974
+ if (!data || typeof data !== "object") {
7975
+ return data;
7976
+ }
7947
7977
  if (data.linktype !== "story") {
7948
7978
  return data;
7949
7979
  }
@@ -7963,6 +7993,9 @@ const bloksFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRefMap
7963
7993
  }));
7964
7994
  };
7965
7995
  const assetFieldRefMapper = (data, { maps }) => {
7996
+ if (!data || typeof data !== "object") {
7997
+ return data;
7998
+ }
7966
7999
  const mappedAsset = typeof data.id === "number" ? maps.assets?.get(data.id) : void 0;
7967
8000
  if (!mappedAsset) {
7968
8001
  return data;
@@ -7980,7 +8013,7 @@ const multiassetFieldRefMapper = (data, options) => {
7980
8013
  return data.map((d) => assetFieldRefMapper(d, options));
7981
8014
  };
7982
8015
  const optionsFieldRefMapper = (data, { schema, maps }) => {
7983
- if (schema.source !== "internal_stories" || !Array.isArray(data)) {
8016
+ if (!schema || !("source" in schema) || schema.source !== "internal_stories" || !Array.isArray(data)) {
7984
8017
  return data;
7985
8018
  }
7986
8019
  return data.map((d) => maps.stories?.get(d) || d);
@@ -8897,8 +8930,8 @@ pushCmd$1.action(async (assetInput, options, command) => {
8897
8930
  }
8898
8931
  });
8899
8932
 
8900
- const program$1 = getProgram();
8901
- const storiesCommand = program$1.command(commands.STORIES).description(`Manage your space's stories`);
8933
+ const program$2 = getProgram();
8934
+ const storiesCommand = program$2.command(commands.STORIES).description(`Manage your space's stories`);
8902
8935
 
8903
8936
  const pullCmd = storiesCommand.command("pull").option("-s, --space <space>", "space ID").option("-d, --dry-run", "Preview changes without applying them to Storyblok").option("-q, --query <query>", 'Filter stories by content attributes using Storyblok filter query syntax. Example: --query="[highlighted][in]=true"').option("--starts-with <path>", 'Filter stories by path. Example: --starts-with="/en/blog/"').description(`Download your space's stories as separate json files.`);
8904
8937
  pullCmd.action(async (options, command) => {
@@ -9391,6 +9424,1957 @@ pushCmd.action(async (options, command) => {
9391
9424
  }
9392
9425
  });
9393
9426
 
9427
+ const program$1 = getProgram();
9428
+ const schemaCommand = program$1.command(commands.SCHEMA).description(`Manage your space's schema from code`);
9429
+
9430
+ function isObject(value) {
9431
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9432
+ }
9433
+ function isComponent(value) {
9434
+ return isObject(value) && typeof value.name === "string" && "schema" in value && isObject(value.schema);
9435
+ }
9436
+ function isDatasource(value) {
9437
+ return isObject(value) && typeof value.name === "string" && typeof value.slug === "string" && !("schema" in value);
9438
+ }
9439
+ function isComponentFolder(value) {
9440
+ return isObject(value) && typeof value.name === "string" && !("schema" in value) && !("slug" in value) && ("uuid" in value || "parent_id" in value);
9441
+ }
9442
+ function isSchemaObject(value) {
9443
+ return isObject(value) && ("blocks" in value || "blockFolders" in value || "datasources" in value);
9444
+ }
9445
+ function classifyExports(moduleExports) {
9446
+ const components = [];
9447
+ const componentFolders = [];
9448
+ const datasources = [];
9449
+ function collect(value) {
9450
+ if (isComponent(value)) {
9451
+ components.push(value);
9452
+ } else if (isDatasource(value)) {
9453
+ datasources.push(value);
9454
+ } else if (isComponentFolder(value)) {
9455
+ componentFolders.push(value);
9456
+ }
9457
+ }
9458
+ for (const value of Object.values(moduleExports)) {
9459
+ if (isSchemaObject(value)) {
9460
+ for (const group of Object.values(value)) {
9461
+ if (isObject(group)) {
9462
+ for (const entity of Object.values(group)) {
9463
+ collect(entity);
9464
+ }
9465
+ }
9466
+ }
9467
+ } else {
9468
+ collect(value);
9469
+ }
9470
+ }
9471
+ return { components, componentFolders, datasources };
9472
+ }
9473
+ async function loadSchema(entryPath) {
9474
+ const { createJiti } = await import('jiti');
9475
+ const jiti = createJiti(import.meta.url, {
9476
+ interopDefault: true
9477
+ });
9478
+ const absolutePath = (await import('pathe')).resolve(entryPath);
9479
+ const mod = await jiti.import(absolutePath);
9480
+ return classifyExports(mod);
9481
+ }
9482
+
9483
+ const COMPONENT_STRIP_KEYS = /* @__PURE__ */ new Set([
9484
+ "id",
9485
+ "created_at",
9486
+ "updated_at",
9487
+ "real_name",
9488
+ // API-computed display/technical name, read-only
9489
+ "preset_id",
9490
+ // Instance-level preset selection, not part of schema definition
9491
+ "all_presets",
9492
+ // Computed list of presets, managed via /presets API
9493
+ "internal_tags_list",
9494
+ // Read-only expanded form of internal_tag_ids ({id, name} objects)
9495
+ "content_type_asset_preview",
9496
+ // Read-only, not in ComponentCreate/ComponentUpdate
9497
+ "image",
9498
+ // Read-only preview image URL
9499
+ "preview_tmpl",
9500
+ // Read-only preview template
9501
+ "metadata"
9502
+ // Not in current API types, stripped defensively
9503
+ ]);
9504
+ const DATASOURCE_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
9505
+ const DATASOURCE_DIMENSION_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "datasource_id", "created_at", "updated_at"]);
9506
+ const FOLDER_INIT_STRIP_KEYS = /* @__PURE__ */ new Set(["id"]);
9507
+ const FOLDER_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "uuid"]);
9508
+ const DATASOURCE_DEFAULTS = {
9509
+ dimensions: []
9510
+ };
9511
+ const COMPONENT_DEFAULTS = {
9512
+ display_name: "",
9513
+ color: "",
9514
+ icon: "",
9515
+ preview_field: "",
9516
+ internal_tag_ids: []
9517
+ };
9518
+ function applyDefaults(entity, defaults) {
9519
+ const result = { ...entity };
9520
+ for (const [key, defaultValue] of Object.entries(defaults)) {
9521
+ if (result[key] === void 0 || result[key] === null) {
9522
+ Object.assign(result, { [key]: defaultValue });
9523
+ }
9524
+ }
9525
+ return result;
9526
+ }
9527
+ const INDENT = " ";
9528
+ function formatValue(value, depth) {
9529
+ const indent = INDENT.repeat(depth);
9530
+ const innerIndent = INDENT.repeat(depth + 1);
9531
+ if (value === null || value === void 0) {
9532
+ return String(value);
9533
+ }
9534
+ if (typeof value === "string") {
9535
+ return `'${value.replace(/'/g, "\\'")}'`;
9536
+ }
9537
+ if (typeof value === "number" || typeof value === "boolean") {
9538
+ return String(value);
9539
+ }
9540
+ if (Array.isArray(value)) {
9541
+ if (value.length === 0) {
9542
+ return "[]";
9543
+ }
9544
+ const items = value.map((item) => `${innerIndent}${formatValue(item, depth + 1)},`);
9545
+ return `[
9546
+ ${items.join("\n")}
9547
+ ${indent}]`;
9548
+ }
9549
+ if (typeof value === "object") {
9550
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0 && v !== null).sort(([a], [b]) => a.localeCompare(b));
9551
+ if (entries.length === 0) {
9552
+ return "{}";
9553
+ }
9554
+ const props = entries.map(
9555
+ ([key, val]) => `${innerIndent}${key}: ${formatValue(val, depth + 1)},`
9556
+ );
9557
+ return `{
9558
+ ${props.join("\n")}
9559
+ ${indent}}`;
9560
+ }
9561
+ return String(value);
9562
+ }
9563
+ function fileTimestamp(iso) {
9564
+ return iso.replace(/[:.]/g, "-");
9565
+ }
9566
+ function stripKeys(obj, keysToStrip) {
9567
+ const result = {};
9568
+ for (const [key, value] of Object.entries(obj)) {
9569
+ if (!keysToStrip.has(key) && value !== void 0 && value !== null) {
9570
+ result[key] = value;
9571
+ }
9572
+ }
9573
+ return result;
9574
+ }
9575
+
9576
+ function sortSchemaByPos$1(schema) {
9577
+ const entries = Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
9578
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
9579
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
9580
+ return posA - posB;
9581
+ });
9582
+ return Object.fromEntries(
9583
+ entries.map(([key, field]) => {
9584
+ const { id, ...rest } = field;
9585
+ return [key, rest];
9586
+ })
9587
+ );
9588
+ }
9589
+ function serializeComponent(component) {
9590
+ const clean = stripKeys(component, COMPONENT_STRIP_KEYS);
9591
+ if (clean.schema && typeof clean.schema === "object") {
9592
+ clean.schema = sortSchemaByPos$1(clean.schema);
9593
+ }
9594
+ const ordered = {};
9595
+ if (clean.name !== void 0) {
9596
+ ordered.name = clean.name;
9597
+ }
9598
+ if (clean.display_name !== void 0) {
9599
+ ordered.display_name = clean.display_name;
9600
+ }
9601
+ if (clean.is_root !== void 0) {
9602
+ ordered.is_root = clean.is_root;
9603
+ }
9604
+ if (clean.is_nestable !== void 0) {
9605
+ ordered.is_nestable = clean.is_nestable;
9606
+ }
9607
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
9608
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
9609
+ if (!handled.has(key)) {
9610
+ ordered[key] = value;
9611
+ }
9612
+ }
9613
+ if (clean.schema !== void 0) {
9614
+ ordered.schema = clean.schema;
9615
+ }
9616
+ return `defineBlock(${formatValue(ordered, 0)})`;
9617
+ }
9618
+ function serializeDatasource(datasource) {
9619
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
9620
+ if (Array.isArray(clean.dimensions)) {
9621
+ clean.dimensions = clean.dimensions.map(
9622
+ (dim) => typeof dim === "object" && dim !== null ? stripKeys(dim, DATASOURCE_DIMENSION_STRIP_KEYS) : dim
9623
+ );
9624
+ }
9625
+ const ordered = {};
9626
+ if (clean.name !== void 0) {
9627
+ ordered.name = clean.name;
9628
+ }
9629
+ if (clean.slug !== void 0) {
9630
+ ordered.slug = clean.slug;
9631
+ }
9632
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
9633
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
9634
+ if (!handled.has(key)) {
9635
+ ordered[key] = value;
9636
+ }
9637
+ }
9638
+ return `defineDatasource(${formatValue(ordered, 0)})`;
9639
+ }
9640
+ function serializeComponentFolder(folder) {
9641
+ const clean = stripKeys(folder, FOLDER_STRIP_KEYS);
9642
+ const ordered = {};
9643
+ if (clean.name !== void 0) {
9644
+ ordered.name = clean.name;
9645
+ }
9646
+ const handled = /* @__PURE__ */ new Set(["name"]);
9647
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
9648
+ if (!handled.has(key)) {
9649
+ ordered[key] = value;
9650
+ }
9651
+ }
9652
+ return `defineBlockFolder(${formatValue(ordered, 0)})`;
9653
+ }
9654
+
9655
+ function diffEntity(type, name, localSerialized, remoteSerialized) {
9656
+ if (!remoteSerialized && localSerialized) {
9657
+ return { type, name, action: "create", diff: null, local: null, remote: null };
9658
+ }
9659
+ if (remoteSerialized && !localSerialized) {
9660
+ return { type, name, action: "stale", diff: null, local: null, remote: null };
9661
+ }
9662
+ if (localSerialized === remoteSerialized) {
9663
+ return { type, name, action: "unchanged", diff: null, local: null, remote: null };
9664
+ }
9665
+ const patch = createTwoFilesPatch(
9666
+ `remote/${name}`,
9667
+ `local/${name}`,
9668
+ remoteSerialized,
9669
+ localSerialized,
9670
+ "remote",
9671
+ "local"
9672
+ );
9673
+ return { type, name, action: "update", diff: patch, local: null, remote: null };
9674
+ }
9675
+ function diffSchema(local, remote) {
9676
+ const diffs = [];
9677
+ const processedComponentNames = /* @__PURE__ */ new Set();
9678
+ for (const comp of local.components) {
9679
+ processedComponentNames.add(comp.name);
9680
+ const remoteComp = remote.components.get(comp.name);
9681
+ const localSerialized = serializeComponent(applyDefaults(comp, COMPONENT_DEFAULTS));
9682
+ const remoteSerialized = remoteComp ? serializeComponent(applyDefaults(remoteComp, COMPONENT_DEFAULTS)) : null;
9683
+ diffs.push(diffEntity("component", comp.name, localSerialized, remoteSerialized));
9684
+ }
9685
+ for (const [name] of remote.components) {
9686
+ if (!processedComponentNames.has(name)) {
9687
+ diffs.push(diffEntity("component", name, null, "stale"));
9688
+ }
9689
+ }
9690
+ const processedFolderNames = /* @__PURE__ */ new Set();
9691
+ for (const folder of local.componentFolders) {
9692
+ processedFolderNames.add(folder.name);
9693
+ const remoteFolder = remote.componentFolders.get(folder.name);
9694
+ const localSerialized = serializeComponentFolder(folder);
9695
+ const remoteSerialized = remoteFolder ? serializeComponentFolder(remoteFolder) : null;
9696
+ diffs.push(diffEntity("componentFolder", folder.name, localSerialized, remoteSerialized));
9697
+ }
9698
+ for (const [name] of remote.componentFolders) {
9699
+ if (!processedFolderNames.has(name)) {
9700
+ diffs.push(diffEntity("componentFolder", name, null, "stale"));
9701
+ }
9702
+ }
9703
+ const processedDatasourceNames = /* @__PURE__ */ new Set();
9704
+ for (const ds of local.datasources) {
9705
+ processedDatasourceNames.add(ds.name);
9706
+ const remoteDs = remote.datasources.get(ds.name);
9707
+ const localSerialized = serializeDatasource(applyDefaults(ds, DATASOURCE_DEFAULTS));
9708
+ const remoteSerialized = remoteDs ? serializeDatasource(applyDefaults(remoteDs, DATASOURCE_DEFAULTS)) : null;
9709
+ diffs.push(diffEntity("datasource", ds.name, localSerialized, remoteSerialized));
9710
+ }
9711
+ for (const [name] of remote.datasources) {
9712
+ if (!processedDatasourceNames.has(name)) {
9713
+ diffs.push(diffEntity("datasource", name, null, "stale"));
9714
+ }
9715
+ }
9716
+ return {
9717
+ diffs,
9718
+ creates: diffs.filter((d) => d.action === "create").length,
9719
+ updates: diffs.filter((d) => d.action === "update").length,
9720
+ unchanged: diffs.filter((d) => d.action === "unchanged").length,
9721
+ stale: diffs.filter((d) => d.action === "stale").length
9722
+ };
9723
+ }
9724
+
9725
+ async function fetchRemoteSchema(spaceId) {
9726
+ const client = getMapiClient();
9727
+ const spaceIdNum = Number(spaceId);
9728
+ const [componentsRes, foldersRes, rawDatasources] = await Promise.all([
9729
+ client.components.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
9730
+ client.componentFolders.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
9731
+ fetchAllPages(
9732
+ (page) => client.datasources.list({ path: { space_id: spaceIdNum }, query: { page }, throwOnError: true }),
9733
+ (data) => data?.datasources ?? []
9734
+ )
9735
+ ]);
9736
+ const rawComponents = componentsRes.data?.components ?? [];
9737
+ const rawComponentFolders = foldersRes.data?.component_groups ?? [];
9738
+ const remote = {
9739
+ components: new Map(rawComponents.map((c) => [c.name, c])),
9740
+ componentFolders: new Map(rawComponentFolders.map((f) => [f.name, f])),
9741
+ datasources: new Map(rawDatasources.map((d) => [d.name, d]))
9742
+ };
9743
+ return { remote, rawComponents, rawComponentFolders, rawDatasources };
9744
+ }
9745
+
9746
+ function isRecord(value) {
9747
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9748
+ }
9749
+ function isSchemaField(value) {
9750
+ return isRecord(value) && "type" in value;
9751
+ }
9752
+ function toSchemaRecord(schema) {
9753
+ const result = {};
9754
+ for (const [key, value] of Object.entries(schema)) {
9755
+ if (key === "_uid" || key === "component" || !isSchemaField(value)) {
9756
+ continue;
9757
+ }
9758
+ result[key] = value;
9759
+ }
9760
+ return result;
9761
+ }
9762
+ function buildComponentPayload(input) {
9763
+ if (!isRecord(input)) {
9764
+ return { name: "" };
9765
+ }
9766
+ return {
9767
+ name: typeof input.name === "string" ? input.name : "",
9768
+ // Fields in COMPONENT_DEFAULTS are always sent with their reset value so that
9769
+ // removing a field from the local schema actually clears it on the API.
9770
+ // (Root-level fields are additive on MAPI update — omitting preserves the old value.)
9771
+ display_name: typeof input.display_name === "string" ? input.display_name : "",
9772
+ color: typeof input.color === "string" ? input.color : "",
9773
+ icon: typeof input.icon === "string" ? input.icon : "",
9774
+ preview_field: typeof input.preview_field === "string" ? input.preview_field : "",
9775
+ internal_tag_ids: Array.isArray(input.internal_tag_ids) ? input.internal_tag_ids : [],
9776
+ // Conditionally sent: only included when explicitly set in local schema
9777
+ ...isRecord(input.schema) && { schema: toSchemaRecord(input.schema) },
9778
+ ...typeof input.is_root === "boolean" && { is_root: input.is_root },
9779
+ ...typeof input.is_nestable === "boolean" && { is_nestable: input.is_nestable },
9780
+ ...typeof input.component_group_uuid === "string" && { component_group_uuid: input.component_group_uuid }
9781
+ };
9782
+ }
9783
+ function toComponentCreate(input) {
9784
+ return buildComponentPayload(input);
9785
+ }
9786
+ function toComponentUpdate(input) {
9787
+ return buildComponentPayload(input);
9788
+ }
9789
+ function toComponentFolderCreate(input) {
9790
+ if (!isRecord(input)) {
9791
+ return { name: "" };
9792
+ }
9793
+ return {
9794
+ name: typeof input.name === "string" ? input.name : "",
9795
+ ...typeof input.parent_id === "number" && { parent_id: input.parent_id }
9796
+ };
9797
+ }
9798
+ function toDatasourceCreate(input) {
9799
+ if (!isRecord(input)) {
9800
+ return { name: "", slug: "" };
9801
+ }
9802
+ const result = {
9803
+ name: typeof input.name === "string" ? input.name : "",
9804
+ slug: typeof input.slug === "string" ? input.slug : ""
9805
+ };
9806
+ if (Array.isArray(input.dimensions)) {
9807
+ result.dimensions_attributes = input.dimensions.filter((d) => isRecord(d) && typeof d.name === "string" && typeof d.entry_value === "string").map((d) => ({
9808
+ name: d.name,
9809
+ entry_value: d.entry_value
9810
+ }));
9811
+ }
9812
+ return result;
9813
+ }
9814
+ function toDatasourceUpdate(input, remote) {
9815
+ const base = toDatasourceCreate(input);
9816
+ const localDims = base.dimensions_attributes ?? [];
9817
+ const remoteDims = remote.dimensions ?? [];
9818
+ if (remoteDims.length === 0) {
9819
+ return base;
9820
+ }
9821
+ const localKeys = new Set(localDims.map((d) => `${d.name}::${d.entry_value}`));
9822
+ const destroyEntries = remoteDims.filter((rd) => rd.id != null && !localKeys.has(`${rd.name}::${rd.entry_value}`)).map((rd) => ({ id: rd.id, _destroy: true }));
9823
+ if (destroyEntries.length > 0) {
9824
+ return {
9825
+ ...base,
9826
+ dimensions_attributes: [...localDims, ...destroyEntries]
9827
+ };
9828
+ }
9829
+ return base;
9830
+ }
9831
+
9832
+ function formatDiffOutput(result, options) {
9833
+ const lines = [];
9834
+ const byType = {
9835
+ component: [],
9836
+ componentFolder: [],
9837
+ datasource: []
9838
+ };
9839
+ for (const diff of result.diffs) {
9840
+ byType[diff.type].push(diff);
9841
+ }
9842
+ const willDelete = options?.delete ?? false;
9843
+ const icons = {
9844
+ create: chalk.green("+"),
9845
+ update: chalk.yellow("~"),
9846
+ unchanged: chalk.dim("="),
9847
+ stale: chalk.red("-")
9848
+ };
9849
+ const sections = [
9850
+ ["Components", byType.component],
9851
+ ["Component Folders", byType.componentFolder],
9852
+ ["Datasources", byType.datasource]
9853
+ ];
9854
+ for (const [label, diffs] of sections) {
9855
+ if (diffs.length === 0) {
9856
+ continue;
9857
+ }
9858
+ lines.push(chalk.bold(label));
9859
+ for (const diff of diffs) {
9860
+ const icon = icons[diff.action] ?? " ";
9861
+ const name = diff.action === "stale" ? chalk.red(diff.name) : diff.name;
9862
+ const actionLabel = diff.action === "stale" && willDelete ? "delete" : diff.action;
9863
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${actionLabel})`)}`);
9864
+ if (diff.diff) {
9865
+ for (const line of diff.diff.split("\n")) {
9866
+ if (line.startsWith("+") && !line.startsWith("+++")) {
9867
+ lines.push(` ${chalk.green(line)}`);
9868
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
9869
+ lines.push(` ${chalk.red(line)}`);
9870
+ }
9871
+ }
9872
+ }
9873
+ }
9874
+ lines.push("");
9875
+ }
9876
+ const summary = [
9877
+ result.creates > 0 ? chalk.green(`${result.creates} to create`) : null,
9878
+ result.updates > 0 ? chalk.yellow(`${result.updates} to update`) : null,
9879
+ result.unchanged > 0 ? chalk.dim(`${result.unchanged} unchanged`) : null,
9880
+ result.stale > 0 ? chalk.red(`${result.stale} ${willDelete ? "to delete" : "stale"}`) : null
9881
+ ].filter(Boolean).join(", ");
9882
+ lines.push(`Summary: ${summary}`);
9883
+ return lines.join("\n");
9884
+ }
9885
+ async function executePush(spaceId, local, remote, diffResult, options) {
9886
+ const client = getMapiClient();
9887
+ const spaceIdNum = Number(spaceId);
9888
+ let created = 0;
9889
+ let updated = 0;
9890
+ let deleted = 0;
9891
+ const createdFolderUuids = /* @__PURE__ */ new Map();
9892
+ const folderDiffs = diffResult.diffs.filter((d) => d.type === "componentFolder");
9893
+ const folderResults = await Promise.allSettled(
9894
+ folderDiffs.map(async (diff) => {
9895
+ const localFolder = local.componentFolders.find((f) => f.name === diff.name);
9896
+ if (diff.action === "create" && localFolder) {
9897
+ const response = await client.componentFolders.create({
9898
+ path: { space_id: spaceIdNum },
9899
+ body: { component_group: toComponentFolderCreate(localFolder) },
9900
+ throwOnError: true
9901
+ });
9902
+ return { action: "created", uuid: response.data?.component_group?.uuid };
9903
+ }
9904
+ if (diff.action === "update" && localFolder) {
9905
+ const existing = remote.componentFolders.get(diff.name);
9906
+ if (existing?.id) {
9907
+ await client.componentFolders.update(existing.id, {
9908
+ path: { space_id: spaceIdNum },
9909
+ body: { component_group: toComponentFolderCreate(localFolder) },
9910
+ throwOnError: true
9911
+ });
9912
+ return { action: "updated" };
9913
+ }
9914
+ }
9915
+ })
9916
+ );
9917
+ for (let i = 0; i < folderResults.length; i++) {
9918
+ const result = folderResults[i];
9919
+ const diff = folderDiffs[i];
9920
+ if (result.status === "fulfilled") {
9921
+ if (result.value?.action === "created") {
9922
+ if (result.value.uuid) {
9923
+ createdFolderUuids.set(diff.name, result.value.uuid);
9924
+ }
9925
+ created++;
9926
+ } else if (result.value?.action === "updated") {
9927
+ updated++;
9928
+ }
9929
+ } else {
9930
+ const eventId = diff.action === "create" ? "push_component_group" : "update_component_group";
9931
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} component folder ${diff.name}`);
9932
+ }
9933
+ }
9934
+ const skippedComponents = /* @__PURE__ */ new Set();
9935
+ if (options.pendingFolderAssignments) {
9936
+ const logger = getLogger();
9937
+ for (const [folderName, componentNames] of options.pendingFolderAssignments) {
9938
+ const remoteUuid = createdFolderUuids.get(folderName);
9939
+ if (!remoteUuid) {
9940
+ logger.warn(`Could not resolve folder '${folderName}' \u2014 skipping components: ${componentNames.join(", ")}`);
9941
+ for (const name of componentNames) {
9942
+ skippedComponents.add(name);
9943
+ }
9944
+ continue;
9945
+ }
9946
+ for (const compName of componentNames) {
9947
+ const comp = local.components.find((c) => c.name === compName);
9948
+ if (comp) {
9949
+ comp.component_group_uuid = remoteUuid;
9950
+ }
9951
+ }
9952
+ }
9953
+ }
9954
+ const componentDiffs = diffResult.diffs.filter((d) => d.type === "component" && !skippedComponents.has(d.name));
9955
+ const componentResults = await Promise.allSettled(
9956
+ componentDiffs.map(async (diff) => {
9957
+ const localComp = local.components.find((c) => c.name === diff.name);
9958
+ if (diff.action === "create" && localComp) {
9959
+ await client.components.create({
9960
+ path: { space_id: spaceIdNum },
9961
+ body: { component: toComponentCreate(localComp) },
9962
+ throwOnError: true
9963
+ });
9964
+ return "created";
9965
+ }
9966
+ if (diff.action === "update" && localComp) {
9967
+ const existing = remote.components.get(diff.name);
9968
+ if (existing?.id) {
9969
+ await client.components.update(existing.id, {
9970
+ path: { space_id: spaceIdNum },
9971
+ body: { component: toComponentUpdate(localComp) },
9972
+ throwOnError: true
9973
+ });
9974
+ return "updated";
9975
+ }
9976
+ }
9977
+ })
9978
+ );
9979
+ for (let i = 0; i < componentResults.length; i++) {
9980
+ const result = componentResults[i];
9981
+ const diff = componentDiffs[i];
9982
+ if (result.status === "fulfilled") {
9983
+ if (result.value === "created") {
9984
+ created++;
9985
+ } else if (result.value === "updated") {
9986
+ updated++;
9987
+ }
9988
+ } else {
9989
+ const eventId = diff.action === "create" ? "push_component" : "update_component";
9990
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} component ${diff.name}`);
9991
+ }
9992
+ }
9993
+ const datasourceDiffs = diffResult.diffs.filter((d) => d.type === "datasource");
9994
+ const datasourceResults = await Promise.allSettled(
9995
+ datasourceDiffs.map(async (diff) => {
9996
+ const localDs = local.datasources.find((d) => d.name === diff.name);
9997
+ if (diff.action === "create" && localDs) {
9998
+ await client.datasources.create({
9999
+ path: { space_id: spaceIdNum },
10000
+ body: { datasource: toDatasourceCreate(localDs) },
10001
+ throwOnError: true
10002
+ });
10003
+ return "created";
10004
+ }
10005
+ if (diff.action === "update" && localDs) {
10006
+ const existing = remote.datasources.get(diff.name);
10007
+ if (existing?.id) {
10008
+ await client.datasources.update(existing.id, {
10009
+ path: { space_id: spaceIdNum },
10010
+ body: { datasource: toDatasourceUpdate(localDs, existing) },
10011
+ throwOnError: true
10012
+ });
10013
+ return "updated";
10014
+ }
10015
+ }
10016
+ })
10017
+ );
10018
+ for (let i = 0; i < datasourceResults.length; i++) {
10019
+ const result = datasourceResults[i];
10020
+ const diff = datasourceDiffs[i];
10021
+ if (result.status === "fulfilled") {
10022
+ if (result.value === "created") {
10023
+ created++;
10024
+ } else if (result.value === "updated") {
10025
+ updated++;
10026
+ }
10027
+ } else {
10028
+ const eventId = diff.action === "create" ? "push_datasource" : "update_datasource";
10029
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} datasource ${diff.name}`);
10030
+ }
10031
+ }
10032
+ if (options.delete) {
10033
+ const staleComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
10034
+ const deleteComponentResults = await Promise.allSettled(
10035
+ staleComponents.map(async (diff) => {
10036
+ const existing = remote.components.get(diff.name);
10037
+ if (existing?.id) {
10038
+ await client.components.delete(existing.id, {
10039
+ path: { space_id: spaceIdNum },
10040
+ throwOnError: true
10041
+ });
10042
+ return true;
10043
+ }
10044
+ })
10045
+ );
10046
+ for (let i = 0; i < deleteComponentResults.length; i++) {
10047
+ const result = deleteComponentResults[i];
10048
+ if (result.status === "fulfilled") {
10049
+ if (result.value) {
10050
+ deleted++;
10051
+ }
10052
+ } else {
10053
+ handleAPIError("push_component", result.reason, `Failed to delete component ${staleComponents[i].name}`);
10054
+ }
10055
+ }
10056
+ const staleDatasources = diffResult.diffs.filter((d) => d.type === "datasource" && d.action === "stale");
10057
+ const deleteDatasourceResults = await Promise.allSettled(
10058
+ staleDatasources.map(async (diff) => {
10059
+ const existing = remote.datasources.get(diff.name);
10060
+ if (existing?.id) {
10061
+ await client.datasources.delete(existing.id, {
10062
+ path: { space_id: spaceIdNum },
10063
+ throwOnError: true
10064
+ });
10065
+ return true;
10066
+ }
10067
+ })
10068
+ );
10069
+ for (let i = 0; i < deleteDatasourceResults.length; i++) {
10070
+ const result = deleteDatasourceResults[i];
10071
+ if (result.status === "fulfilled") {
10072
+ if (result.value) {
10073
+ deleted++;
10074
+ }
10075
+ } else {
10076
+ handleAPIError("delete_datasource", result.reason, `Failed to delete datasource ${staleDatasources[i].name}`);
10077
+ }
10078
+ }
10079
+ const staleFolders = diffResult.diffs.filter((d) => d.type === "componentFolder" && d.action === "stale");
10080
+ const deleteFolderResults = await Promise.allSettled(
10081
+ staleFolders.map(async (diff) => {
10082
+ const existing = remote.componentFolders.get(diff.name);
10083
+ if (existing?.id) {
10084
+ await client.componentFolders.delete(existing.id, {
10085
+ path: { space_id: spaceIdNum },
10086
+ throwOnError: true
10087
+ });
10088
+ return true;
10089
+ }
10090
+ })
10091
+ );
10092
+ for (let i = 0; i < deleteFolderResults.length; i++) {
10093
+ const result = deleteFolderResults[i];
10094
+ if (result.status === "fulfilled") {
10095
+ if (result.value) {
10096
+ deleted++;
10097
+ }
10098
+ } else {
10099
+ handleAPIError("push_component_group", result.reason, `Failed to delete folder ${staleFolders[i].name}`);
10100
+ }
10101
+ }
10102
+ }
10103
+ return { created, updated, deleted };
10104
+ }
10105
+ function buildChangesetEntries(diffResult, local, remote, options) {
10106
+ const changes = [];
10107
+ for (const diff of diffResult.diffs) {
10108
+ if (diff.action === "unchanged") {
10109
+ continue;
10110
+ }
10111
+ if (diff.action === "stale" && !options.delete) {
10112
+ continue;
10113
+ }
10114
+ const action = diff.action === "stale" ? "delete" : diff.action;
10115
+ let remoteSrc;
10116
+ let localSrc;
10117
+ if (diff.type === "component") {
10118
+ remoteSrc = remote.components.get(diff.name);
10119
+ localSrc = local.components.find((c) => c.name === diff.name);
10120
+ } else if (diff.type === "componentFolder") {
10121
+ remoteSrc = remote.componentFolders.get(diff.name);
10122
+ localSrc = local.componentFolders.find((f) => f.name === diff.name);
10123
+ } else if (diff.type === "datasource") {
10124
+ remoteSrc = remote.datasources.get(diff.name);
10125
+ localSrc = local.datasources.find((d) => d.name === diff.name);
10126
+ }
10127
+ changes.push({
10128
+ type: diff.type,
10129
+ name: diff.name,
10130
+ action,
10131
+ ...remoteSrc && { before: { ...remoteSrc } },
10132
+ ...localSrc && { after: { ...localSrc } }
10133
+ });
10134
+ }
10135
+ return changes;
10136
+ }
10137
+
10138
+ function findRemoteFolderByUuid(remoteFolders, uuid) {
10139
+ for (const folder of remoteFolders.values()) {
10140
+ if (folder.uuid === uuid) {
10141
+ return folder;
10142
+ }
10143
+ }
10144
+ return void 0;
10145
+ }
10146
+ function resolveFolderReferences(local, remote) {
10147
+ const localUuidToName = /* @__PURE__ */ new Map();
10148
+ for (const folder of local.componentFolders) {
10149
+ if (folder.uuid) {
10150
+ localUuidToName.set(folder.uuid, folder.name);
10151
+ }
10152
+ }
10153
+ const pendingFolderAssignments = /* @__PURE__ */ new Map();
10154
+ const resolvedComponents = local.components.map((comp) => {
10155
+ if (!comp.component_group_uuid) {
10156
+ return comp;
10157
+ }
10158
+ const folderName = localUuidToName.get(comp.component_group_uuid);
10159
+ if (!folderName) {
10160
+ return comp;
10161
+ }
10162
+ const remoteByUuid = findRemoteFolderByUuid(remote.componentFolders, comp.component_group_uuid);
10163
+ if (remoteByUuid) {
10164
+ return comp;
10165
+ }
10166
+ const remoteByName = remote.componentFolders.get(folderName);
10167
+ if (remoteByName) {
10168
+ return { ...comp, component_group_uuid: remoteByName.uuid };
10169
+ }
10170
+ const pending = pendingFolderAssignments.get(folderName) ?? [];
10171
+ pending.push(comp.name);
10172
+ pendingFolderAssignments.set(folderName, pending);
10173
+ return comp;
10174
+ });
10175
+ return {
10176
+ resolved: { ...local, components: resolvedComponents },
10177
+ pendingFolderAssignments
10178
+ };
10179
+ }
10180
+
10181
+ async function ensureDir(dir) {
10182
+ await mkdir(dir, { recursive: true });
10183
+ }
10184
+ async function saveChangeset(basePath, data) {
10185
+ const dir = join(basePath, "schema", "changesets");
10186
+ await ensureDir(dir);
10187
+ const fileName = `${fileTimestamp(data.timestamp)}.json`;
10188
+ const filePath = join(dir, fileName);
10189
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
10190
+ return filePath;
10191
+ }
10192
+
10193
+ const SENTINEL_FIELDS = /* @__PURE__ */ new Set(["_uid", "component"]);
10194
+ function classifyFieldChanges(remoteSchema, localSchema) {
10195
+ const removed = [];
10196
+ const added = [];
10197
+ const typeChanged = [];
10198
+ const requiredAdded = [];
10199
+ const requiredChanged = [];
10200
+ for (const [field, remoteField] of Object.entries(remoteSchema)) {
10201
+ if (SENTINEL_FIELDS.has(field)) {
10202
+ continue;
10203
+ }
10204
+ if (typeof remoteField.type !== "string") {
10205
+ continue;
10206
+ }
10207
+ if (!(field in localSchema)) {
10208
+ removed.push({ field, type: remoteField.type });
10209
+ }
10210
+ }
10211
+ for (const [field, localField] of Object.entries(localSchema)) {
10212
+ if (SENTINEL_FIELDS.has(field)) {
10213
+ continue;
10214
+ }
10215
+ if (typeof localField.type !== "string") {
10216
+ continue;
10217
+ }
10218
+ if (!(field in remoteSchema)) {
10219
+ if (localField.required) {
10220
+ requiredAdded.push({ field, type: localField.type });
10221
+ } else {
10222
+ added.push({ field, type: localField.type, required: false });
10223
+ }
10224
+ } else {
10225
+ const remoteField = remoteSchema[field];
10226
+ if (typeof remoteField?.type !== "string") {
10227
+ continue;
10228
+ }
10229
+ if (remoteField.type !== localField.type) {
10230
+ typeChanged.push({ field, oldType: remoteField.type, newType: localField.type });
10231
+ }
10232
+ if (localField.required && !remoteField.required) {
10233
+ requiredChanged.push({ field, type: localField.type });
10234
+ }
10235
+ }
10236
+ }
10237
+ return { removed, added, typeChanged, requiredAdded, requiredChanged };
10238
+ }
10239
+ function longestCommonSubstring(a, b) {
10240
+ let maxLen = 0;
10241
+ for (let i = 0; i < a.length; i++) {
10242
+ for (let j = 0; j < b.length; j++) {
10243
+ let len = 0;
10244
+ while (i + len < a.length && j + len < b.length && a[i + len] === b[j + len]) {
10245
+ len++;
10246
+ }
10247
+ if (len > maxLen) {
10248
+ maxLen = len;
10249
+ }
10250
+ }
10251
+ }
10252
+ return maxLen;
10253
+ }
10254
+ function nameSimilarity(a, b) {
10255
+ const longer = Math.max(a.length, b.length);
10256
+ if (longer === 0) {
10257
+ return 1;
10258
+ }
10259
+ return longestCommonSubstring(a, b) / longer;
10260
+ }
10261
+ function detectRenames(removed, added) {
10262
+ const renames = [];
10263
+ const usedRemoved = /* @__PURE__ */ new Set();
10264
+ const usedAdded = /* @__PURE__ */ new Set();
10265
+ const addedByType = /* @__PURE__ */ new Map();
10266
+ for (const addedField of added) {
10267
+ if (!addedByType.has(addedField.type)) {
10268
+ addedByType.set(addedField.type, []);
10269
+ }
10270
+ addedByType.get(addedField.type).push(addedField);
10271
+ }
10272
+ const isSinglePair = removed.length === 1 && added.length === 1;
10273
+ for (const removedField of removed) {
10274
+ const candidates = addedByType.get(removedField.type) ?? [];
10275
+ const availableCandidates = candidates.filter((c) => !usedAdded.has(c.field));
10276
+ if (availableCandidates.length === 0) {
10277
+ continue;
10278
+ }
10279
+ let bestCandidate = availableCandidates[0];
10280
+ let bestScore = nameSimilarity(removedField.field, bestCandidate.field);
10281
+ for (let i = 1; i < availableCandidates.length; i++) {
10282
+ const score = nameSimilarity(removedField.field, availableCandidates[i].field);
10283
+ if (score > bestScore) {
10284
+ bestScore = score;
10285
+ bestCandidate = availableCandidates[i];
10286
+ }
10287
+ }
10288
+ if (!isSinglePair && bestScore < 0.3) {
10289
+ continue;
10290
+ }
10291
+ renames.push({ oldField: removedField.field, newField: bestCandidate.field, fieldType: removedField.type });
10292
+ usedRemoved.add(removedField.field);
10293
+ usedAdded.add(bestCandidate.field);
10294
+ }
10295
+ const unmatchedRemoved = removed.filter((r) => !usedRemoved.has(r.field));
10296
+ const unmatchedAdded = added.filter((a) => !usedAdded.has(a.field));
10297
+ return { renames, unmatchedRemoved, unmatchedAdded };
10298
+ }
10299
+ function analyzeBreakingChanges(diffResult, local, remote) {
10300
+ const results = [];
10301
+ const updatedComponents = diffResult.diffs.filter(
10302
+ (d) => d.type === "component" && d.action === "update"
10303
+ );
10304
+ for (const diff of updatedComponents) {
10305
+ const localComp = local.components.find((c) => c.name === diff.name);
10306
+ const remoteComp = remote.components.get(diff.name);
10307
+ if (!localComp?.schema || !remoteComp?.schema) {
10308
+ continue;
10309
+ }
10310
+ const classification = classifyFieldChanges(
10311
+ remoteComp.schema,
10312
+ localComp.schema
10313
+ );
10314
+ const changes = [];
10315
+ const { renames, unmatchedRemoved } = detectRenames(classification.removed, classification.added);
10316
+ for (const rename of renames) {
10317
+ changes.push({ kind: "rename", field: rename.newField, oldField: rename.oldField });
10318
+ }
10319
+ for (const removed of unmatchedRemoved) {
10320
+ changes.push({ kind: "removed", field: removed.field });
10321
+ }
10322
+ for (const tc of classification.typeChanged) {
10323
+ changes.push({ kind: "type_changed", field: tc.field, oldType: tc.oldType, newType: tc.newType });
10324
+ }
10325
+ for (const ra of classification.requiredAdded) {
10326
+ changes.push({ kind: "required_added", field: ra.field, fieldType: ra.type });
10327
+ }
10328
+ for (const rc of classification.requiredChanged) {
10329
+ changes.push({ kind: "required_changed", field: rc.field, fieldType: rc.type });
10330
+ }
10331
+ if (changes.length > 0) {
10332
+ results.push({ componentName: diff.name, changes });
10333
+ }
10334
+ }
10335
+ return results;
10336
+ }
10337
+
10338
+ const COMPATIBLE_TYPES = /* @__PURE__ */ new Set(["text:textarea", "textarea:text"]);
10339
+ function defaultForType(fieldType) {
10340
+ switch (fieldType) {
10341
+ case "text":
10342
+ case "textarea":
10343
+ case "markdown":
10344
+ return `''`;
10345
+ case "number":
10346
+ return "0";
10347
+ case "boolean":
10348
+ return "false";
10349
+ default:
10350
+ return null;
10351
+ }
10352
+ }
10353
+ function typeConversion(field, oldType, newType) {
10354
+ const key = `${oldType}:${newType}`;
10355
+ if (COMPATIBLE_TYPES.has(key)) {
10356
+ return null;
10357
+ }
10358
+ const accessor = `block.${field}`;
10359
+ switch (key) {
10360
+ case "text:number":
10361
+ return `${accessor} = Number(${accessor}) || 0;`;
10362
+ case "number:text":
10363
+ return `${accessor} = String(${accessor});`;
10364
+ case "text:boolean":
10365
+ return `${accessor} = !!${accessor};`;
10366
+ case "boolean:text":
10367
+ return `${accessor} = String(${accessor});`;
10368
+ default:
10369
+ return `${accessor}; // TODO: convert from ${oldType} to ${newType}`;
10370
+ }
10371
+ }
10372
+ function renderMigrationCode(changes) {
10373
+ const lines = [];
10374
+ lines.push(" // Review this migration before running it against your space.");
10375
+ lines.push(" // Generated migrations are scaffolds and may need manual adjustments.");
10376
+ lines.push(" // Example rename migration:");
10377
+ lines.push(" // block.new_field = block.old_field;");
10378
+ lines.push(" // delete block.old_field;");
10379
+ lines.push("");
10380
+ for (const change of changes) {
10381
+ switch (change.kind) {
10382
+ case "rename":
10383
+ lines.push(` // Rename: ${change.oldField} \u2192 ${change.field}`);
10384
+ lines.push(` if ('${change.oldField}' in block) {`);
10385
+ lines.push(` block.${change.field} = block.${change.oldField};`);
10386
+ lines.push(` delete block.${change.oldField};`);
10387
+ lines.push(` }`);
10388
+ break;
10389
+ case "removed":
10390
+ if (change.renameHint) {
10391
+ lines.push(` // If '${change.field}' was renamed to '${change.renameHint.newField}', uncomment:`);
10392
+ lines.push(` // block.${change.renameHint.newField} = block.${change.field};`);
10393
+ } else {
10394
+ lines.push(` // Removed field: ${change.field}`);
10395
+ }
10396
+ lines.push(` delete block.${change.field};`);
10397
+ break;
10398
+ case "type_changed": {
10399
+ const conversion = typeConversion(change.field, change.oldType, change.newType);
10400
+ if (conversion) {
10401
+ lines.push(` // Type change: ${change.field} (${change.oldType} \u2192 ${change.newType})`);
10402
+ lines.push(` ${conversion}`);
10403
+ }
10404
+ break;
10405
+ }
10406
+ case "required_added": {
10407
+ const defaultValue = defaultForType(change.fieldType);
10408
+ lines.push(` // New required field: ${change.field} (${change.fieldType})`);
10409
+ if (defaultValue !== null) {
10410
+ lines.push(` // TODO: provide a meaningful default value`);
10411
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
10412
+ } else {
10413
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
10414
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
10415
+ }
10416
+ break;
10417
+ }
10418
+ case "required_changed": {
10419
+ const defaultValue = defaultForType(change.fieldType);
10420
+ lines.push(` // Field is now required: ${change.field} (${change.fieldType})`);
10421
+ lines.push(` // Existing stories may have null/undefined values \u2014 provide a default for those.`);
10422
+ if (defaultValue !== null) {
10423
+ lines.push(` // TODO: provide a meaningful default value`);
10424
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
10425
+ } else {
10426
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
10427
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
10428
+ }
10429
+ break;
10430
+ }
10431
+ }
10432
+ lines.push("");
10433
+ }
10434
+ const body = lines.length > 0 ? `
10435
+ ${lines.join("\n")}` : "\n";
10436
+ return `export default function (block) {${body} return block;
10437
+ }
10438
+ `;
10439
+ }
10440
+ async function writeMigrationFile(options) {
10441
+ const { spaceId, componentName, code, timestamp, basePath } = options;
10442
+ const dir = resolvePath(basePath, `migrations/${spaceId}`);
10443
+ await mkdir(dir, { recursive: true });
10444
+ const fileName = `${componentName}.${fileTimestamp(timestamp)}.js`;
10445
+ const filePath = join(dir, fileName);
10446
+ await writeFile(filePath, code, "utf-8");
10447
+ return filePath;
10448
+ }
10449
+
10450
+ const DEFAULT_GROUPS_FILENAME = "groups.json";
10451
+ const CONSOLIDATED_COMPONENTS_FILENAME = "components.json";
10452
+ async function writeLocalComponents({
10453
+ space,
10454
+ basePath,
10455
+ resolved,
10456
+ diffResult,
10457
+ deleteRemoved,
10458
+ ui,
10459
+ logger
10460
+ }) {
10461
+ const componentsDir = resolveCommandPath(directories.components, space, basePath);
10462
+ const consolidatedPath = join(componentsDir, CONSOLIDATED_COMPONENTS_FILENAME);
10463
+ if (await fileExists(consolidatedPath)) {
10464
+ ui.warn(
10465
+ `A consolidated ${CONSOLIDATED_COMPONENTS_FILENAME} exists at ${componentsDir}. Per-component files will still be written, but the consolidated file may shadow them when stories push validates schemas. Delete it or run \`storyblok components pull --separate-files\` to regenerate.`
10466
+ );
10467
+ }
10468
+ for (const component of resolved.components) {
10469
+ const filePath = join(componentsDir, `${sanitizeFilename(component.name || "")}.json`);
10470
+ await saveToFile(filePath, JSON.stringify(component, null, 2));
10471
+ }
10472
+ const groupsPath = join(componentsDir, DEFAULT_GROUPS_FILENAME);
10473
+ if (resolved.componentFolders.length > 0) {
10474
+ await saveToFile(groupsPath, JSON.stringify(resolved.componentFolders, null, 2));
10475
+ } else if (await fileExists(groupsPath)) {
10476
+ try {
10477
+ await unlink(groupsPath);
10478
+ logger.info("Removed stale local groups file", { path: groupsPath });
10479
+ } catch (error) {
10480
+ if (error.code !== "ENOENT") {
10481
+ throw error;
10482
+ }
10483
+ }
10484
+ }
10485
+ if (deleteRemoved) {
10486
+ const staleComponents = diffResult.diffs.filter(
10487
+ (d) => d.type === "component" && d.action === "stale"
10488
+ );
10489
+ for (const stale of staleComponents) {
10490
+ const filePath = join(componentsDir, `${sanitizeFilename(stale.name)}.json`);
10491
+ try {
10492
+ await unlink(filePath);
10493
+ logger.info("Removed stale local component file", { path: filePath });
10494
+ } catch (error) {
10495
+ if (error.code !== "ENOENT") {
10496
+ throw error;
10497
+ }
10498
+ }
10499
+ }
10500
+ }
10501
+ logger.info("Wrote local component files", {
10502
+ space,
10503
+ componentsWritten: resolved.components.length,
10504
+ groupsWritten: resolved.componentFolders.length
10505
+ });
10506
+ }
10507
+
10508
+ schemaCommand.command("push <entry-file>").description("Push local TypeScript schema and datasource definitions to a Storyblok space").option("-s, --space <space>", "space ID").option("-p, --path <path>", "path for file storage").option("--dry-run", "Show diffs without applying changes", false).option("--delete", "Delete remote entities not present in local schema", false).option("--migrations", "Generate scaffold migration files for breaking changes", true).addOption(new Option("--no-migrations", "Skip migration generation for breaking changes")).option("--write-components", "Write component schemas as local JSON files after push (also removes local files for components deleted via --delete)", true).addOption(new Option("--no-write-components", "Skip writing local component files")).action(async (entryFile, options, command) => {
10509
+ const ui = getUI();
10510
+ const logger = getLogger();
10511
+ const reporter = getReporter();
10512
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
10513
+ const { state } = session();
10514
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Pushing schema...");
10515
+ logger.info("Schema push started", { entryFile, space });
10516
+ if (!requireAuthentication(state, verbose)) {
10517
+ return;
10518
+ }
10519
+ if (!space) {
10520
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
10521
+ return;
10522
+ }
10523
+ const summary = { total: 0, succeeded: 0, failed: 0 };
10524
+ try {
10525
+ const loadSpinner = ui.createSpinner("Resolving schema...");
10526
+ let local;
10527
+ try {
10528
+ local = await loadSchema(entryFile);
10529
+ } catch (maybeError) {
10530
+ loadSpinner.failed("Failed to resolve schema");
10531
+ handleError(toError(maybeError), verbose);
10532
+ return;
10533
+ }
10534
+ loadSpinner.succeed(`Found: ${local.components.length} components, ${local.componentFolders.length} component folders, ${local.datasources.length} datasources`);
10535
+ const totalLocal = local.components.length + local.componentFolders.length + local.datasources.length;
10536
+ if (totalLocal === 0) {
10537
+ ui.warn("No components, folders, or datasources found in the entry file. Verify the file exports schema definitions.");
10538
+ return;
10539
+ }
10540
+ const remoteSpinner = ui.createSpinner(`Fetching remote state from space ${space}...`);
10541
+ let remoteResult;
10542
+ try {
10543
+ remoteResult = await fetchRemoteSchema(space);
10544
+ } catch (maybeError) {
10545
+ remoteSpinner.failed("Failed to fetch remote schema");
10546
+ handleError(toError(maybeError), verbose);
10547
+ return;
10548
+ }
10549
+ const { remote, rawComponents, rawComponentFolders, rawDatasources } = remoteResult;
10550
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.componentFolders.size} component folders, ${remote.datasources.size} datasources`);
10551
+ const { resolved, pendingFolderAssignments } = resolveFolderReferences(local, remote);
10552
+ const diffResult = diffSchema(resolved, remote);
10553
+ ui.br();
10554
+ ui.log(formatDiffOutput(diffResult, { delete: options.delete }));
10555
+ if (options.migrations) {
10556
+ const breakingChanges = analyzeBreakingChanges(diffResult, resolved, remote);
10557
+ if (breakingChanges.length > 0) {
10558
+ const totalChanges = breakingChanges.reduce((sum, c) => sum + c.changes.length, 0);
10559
+ ui.br();
10560
+ ui.warn(`${totalChanges} breaking change(s) detected in ${breakingChanges.length} component(s).`);
10561
+ ui.info("Generated migrations are scaffolds. Review and adjust them before running `storyblok migrations run`.");
10562
+ if (!options.dryRun) {
10563
+ const explicitMigrations = command.getOptionValueSource("migrations") === "cli";
10564
+ const shouldGenerate = explicitMigrations || await confirm({
10565
+ message: "Generate migration files for breaking changes?",
10566
+ default: true
10567
+ });
10568
+ if (shouldGenerate) {
10569
+ const migrationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
10570
+ const resolvedBase = resolvePath(basePath, "");
10571
+ for (const comp of breakingChanges) {
10572
+ const renames = comp.changes.filter((c) => c.kind === "rename");
10573
+ if (renames.length > 0 && explicitMigrations) {
10574
+ for (const r of renames) {
10575
+ if (r.kind === "rename") {
10576
+ ui.log(` Assumed rename in '${comp.componentName}': ${r.oldField} \u2192 ${r.field}`);
10577
+ }
10578
+ }
10579
+ }
10580
+ if (renames.length > 0 && !explicitMigrations) {
10581
+ ui.br();
10582
+ ui.log(`Detected renames in '${comp.componentName}':`);
10583
+ for (const r of renames) {
10584
+ if (r.kind === "rename") {
10585
+ ui.log(` ${r.oldField} \u2192 ${r.field}`);
10586
+ }
10587
+ }
10588
+ const renameConfirmed = await confirm({
10589
+ message: "Are these renames correct?",
10590
+ default: true
10591
+ });
10592
+ if (!renameConfirmed) {
10593
+ comp.changes = comp.changes.map((c) => {
10594
+ if (c.kind === "rename") {
10595
+ return { kind: "removed", field: c.oldField, renameHint: { newField: c.field } };
10596
+ }
10597
+ return c;
10598
+ });
10599
+ }
10600
+ }
10601
+ const code = renderMigrationCode(comp.changes);
10602
+ const path = await writeMigrationFile({
10603
+ spaceId: space,
10604
+ componentName: comp.componentName,
10605
+ code,
10606
+ timestamp: migrationTimestamp,
10607
+ basePath: resolvedBase
10608
+ });
10609
+ ui.log(` Generated: ${path}`);
10610
+ }
10611
+ ui.br();
10612
+ ui.info(`Run migrations when ready: storyblok migrations run --space ${space}`);
10613
+ }
10614
+ }
10615
+ }
10616
+ }
10617
+ if (options.delete) {
10618
+ const deletedComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
10619
+ for (const comp of deletedComponents) {
10620
+ ui.warn(`Component '${comp.name}' will be deleted. Stories using it will have out-of-schema content.`);
10621
+ }
10622
+ }
10623
+ if (diffResult.stale > 0 && !options.delete) {
10624
+ ui.warn(`${diffResult.stale} stale entity(s) exist remotely but not in schema. Use --delete to remove.`);
10625
+ }
10626
+ if (options.dryRun) {
10627
+ ui.info("Dry run \u2014 no changes applied.");
10628
+ logger.info("Dry run completed", { creates: diffResult.creates, updates: diffResult.updates });
10629
+ return;
10630
+ }
10631
+ const resolvedPath = resolvePath(basePath, "");
10632
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
10633
+ const changesetPath = await saveChangeset(resolvedPath, {
10634
+ timestamp,
10635
+ spaceId: Number(space),
10636
+ remote: { components: rawComponents, componentFolders: rawComponentFolders, datasources: rawDatasources },
10637
+ changes: buildChangesetEntries(diffResult, resolved, remote, { delete: options.delete })
10638
+ });
10639
+ logger.info("Changeset saved", { path: changesetPath });
10640
+ const nothingToPush = diffResult.creates === 0 && diffResult.updates === 0 && (!options.delete || diffResult.stale === 0);
10641
+ if (nothingToPush) {
10642
+ ui.ok("Everything up to date \u2014 nothing to push.");
10643
+ } else {
10644
+ const pushSpinner = ui.createSpinner("Pushing schema...");
10645
+ let result;
10646
+ try {
10647
+ result = await executePush(space, resolved, remote, diffResult, { delete: options.delete, pendingFolderAssignments });
10648
+ } catch (error) {
10649
+ pushSpinner.failed("Failed to push schema");
10650
+ throw error;
10651
+ }
10652
+ summary.total = result.created + result.updated + result.deleted;
10653
+ summary.succeeded = summary.total;
10654
+ pushSpinner.succeed(`Pushed ${result.created} creations, ${result.updated} updates${result.deleted > 0 ? `, ${result.deleted} deletions` : ""}.`);
10655
+ }
10656
+ if (options.writeComponents) {
10657
+ try {
10658
+ await writeLocalComponents({
10659
+ space,
10660
+ basePath,
10661
+ resolved,
10662
+ diffResult,
10663
+ deleteRemoved: options.delete,
10664
+ ui,
10665
+ logger
10666
+ });
10667
+ } catch (writeError) {
10668
+ ui.warn(`Failed to write local component files: ${toError(writeError).message}`);
10669
+ logger.warn("Failed to write local component files", { error: toError(writeError).message });
10670
+ }
10671
+ }
10672
+ } catch (maybeError) {
10673
+ summary.failed += 1;
10674
+ handleError(toError(maybeError), verbose);
10675
+ } finally {
10676
+ logger.info("Schema push finished", { summary });
10677
+ reporter.addSummary("schemaPushResults", summary);
10678
+ reporter.finalize();
10679
+ }
10680
+ });
10681
+
10682
+ const FIELD_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "pos"]);
10683
+ function toCamelCase(str) {
10684
+ return str.toLowerCase().replace(/[\s_-]+(.)/g, (_, char) => char.toUpperCase());
10685
+ }
10686
+ function toKebabCase(str) {
10687
+ return str.replace(/[\s_]+/g, "-").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
10688
+ }
10689
+ function componentVarName(name) {
10690
+ return `${toCamelCase(name)}Block`;
10691
+ }
10692
+ function folderVarName(name) {
10693
+ return `${toCamelCase(name)}Folder`;
10694
+ }
10695
+ function datasourceVarName(name) {
10696
+ return `${toCamelCase(name)}Datasource`;
10697
+ }
10698
+ function componentFileName(name) {
10699
+ return toKebabCase(name);
10700
+ }
10701
+ function folderFileName(name) {
10702
+ return toKebabCase(name);
10703
+ }
10704
+ function datasourceFileName(datasource) {
10705
+ return toKebabCase(datasource.slug || datasource.name);
10706
+ }
10707
+ function generateFieldCode(fieldName, fieldData, depth) {
10708
+ const clean = stripKeys(fieldData, FIELD_STRIP_KEYS);
10709
+ return `defineField('${fieldName.replace(/'/g, "\\'")}', ${formatValue(clean, depth + 1)})`;
10710
+ }
10711
+ function sortSchemaByPos(schema) {
10712
+ return Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
10713
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
10714
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
10715
+ return posA - posB;
10716
+ });
10717
+ }
10718
+ function generateComponentFile(component, componentFolders) {
10719
+ const lines = [];
10720
+ let matchedFolder;
10721
+ if (component.component_group_uuid && componentFolders) {
10722
+ matchedFolder = componentFolders.find((f) => f.uuid === component.component_group_uuid);
10723
+ }
10724
+ lines.push("import {");
10725
+ lines.push(" defineBlock,");
10726
+ lines.push(" defineField,");
10727
+ lines.push("} from '@storyblok/schema';");
10728
+ if (matchedFolder) {
10729
+ lines.push("");
10730
+ const fVarName = folderVarName(matchedFolder.name);
10731
+ const fFileName = folderFileName(matchedFolder.name);
10732
+ lines.push(`import { ${fVarName} } from './folders/${fFileName}';`);
10733
+ }
10734
+ lines.push("");
10735
+ const varName = componentVarName(component.name);
10736
+ lines.push(`export const ${varName} = defineBlock({`);
10737
+ const clean = stripKeys(component, COMPONENT_STRIP_KEYS);
10738
+ if (matchedFolder) {
10739
+ delete clean.component_group_uuid;
10740
+ }
10741
+ const orderedKeys = [];
10742
+ if (clean.name !== void 0) {
10743
+ orderedKeys.push("name");
10744
+ }
10745
+ if (clean.display_name !== void 0) {
10746
+ orderedKeys.push("display_name");
10747
+ }
10748
+ if (clean.is_root !== void 0) {
10749
+ orderedKeys.push("is_root");
10750
+ }
10751
+ if (clean.is_nestable !== void 0) {
10752
+ orderedKeys.push("is_nestable");
10753
+ }
10754
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
10755
+ for (const key of Object.keys(clean).sort()) {
10756
+ if (!handled.has(key)) {
10757
+ orderedKeys.push(key);
10758
+ }
10759
+ }
10760
+ if (matchedFolder) {
10761
+ const fVarName = folderVarName(matchedFolder.name);
10762
+ lines.push(`${INDENT}component_group_uuid: ${fVarName}.uuid,`);
10763
+ }
10764
+ for (const key of orderedKeys) {
10765
+ lines.push(`${INDENT}${key}: ${formatValue(clean[key], 1)},`);
10766
+ }
10767
+ if (clean.schema && typeof clean.schema === "object") {
10768
+ const schema = clean.schema;
10769
+ const sortedFields = sortSchemaByPos(schema);
10770
+ if (sortedFields.length > 0) {
10771
+ lines.push(`${INDENT}schema: [`);
10772
+ for (const [fieldName, fieldData] of sortedFields) {
10773
+ const fieldCode = generateFieldCode(fieldName, fieldData, 2);
10774
+ lines.push(`${INDENT}${INDENT}${fieldCode},`);
10775
+ }
10776
+ lines.push(`${INDENT}],`);
10777
+ } else {
10778
+ lines.push(`${INDENT}schema: [],`);
10779
+ }
10780
+ }
10781
+ lines.push("});");
10782
+ lines.push("");
10783
+ return lines.join("\n");
10784
+ }
10785
+ function generateFolderFile(folder) {
10786
+ const lines = [];
10787
+ lines.push("import { defineBlockFolder } from '@storyblok/schema';");
10788
+ lines.push("");
10789
+ const varName = folderVarName(folder.name);
10790
+ lines.push(`export const ${varName} = defineBlockFolder({`);
10791
+ const clean = stripKeys(folder, FOLDER_INIT_STRIP_KEYS);
10792
+ if (clean.name !== void 0) {
10793
+ lines.push(`${INDENT}name: ${formatValue(clean.name, 1)},`);
10794
+ }
10795
+ const handled = /* @__PURE__ */ new Set(["name"]);
10796
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10797
+ if (!handled.has(key)) {
10798
+ lines.push(`${INDENT}${key}: ${formatValue(value, 1)},`);
10799
+ }
10800
+ }
10801
+ lines.push("});");
10802
+ lines.push("");
10803
+ return lines.join("\n");
10804
+ }
10805
+ function generateDatasourceFile(datasource) {
10806
+ const lines = [];
10807
+ lines.push("import { defineDatasource } from '@storyblok/schema';");
10808
+ lines.push("");
10809
+ const varName = datasourceVarName(datasource.name);
10810
+ lines.push(`export const ${varName} = defineDatasource({`);
10811
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
10812
+ if (clean.name !== void 0) {
10813
+ lines.push(`${INDENT}name: ${formatValue(clean.name, 1)},`);
10814
+ }
10815
+ if (clean.slug !== void 0) {
10816
+ lines.push(`${INDENT}slug: ${formatValue(clean.slug, 1)},`);
10817
+ }
10818
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
10819
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10820
+ if (!handled.has(key)) {
10821
+ lines.push(`${INDENT}${key}: ${formatValue(value, 1)},`);
10822
+ }
10823
+ }
10824
+ lines.push("});");
10825
+ lines.push("");
10826
+ return lines.join("\n");
10827
+ }
10828
+ function generateSchemaFile(components, componentFolders, datasources) {
10829
+ const lines = [];
10830
+ lines.push("import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';");
10831
+ lines.push("import type { MapiStory as InferStoryMapi } from '@storyblok/schema';");
10832
+ lines.push("");
10833
+ for (const component of components) {
10834
+ const varName = componentVarName(component.name);
10835
+ const fileName = componentFileName(component.name);
10836
+ lines.push(`import { ${varName} } from './components/${fileName}';`);
10837
+ }
10838
+ for (const folder of componentFolders) {
10839
+ const varName = folderVarName(folder.name);
10840
+ const fileName = folderFileName(folder.name);
10841
+ lines.push(`import { ${varName} } from './components/folders/${fileName}';`);
10842
+ }
10843
+ for (const datasource of datasources) {
10844
+ const varName = datasourceVarName(datasource.name);
10845
+ const fileName = datasourceFileName(datasource);
10846
+ lines.push(`import { ${varName} } from './datasources/${fileName}';`);
10847
+ }
10848
+ lines.push("");
10849
+ lines.push("export const schema = {");
10850
+ if (components.length > 0) {
10851
+ lines.push(" blocks: {");
10852
+ for (const component of components) {
10853
+ const varName = componentVarName(component.name);
10854
+ lines.push(` ${varName},`);
10855
+ }
10856
+ lines.push(" },");
10857
+ }
10858
+ if (componentFolders.length > 0) {
10859
+ lines.push(" blockFolders: {");
10860
+ for (const folder of componentFolders) {
10861
+ const varName = folderVarName(folder.name);
10862
+ lines.push(` ${varName},`);
10863
+ }
10864
+ lines.push(" },");
10865
+ }
10866
+ if (datasources.length > 0) {
10867
+ lines.push(" datasources: {");
10868
+ for (const datasource of datasources) {
10869
+ const varName = datasourceVarName(datasource.name);
10870
+ lines.push(` ${varName},`);
10871
+ }
10872
+ lines.push(" },");
10873
+ }
10874
+ lines.push("};");
10875
+ lines.push("");
10876
+ lines.push("export type Schema = InferSchema<typeof schema>;");
10877
+ lines.push("export type Blocks = Schema['blocks'];");
10878
+ lines.push("export type Story = InferStory<Blocks>;");
10879
+ lines.push("export type StoryMapi = InferStoryMapi<Blocks>;");
10880
+ lines.push("");
10881
+ return lines.join("\n");
10882
+ }
10883
+
10884
+ async function writeFileWithDirs(filePath, content) {
10885
+ const dir = dirname(filePath);
10886
+ await mkdir(dir, { recursive: true });
10887
+ await writeFile(filePath, content, "utf-8");
10888
+ }
10889
+ async function writeSchemaFiles(targetPath, components, componentFolders, datasources) {
10890
+ const writtenFiles = [];
10891
+ for (const comp of components) {
10892
+ const fileName = componentFileName(comp.name);
10893
+ const filePath = join(targetPath, "components", `${fileName}.ts`);
10894
+ await writeFileWithDirs(filePath, generateComponentFile(comp, componentFolders));
10895
+ writtenFiles.push(filePath);
10896
+ }
10897
+ for (const folder of componentFolders) {
10898
+ const fileName = folderFileName(folder.name);
10899
+ const filePath = join(targetPath, "components", "folders", `${fileName}.ts`);
10900
+ await writeFileWithDirs(filePath, generateFolderFile(folder));
10901
+ writtenFiles.push(filePath);
10902
+ }
10903
+ for (const ds of datasources) {
10904
+ const fileName = datasourceFileName(ds);
10905
+ const filePath = join(targetPath, "datasources", `${fileName}.ts`);
10906
+ await writeFileWithDirs(filePath, generateDatasourceFile(ds));
10907
+ writtenFiles.push(filePath);
10908
+ }
10909
+ const schemaPath = join(targetPath, "schema.ts");
10910
+ await writeFileWithDirs(schemaPath, generateSchemaFile(components, componentFolders, datasources));
10911
+ writtenFiles.push(schemaPath);
10912
+ return writtenFiles;
10913
+ }
10914
+
10915
+ async function isTargetEmpty(targetPath) {
10916
+ try {
10917
+ const entries = await readdir(targetPath);
10918
+ return entries.every((entry) => entry.startsWith("."));
10919
+ } catch (maybeError) {
10920
+ const error = maybeError;
10921
+ if (error?.code === "ENOENT") {
10922
+ return true;
10923
+ }
10924
+ throw error;
10925
+ }
10926
+ }
10927
+ schemaCommand.command("init").description("Initialize a local code-driven schema workspace from an existing Storyblok space (one-time bootstrap)").option("-s, --space <space>", "space ID").option("--out-dir <dir>", "Output directory for generated bootstrap files", ".storyblok/schema").action(async (options, command) => {
10928
+ const ui = getUI();
10929
+ const logger = getLogger();
10930
+ const reporter = getReporter();
10931
+ const { space, verbose } = command.optsWithGlobals();
10932
+ const { state } = session();
10933
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Initializing schema...");
10934
+ logger.info("Schema init started", { space });
10935
+ if (!requireAuthentication(state, verbose)) {
10936
+ return;
10937
+ }
10938
+ if (!space) {
10939
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
10940
+ return;
10941
+ }
10942
+ const targetPath = resolve(options.outDir);
10943
+ if (!await isTargetEmpty(targetPath)) {
10944
+ handleError(
10945
+ new CommandError(`Target directory ${targetPath} is not empty. \`schema init\` is a one-time bootstrap and refuses to overwrite existing files. Use \`schema push\` for ongoing changes, or remove the directory to re-bootstrap.`),
10946
+ verbose
10947
+ );
10948
+ return;
10949
+ }
10950
+ const summary = { total: 0, succeeded: 0, failed: 0 };
10951
+ try {
10952
+ const fetchSpinner = ui.createSpinner(`Fetching schema from space ${space}...`);
10953
+ let fetchResult;
10954
+ try {
10955
+ fetchResult = await fetchRemoteSchema(space);
10956
+ } catch (maybeError) {
10957
+ fetchSpinner.failed("Failed to fetch remote schema");
10958
+ handleError(toError(maybeError), verbose);
10959
+ return;
10960
+ }
10961
+ const { rawComponents, rawComponentFolders, rawDatasources } = fetchResult;
10962
+ fetchSpinner.succeed(`Found: ${rawComponents.length} components, ${rawComponentFolders.length} component folders, ${rawDatasources.length} datasources`);
10963
+ const writeSpinner = ui.createSpinner(`Generating TypeScript files to ${targetPath}...`);
10964
+ const writtenFiles = await writeSchemaFiles(targetPath, rawComponents, rawComponentFolders, rawDatasources);
10965
+ summary.total = writtenFiles.length;
10966
+ summary.succeeded = writtenFiles.length;
10967
+ writeSpinner.succeed(`Generated ${writtenFiles.length} files`);
10968
+ ui.list(writtenFiles);
10969
+ ui.warn("`schema init` is a one-time bootstrap step for adopting an existing space. Review generated files before continuing.");
10970
+ ui.info("After bootstrapping, keep your local schema as the source of truth and use `schema push` for ongoing changes.");
10971
+ ui.info("Make sure `@storyblok/schema` is installed in the project that imports these files (e.g. `pnpm add @storyblok/schema`).");
10972
+ } catch (maybeError) {
10973
+ summary.failed += 1;
10974
+ handleError(toError(maybeError), verbose);
10975
+ } finally {
10976
+ logger.info("Schema init finished", { summary });
10977
+ reporter.addSummary("schemaInitResults", summary);
10978
+ reporter.finalize();
10979
+ }
10980
+ });
10981
+
10982
+ const API_ASSIGNED_FIELDS = [
10983
+ "id",
10984
+ "created_at",
10985
+ "updated_at",
10986
+ "real_name",
10987
+ "all_presets",
10988
+ "image",
10989
+ "uuid"
10990
+ ];
10991
+ function stripApiFields(payload) {
10992
+ const result = { ...payload };
10993
+ for (const field of API_ASSIGNED_FIELDS) {
10994
+ delete result[field];
10995
+ }
10996
+ return result;
10997
+ }
10998
+ async function listChangesets(basePath) {
10999
+ const dir = join(basePath, "schema", "changesets");
11000
+ if (!await fileExists(dir)) {
11001
+ return [];
11002
+ }
11003
+ const files = await readDirectory(dir);
11004
+ return files.filter((f) => f.endsWith(".json")).sort().reverse().map((f) => join(dir, f));
11005
+ }
11006
+ async function loadChangeset(filePath) {
11007
+ const content = await readFile$1(filePath, "utf-8");
11008
+ return JSON.parse(content);
11009
+ }
11010
+ function buildRollbackOps(changeset) {
11011
+ if (changeset.changes.length === 0) {
11012
+ return [];
11013
+ }
11014
+ return changeset.changes.map((entry) => {
11015
+ switch (entry.action) {
11016
+ case "create":
11017
+ return { type: entry.type, name: entry.name, action: "delete", payload: {} };
11018
+ case "update":
11019
+ return { type: entry.type, name: entry.name, action: "update", payload: entry.before ?? {} };
11020
+ case "delete":
11021
+ return { type: entry.type, name: entry.name, action: "create", payload: entry.before ?? {} };
11022
+ default:
11023
+ return { type: entry.type, name: entry.name, action: entry.action, payload: {} };
11024
+ }
11025
+ });
11026
+ }
11027
+ function rollbackAction(original) {
11028
+ switch (original) {
11029
+ case "create":
11030
+ return "delete";
11031
+ case "update":
11032
+ return "update";
11033
+ case "delete":
11034
+ return "create";
11035
+ }
11036
+ }
11037
+ function formatRollbackOutput(changes) {
11038
+ const byType = {
11039
+ component: [],
11040
+ componentFolder: [],
11041
+ datasource: []
11042
+ };
11043
+ for (const entry of changes) {
11044
+ byType[entry.type]?.push(entry);
11045
+ }
11046
+ const icons = {
11047
+ create: chalk.green("+"),
11048
+ update: chalk.yellow("~"),
11049
+ delete: chalk.red("-")
11050
+ };
11051
+ const lines = [];
11052
+ const sections = [
11053
+ ["Components", byType.component],
11054
+ ["Component Folders", byType.componentFolder],
11055
+ ["Datasources", byType.datasource]
11056
+ ];
11057
+ for (const [label, entries] of sections) {
11058
+ if (entries.length === 0) {
11059
+ continue;
11060
+ }
11061
+ lines.push(chalk.bold(label));
11062
+ for (const entry of entries) {
11063
+ const action = rollbackAction(entry.action);
11064
+ const icon = icons[action] ?? " ";
11065
+ const name = action === "delete" ? chalk.red(entry.name) : entry.name;
11066
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${action})`)}`);
11067
+ if (entry.action === "update" && entry.before && entry.after) {
11068
+ let fromStr;
11069
+ let toStr;
11070
+ if (entry.type === "component") {
11071
+ fromStr = serializeComponent(applyDefaults(entry.after, COMPONENT_DEFAULTS));
11072
+ toStr = serializeComponent(applyDefaults(entry.before, COMPONENT_DEFAULTS));
11073
+ } else if (entry.type === "componentFolder") {
11074
+ fromStr = serializeComponentFolder(entry.after);
11075
+ toStr = serializeComponentFolder(entry.before);
11076
+ } else {
11077
+ fromStr = serializeDatasource(entry.after);
11078
+ toStr = serializeDatasource(entry.before);
11079
+ }
11080
+ if (fromStr !== toStr) {
11081
+ const patch = createTwoFilesPatch(
11082
+ `current/${entry.name}`,
11083
+ `restore/${entry.name}`,
11084
+ fromStr,
11085
+ toStr,
11086
+ "current",
11087
+ "restore"
11088
+ );
11089
+ for (const line of patch.split("\n")) {
11090
+ if (line.startsWith("+") && !line.startsWith("+++")) {
11091
+ lines.push(` ${chalk.green(line)}`);
11092
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
11093
+ lines.push(` ${chalk.red(line)}`);
11094
+ }
11095
+ }
11096
+ }
11097
+ }
11098
+ }
11099
+ lines.push("");
11100
+ }
11101
+ return lines.join("\n").trimEnd();
11102
+ }
11103
+ async function executeRollback(spaceId, ops, remote) {
11104
+ const client = getMapiClient();
11105
+ const spaceIdNum = Number(spaceId);
11106
+ let created = 0;
11107
+ let updated = 0;
11108
+ let deleted = 0;
11109
+ const folderOps = ops.filter((op) => op.type === "componentFolder");
11110
+ const componentOps = ops.filter((op) => op.type === "component");
11111
+ const datasourceOps = ops.filter((op) => op.type === "datasource");
11112
+ const folderUuidRemap = /* @__PURE__ */ new Map();
11113
+ for (const op of folderOps.filter((o) => o.action !== "delete")) {
11114
+ if (op.action === "create") {
11115
+ const oldUuid = op.payload.uuid;
11116
+ const payload = toComponentFolderCreate(stripApiFields(op.payload));
11117
+ try {
11118
+ const response = await client.componentFolders.create({
11119
+ path: { space_id: spaceIdNum },
11120
+ body: { component_group: payload },
11121
+ throwOnError: true
11122
+ });
11123
+ const remoteUuid = response.data?.component_group?.uuid;
11124
+ if (remoteUuid && typeof oldUuid === "string") {
11125
+ folderUuidRemap.set(oldUuid, remoteUuid);
11126
+ }
11127
+ created++;
11128
+ } catch (error) {
11129
+ handleAPIError("push_component_group", error, `Failed to create folder ${op.name}`);
11130
+ }
11131
+ } else if (op.action === "update") {
11132
+ const existing = remote.componentFolders.get(op.name);
11133
+ if (existing?.id) {
11134
+ const payload = toComponentFolderCreate(stripApiFields(op.payload));
11135
+ try {
11136
+ await client.componentFolders.update(existing.id, {
11137
+ path: { space_id: spaceIdNum },
11138
+ body: { component_group: payload },
11139
+ throwOnError: true
11140
+ });
11141
+ updated++;
11142
+ } catch (error) {
11143
+ handleAPIError("update_component_group", error, `Failed to update folder ${op.name}`);
11144
+ }
11145
+ }
11146
+ }
11147
+ }
11148
+ for (const op of componentOps) {
11149
+ const oldUuid = op.payload.component_group_uuid;
11150
+ if (typeof oldUuid !== "string") {
11151
+ continue;
11152
+ }
11153
+ const newUuid = folderUuidRemap.get(oldUuid);
11154
+ if (newUuid) {
11155
+ op.payload.component_group_uuid = newUuid;
11156
+ }
11157
+ }
11158
+ for (const op of componentOps.filter((o) => o.action !== "delete")) {
11159
+ if (op.action === "create") {
11160
+ const payload = toComponentCreate(stripApiFields(op.payload));
11161
+ try {
11162
+ await client.components.create({
11163
+ path: { space_id: spaceIdNum },
11164
+ body: { component: payload },
11165
+ throwOnError: true
11166
+ });
11167
+ created++;
11168
+ } catch (error) {
11169
+ handleAPIError("push_component", error, `Failed to create component ${op.name}`);
11170
+ }
11171
+ } else if (op.action === "update") {
11172
+ const existing = remote.components.get(op.name);
11173
+ if (existing?.id) {
11174
+ const payload = toComponentUpdate(stripApiFields(op.payload));
11175
+ try {
11176
+ await client.components.update(existing.id, {
11177
+ path: { space_id: spaceIdNum },
11178
+ body: { component: payload },
11179
+ throwOnError: true
11180
+ });
11181
+ updated++;
11182
+ } catch (error) {
11183
+ handleAPIError("update_component", error, `Failed to update component ${op.name}`);
11184
+ }
11185
+ }
11186
+ }
11187
+ }
11188
+ for (const op of datasourceOps.filter((o) => o.action !== "delete")) {
11189
+ if (op.action === "create") {
11190
+ const payload = toDatasourceCreate(stripApiFields(op.payload));
11191
+ try {
11192
+ await client.datasources.create({
11193
+ path: { space_id: spaceIdNum },
11194
+ body: { datasource: payload },
11195
+ throwOnError: true
11196
+ });
11197
+ created++;
11198
+ } catch (error) {
11199
+ handleAPIError("push_datasource", error, `Failed to create datasource ${op.name}`);
11200
+ }
11201
+ } else if (op.action === "update") {
11202
+ const existing = remote.datasources.get(op.name);
11203
+ if (existing?.id) {
11204
+ const payload = toDatasourceUpdate(stripApiFields(op.payload), existing);
11205
+ try {
11206
+ await client.datasources.update(existing.id, {
11207
+ path: { space_id: spaceIdNum },
11208
+ body: { datasource: payload },
11209
+ throwOnError: true
11210
+ });
11211
+ updated++;
11212
+ } catch (error) {
11213
+ handleAPIError("update_datasource", error, `Failed to update datasource ${op.name}`);
11214
+ }
11215
+ }
11216
+ }
11217
+ }
11218
+ for (const op of datasourceOps.filter((o) => o.action === "delete")) {
11219
+ const existing = remote.datasources.get(op.name);
11220
+ if (existing?.id) {
11221
+ try {
11222
+ await client.datasources.delete(existing.id, {
11223
+ path: { space_id: spaceIdNum },
11224
+ throwOnError: true
11225
+ });
11226
+ deleted++;
11227
+ } catch (error) {
11228
+ handleAPIError("delete_datasource", error, `Failed to delete datasource ${op.name}`);
11229
+ }
11230
+ }
11231
+ }
11232
+ for (const op of componentOps.filter((o) => o.action === "delete")) {
11233
+ const existing = remote.components.get(op.name);
11234
+ if (existing?.id) {
11235
+ try {
11236
+ await client.components.delete(existing.id, {
11237
+ path: { space_id: spaceIdNum },
11238
+ throwOnError: true
11239
+ });
11240
+ deleted++;
11241
+ } catch (error) {
11242
+ handleAPIError("push_component", error, `Failed to delete component ${op.name}`);
11243
+ }
11244
+ }
11245
+ }
11246
+ for (const op of folderOps.filter((o) => o.action === "delete")) {
11247
+ const existing = remote.componentFolders.get(op.name);
11248
+ if (existing?.id) {
11249
+ try {
11250
+ await client.componentFolders.delete(existing.id, {
11251
+ path: { space_id: spaceIdNum },
11252
+ throwOnError: true
11253
+ });
11254
+ deleted++;
11255
+ } catch (error) {
11256
+ handleAPIError("push_component_group", error, `Failed to delete folder ${op.name}`);
11257
+ }
11258
+ }
11259
+ }
11260
+ return { created, updated, deleted };
11261
+ }
11262
+
11263
+ schemaCommand.command("rollback [changeset-file]").description("Roll back a Storyblok space to the state captured in a changeset").option("-s, --space <space>", "space ID").option("-p, --path <path>", "path for file storage").option("--dry-run", "Show what would be undone without applying changes", false).option("--yes", "Skip confirmation prompt", false).option("--latest", "Automatically select the most recent changeset", false).action(async (changesetFile, options, command) => {
11264
+ const ui = getUI();
11265
+ const logger = getLogger();
11266
+ const reporter = getReporter();
11267
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
11268
+ const { state } = session();
11269
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Rolling back schema...");
11270
+ logger.info("Schema rollback started", { changesetFile, space });
11271
+ if (!requireAuthentication(state, verbose)) {
11272
+ return;
11273
+ }
11274
+ if (!space) {
11275
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
11276
+ return;
11277
+ }
11278
+ const summary = { total: 0, succeeded: 0, failed: 0 };
11279
+ try {
11280
+ const resolvedBase = resolvePath(basePath, "");
11281
+ let resolvedFile;
11282
+ if (changesetFile) {
11283
+ resolvedFile = changesetFile;
11284
+ } else if (options.latest) {
11285
+ const available = await listChangesets(resolvedBase);
11286
+ if (available.length === 0) {
11287
+ ui.warn("No changesets found. Run `schema push` first to create one.");
11288
+ return;
11289
+ }
11290
+ resolvedFile = available[0];
11291
+ ui.info(`Using latest changeset: ${basename(resolvedFile)}`);
11292
+ } else {
11293
+ const available = await listChangesets(resolvedBase);
11294
+ if (available.length === 0) {
11295
+ ui.warn("No changesets found. Run `schema push` first to create one.");
11296
+ return;
11297
+ }
11298
+ resolvedFile = await select({
11299
+ message: "Select a changeset to roll back:",
11300
+ choices: available.map((f) => ({ name: basename(f), value: f }))
11301
+ });
11302
+ }
11303
+ let changeset;
11304
+ try {
11305
+ changeset = await loadChangeset(resolvedFile);
11306
+ } catch (maybeError) {
11307
+ handleError(toError(maybeError), verbose);
11308
+ return;
11309
+ }
11310
+ logger.info("Changeset loaded", { file: resolvedFile, changes: changeset.changes.length });
11311
+ const ops = buildRollbackOps(changeset);
11312
+ if (ops.length === 0) {
11313
+ ui.ok("Changeset has no changes \u2014 nothing to roll back.");
11314
+ return;
11315
+ }
11316
+ ui.br();
11317
+ ui.log(formatRollbackOutput(changeset.changes));
11318
+ if (options.dryRun) {
11319
+ ui.info("Dry run \u2014 no changes applied.");
11320
+ logger.info("Dry run completed", { ops: ops.length });
11321
+ return;
11322
+ }
11323
+ if (!options.yes) {
11324
+ const confirmed = await confirm({
11325
+ message: `Apply rollback of ${ops.length} change(s) from ${basename(resolvedFile)}?`,
11326
+ default: false
11327
+ });
11328
+ if (!confirmed) {
11329
+ ui.info("Rollback cancelled.");
11330
+ return;
11331
+ }
11332
+ }
11333
+ const remoteSpinner = ui.createSpinner(`Fetching current remote state from space ${space}...`);
11334
+ let remoteResult;
11335
+ try {
11336
+ remoteResult = await fetchRemoteSchema(space);
11337
+ } catch (maybeError) {
11338
+ remoteSpinner.failed("Failed to fetch remote schema");
11339
+ handleError(toError(maybeError), verbose);
11340
+ return;
11341
+ }
11342
+ const { remote } = remoteResult;
11343
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.componentFolders.size} component folders, ${remote.datasources.size} datasources`);
11344
+ const rollbackSpinner = ui.createSpinner("Applying rollback...");
11345
+ let result;
11346
+ try {
11347
+ result = await executeRollback(space, ops, remote);
11348
+ } catch (error) {
11349
+ rollbackSpinner.failed("Failed to apply rollback");
11350
+ throw error;
11351
+ }
11352
+ summary.total = result.created + result.updated + result.deleted;
11353
+ summary.succeeded = summary.total;
11354
+ rollbackSpinner.succeed(`Rolled back: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted.`);
11355
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11356
+ const rollbackChangesetPath = await saveChangeset(resolvedBase, {
11357
+ timestamp,
11358
+ spaceId: Number(space),
11359
+ remote: { components: remoteResult.rawComponents, componentFolders: remoteResult.rawComponentFolders, datasources: remoteResult.rawDatasources },
11360
+ changes: ops.map((op) => ({
11361
+ type: op.type,
11362
+ name: op.name,
11363
+ action: op.action,
11364
+ ...Object.keys(op.payload).length > 0 && { after: op.payload }
11365
+ }))
11366
+ });
11367
+ logger.info("Rollback changeset saved", { path: rollbackChangesetPath });
11368
+ } catch (maybeError) {
11369
+ summary.failed += 1;
11370
+ handleError(toError(maybeError), verbose);
11371
+ } finally {
11372
+ logger.info("Schema rollback finished", { summary });
11373
+ reporter.addSummary("schemaRollbackResults", summary);
11374
+ reporter.finalize();
11375
+ }
11376
+ });
11377
+
9394
11378
  const program = getProgram();
9395
11379
  konsola.br();
9396
11380
  konsola.br();