storyblok 4.18.2 → 4.19.0-alpha.1

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, readdir, readFile as readFile$1, appendFile, access, constants, 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",
@@ -702,6 +705,7 @@ const API_ACTIONS = {
702
705
  update_component_group: "Failed to update component group",
703
706
  update_component_preset: "Failed to update component preset",
704
707
  delete_component_preset: "Failed to delete component preset",
708
+ delete_component: "Failed to delete component",
705
709
  pull_stories: "Failed to pull stories",
706
710
  pull_story: "Failed to pull story",
707
711
  create_story: "Failed to create story",
@@ -720,6 +724,8 @@ const API_ACTIONS = {
720
724
  push_datasource: "Failed to push datasource",
721
725
  update_datasource: "Failed to update datasource",
722
726
  delete_datasource: "Failed to delete datasource",
727
+ push_datasource_entry: "Failed to push datasource entry",
728
+ update_datasource_entry: "Failed to update datasource entry",
723
729
  delete_datasource_entry: "Failed to delete datasource entry",
724
730
  create_space: "Failed to create space",
725
731
  pull_spaces: "Failed to pull spaces",
@@ -1048,11 +1054,11 @@ function requireAuthentication(state, verbose = false) {
1048
1054
  return true;
1049
1055
  }
1050
1056
 
1051
- const toCamelCase = (str) => {
1057
+ const toCamelCase$1 = (str) => {
1052
1058
  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, "");
1053
1059
  };
1054
1060
  const toPascalCase = (str) => {
1055
- const camelCase = toCamelCase(str);
1061
+ const camelCase = toCamelCase$1(str);
1056
1062
  return camelCase ? camelCase[0].toUpperCase() + camelCase.slice(1) : camelCase;
1057
1063
  };
1058
1064
  const capitalize = (str) => {
@@ -1069,6 +1075,7 @@ function maskToken(token) {
1069
1075
  const maskedPart = "*".repeat(token.length - 4);
1070
1076
  return `${visiblePart}${maskedPart}`;
1071
1077
  }
1078
+ const slugify = (text) => text.toString().toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, "").replace(/-{2,}/g, "-").replace(/^-+/, "").replace(/-+$/, "");
1072
1079
  function createRegexFromGlob(pattern) {
1073
1080
  return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`);
1074
1081
  }
@@ -1150,6 +1157,25 @@ function getPackageJson() {
1150
1157
  return packageJson$1;
1151
1158
  }
1152
1159
 
1160
+ async function fetchAllPages(fetchFunction, extractDataFunction) {
1161
+ const items = [];
1162
+ let page = 1;
1163
+ while (true) {
1164
+ const { data, response } = await fetchFunction(page);
1165
+ const totalHeader = response.headers.get("total");
1166
+ const fetchedItems = extractDataFunction(data);
1167
+ items.push(...fetchedItems);
1168
+ if (!totalHeader) {
1169
+ return items;
1170
+ }
1171
+ const total = Number(totalHeader);
1172
+ if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
1173
+ return items;
1174
+ }
1175
+ page++;
1176
+ }
1177
+ }
1178
+
1153
1179
  const __filename$1 = fileURLToPath(import.meta.url);
1154
1180
  const __dirname$1 = dirname(__filename$1);
1155
1181
  function isRegion(value) {
@@ -1238,6 +1264,10 @@ class UI {
1238
1264
  this.br();
1239
1265
  }
1240
1266
  }
1267
+ /** Plain console.log passthrough — use for preformatted or multi-line text. */
1268
+ log(message) {
1269
+ this.console?.log(message);
1270
+ }
1241
1271
  list(items) {
1242
1272
  for (const item of items) {
1243
1273
  this.console?.log(` ${item}`);
@@ -2147,14 +2177,14 @@ async function performInteractiveLogin(options) {
2147
2177
  }
2148
2178
  }
2149
2179
 
2150
- const program$e = getProgram();
2180
+ const program$f = getProgram();
2151
2181
  const allRegionsText = Object.values(regions).join(",");
2152
- 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(
2182
+ 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(
2153
2183
  "-r, --region <region>",
2154
2184
  `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}.`
2155
2185
  ).action(async (options) => {
2156
2186
  konsola.title(`${commands.LOGIN}`, colorPalette.LOGIN);
2157
- const verbose = program$e.opts().verbose;
2187
+ const verbose = program$f.opts().verbose;
2158
2188
  const { token, region } = options;
2159
2189
  const { state, updateSession, persistCredentials } = session();
2160
2190
  if (state.isLoggedIn && !state.envLogin) {
@@ -2212,10 +2242,10 @@ program$e.command(commands.LOGIN).description("Login to the Storyblok CLI").opti
2212
2242
  konsola.br();
2213
2243
  });
2214
2244
 
2215
- const program$d = getProgram();
2216
- program$d.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2245
+ const program$e = getProgram();
2246
+ program$e.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2217
2247
  konsola.title(`${commands.LOGOUT}`, colorPalette.LOGOUT);
2218
- const verbose = program$d.opts().verbose;
2248
+ const verbose = program$e.opts().verbose;
2219
2249
  try {
2220
2250
  const { state } = session();
2221
2251
  if (!state.isLoggedIn || !state.password || !state.region) {
@@ -2262,10 +2292,10 @@ async function openSignupInBrowser(url) {
2262
2292
  }
2263
2293
  }
2264
2294
 
2265
- const program$c = getProgram();
2266
- program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2295
+ const program$d = getProgram();
2296
+ program$d.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2267
2297
  konsola.title(`${commands.SIGNUP}`, colorPalette.SIGNUP);
2268
- const verbose = program$c.opts().verbose;
2298
+ const verbose = program$d.opts().verbose;
2269
2299
  const { state } = session();
2270
2300
  if (state.isLoggedIn && !state.envLogin) {
2271
2301
  konsola.ok(`You are already logged in. If you want to signup with a different account, please logout first.`);
@@ -2286,10 +2316,10 @@ program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(a
2286
2316
  konsola.br();
2287
2317
  });
2288
2318
 
2289
- const program$b = getProgram();
2290
- program$b.command(commands.USER).description("Get the current user").action(async () => {
2319
+ const program$c = getProgram();
2320
+ program$c.command(commands.USER).description("Get the current user").action(async () => {
2291
2321
  konsola.title(`${commands.USER}`, colorPalette.USER);
2292
- const verbose = program$b.opts().verbose;
2322
+ const verbose = program$c.opts().verbose;
2293
2323
  const { state } = session();
2294
2324
  if (!requireAuthentication(state)) {
2295
2325
  return;
@@ -2317,10 +2347,10 @@ program$b.command(commands.USER).description("Get the current user").action(asyn
2317
2347
  konsola.br();
2318
2348
  });
2319
2349
 
2320
- const program$a = getProgram();
2321
- const componentsCommand = program$a.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2350
+ const program$b = getProgram();
2351
+ const componentsCommand = program$b.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2322
2352
 
2323
- function isComponent(item) {
2353
+ function isComponent$1(item) {
2324
2354
  return "schema" in item;
2325
2355
  }
2326
2356
  function isPreset(item) {
@@ -2354,7 +2384,7 @@ async function loadComponents(directoryPath, options) {
2354
2384
  throw new Error('Internal tag is missing "id"!');
2355
2385
  }
2356
2386
  tagMap.set(item.id, item);
2357
- } else if (isComponent(item)) {
2387
+ } else if (isComponent$1(item)) {
2358
2388
  const existing = componentMap.get(item.name);
2359
2389
  if (existing) {
2360
2390
  duplicates.push(`Component "${item.name}" found in both "${existing.file}" and "${file}"`);
@@ -2390,44 +2420,20 @@ To fix this, either:
2390
2420
  }
2391
2421
 
2392
2422
  const DEFAULT_COMPONENTS_FILENAME = "components";
2393
- const DEFAULT_GROUPS_FILENAME = "groups";
2423
+ const DEFAULT_GROUPS_FILENAME$1 = "groups";
2394
2424
  const DEFAULT_PRESETS_FILENAME = "presets";
2395
2425
  const DEFAULT_TAGS_FILENAME = "tags";
2396
2426
 
2397
- async function fetchAllPages(fetchFunction, extractDataFunction) {
2398
- const items = [];
2399
- let page = 1;
2400
- while (true) {
2401
- const { data, response } = await fetchFunction(page);
2402
- const totalHeader = response.headers.get("total");
2403
- const fetchedItems = extractDataFunction(data);
2404
- items.push(...fetchedItems);
2405
- if (!totalHeader) {
2406
- return items;
2407
- }
2408
- const total = Number(totalHeader);
2409
- if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
2410
- return items;
2411
- }
2412
- page++;
2413
- }
2414
- }
2415
-
2416
2427
  const fetchComponents = async (spaceId) => {
2417
2428
  try {
2418
2429
  const client = getMapiClient();
2419
- return await fetchAllPages(
2420
- (page) => client.components.list({
2421
- path: {
2422
- space_id: Number(spaceId)
2423
- },
2424
- query: {
2425
- page
2426
- },
2427
- throwOnError: true
2428
- }),
2429
- (data) => data?.components ?? []
2430
- );
2430
+ const { data } = await client.components.list({
2431
+ path: {
2432
+ space_id: Number(spaceId)
2433
+ },
2434
+ throwOnError: true
2435
+ });
2436
+ return data?.components ?? [];
2431
2437
  } catch (error) {
2432
2438
  handleAPIError("pull_components", error);
2433
2439
  }
@@ -2435,19 +2441,16 @@ const fetchComponents = async (spaceId) => {
2435
2441
  const fetchComponent = async (spaceId, componentName) => {
2436
2442
  try {
2437
2443
  const client = getMapiClient();
2438
- const matches = await fetchAllPages(
2439
- (page) => client.components.list({
2440
- path: {
2441
- space_id: Number(spaceId)
2442
- },
2443
- query: {
2444
- page,
2445
- search: componentName
2446
- },
2447
- throwOnError: true
2448
- }),
2449
- (data) => data?.components ?? []
2450
- );
2444
+ const { data } = await client.components.list({
2445
+ path: {
2446
+ space_id: Number(spaceId)
2447
+ },
2448
+ query: {
2449
+ search: componentName
2450
+ },
2451
+ throwOnError: true
2452
+ });
2453
+ const matches = data?.components ?? [];
2451
2454
  return matches.find((c) => c.name === componentName);
2452
2455
  } catch (error) {
2453
2456
  handleAPIError("pull_components", error, `Failed to fetch component ${componentName}`);
@@ -2514,7 +2517,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2514
2517
  const presetsFilePath = join(resolvedPath, suffix ? `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.${suffix}.json` : `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.json`);
2515
2518
  await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2));
2516
2519
  }
2517
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2520
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2518
2521
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2519
2522
  const internalTagsFilePath = join(resolvedPath, suffix ? `${DEFAULT_TAGS_FILENAME}.${suffix}.json` : `${DEFAULT_TAGS_FILENAME}.json`);
2520
2523
  await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2));
@@ -2524,7 +2527,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2524
2527
  const componentsFilePath = join(resolvedPath, suffix ? `${filename}.${suffix}.json` : `${filename}.json`);
2525
2528
  await saveToFile(componentsFilePath, JSON.stringify(components, null, 2));
2526
2529
  if (groups.length > 0) {
2527
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2530
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2528
2531
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2529
2532
  }
2530
2533
  if (presets.length > 0) {
@@ -2540,6 +2543,27 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2540
2543
  }
2541
2544
  };
2542
2545
 
2546
+ function isSchemaField$1(value) {
2547
+ return typeof value === "object" && value !== null && "type" in value;
2548
+ }
2549
+ function toWritableSchema(schema) {
2550
+ if (!schema) {
2551
+ return void 0;
2552
+ }
2553
+ const result = {};
2554
+ for (const [key, value] of Object.entries(schema)) {
2555
+ if (isSchemaField$1(value)) {
2556
+ result[key] = value;
2557
+ }
2558
+ }
2559
+ return result;
2560
+ }
2561
+ function toRequestTagIds(tagIds) {
2562
+ if (!tagIds) {
2563
+ return void 0;
2564
+ }
2565
+ return tagIds.map((id) => Number(id));
2566
+ }
2543
2567
  const pushComponent = async (space, component) => {
2544
2568
  try {
2545
2569
  const client = getMapiClient();
@@ -2574,10 +2598,23 @@ const updateComponent = async (space, componentId, component) => {
2574
2598
  }
2575
2599
  };
2576
2600
  const upsertComponent = async (space, component, existingId) => {
2601
+ const { name, display_name, schema, is_root, is_nestable, component_group_uuid, color, icon, preview_field, internal_tag_ids } = component;
2602
+ const payload = {
2603
+ name,
2604
+ display_name: display_name ?? void 0,
2605
+ schema: toWritableSchema(schema),
2606
+ is_root,
2607
+ is_nestable,
2608
+ component_group_uuid: component_group_uuid ?? void 0,
2609
+ color: color ?? void 0,
2610
+ icon: icon ?? void 0,
2611
+ preview_field: preview_field ?? void 0,
2612
+ internal_tag_ids: toRequestTagIds(internal_tag_ids)
2613
+ };
2577
2614
  if (existingId) {
2578
- return await updateComponent(space, existingId, component);
2615
+ return await updateComponent(space, existingId, payload);
2579
2616
  } else {
2580
- return await pushComponent(space, component);
2617
+ return await pushComponent(space, payload);
2581
2618
  }
2582
2619
  };
2583
2620
  const pushComponentGroup = async (space, componentGroup) => {
@@ -2629,7 +2666,14 @@ const pushComponentPreset = async (space, preset) => {
2629
2666
  space_id: Number(space)
2630
2667
  },
2631
2668
  body: {
2632
- preset
2669
+ preset: {
2670
+ ...preset,
2671
+ preset: preset.preset ?? void 0,
2672
+ image: preset.image ?? void 0,
2673
+ color: preset.color ?? void 0,
2674
+ icon: preset.icon ?? void 0,
2675
+ description: preset.description ?? void 0
2676
+ }
2633
2677
  },
2634
2678
  throwOnError: true
2635
2679
  });
@@ -2646,7 +2690,14 @@ const updateComponentPreset = async (space, presetId, preset) => {
2646
2690
  space_id: Number(space)
2647
2691
  },
2648
2692
  body: {
2649
- preset
2693
+ preset: {
2694
+ ...preset,
2695
+ preset: preset.preset ?? void 0,
2696
+ image: preset.image ?? void 0,
2697
+ color: preset.color ?? void 0,
2698
+ icon: preset.icon ?? void 0,
2699
+ description: preset.description ?? void 0
2700
+ }
2650
2701
  },
2651
2702
  throwOnError: true
2652
2703
  });
@@ -2680,7 +2731,7 @@ const pushComponentInternalTag = async (space, componentInternalTag) => {
2680
2731
  path: {
2681
2732
  space_id: Number(space)
2682
2733
  },
2683
- body: componentInternalTag,
2734
+ body: { internal_tag: componentInternalTag },
2684
2735
  throwOnError: true
2685
2736
  });
2686
2737
  return data.internal_tag;
@@ -2695,7 +2746,7 @@ const updateComponentInternalTag = async (space, tagId, componentInternalTag) =>
2695
2746
  path: {
2696
2747
  space_id: Number(space)
2697
2748
  },
2698
- body: componentInternalTag,
2749
+ body: { internal_tag: componentInternalTag },
2699
2750
  throwOnError: true
2700
2751
  });
2701
2752
  return data.internal_tag;
@@ -3951,16 +4002,14 @@ const fetchSpace = async (spaceId) => {
3951
4002
  handleAPIError("pull_spaces", error, `Failed to fetch space ${spaceId}`);
3952
4003
  }
3953
4004
  };
3954
- const createSpace = async (space) => {
4005
+ const createSpace = async (space, query) => {
3955
4006
  try {
3956
- const { in_org, assign_partner, ...spaceData } = space;
3957
4007
  const client = getMapiClient();
3958
4008
  const { data } = await client.spaces.create({
3959
4009
  body: {
3960
- space: spaceData,
3961
- ...in_org && { in_org },
3962
- ...assign_partner && { assign_partner }
3963
- }
4010
+ space
4011
+ },
4012
+ query
3964
4013
  });
3965
4014
  return data?.space;
3966
4015
  } catch (error) {
@@ -3971,10 +4020,10 @@ const createSpace = async (space) => {
3971
4020
  const fetchLanguages = async (spaceId) => {
3972
4021
  try {
3973
4022
  const space = await fetchSpace(spaceId);
3974
- if (space?.default_lang_name !== void 0 && space?.languages?.length) {
4023
+ if (space?.default_lang_name && space?.languages?.length) {
3975
4024
  return {
3976
- default_lang_name: space?.default_lang_name,
3977
- languages: space?.languages
4025
+ default_lang_name: space.default_lang_name,
4026
+ languages: space.languages
3978
4027
  };
3979
4028
  }
3980
4029
  } catch (error) {
@@ -3994,8 +4043,8 @@ const saveLanguagesToFile = async (space, internationalizationOptions, options)
3994
4043
  }
3995
4044
  };
3996
4045
 
3997
- const program$9 = getProgram();
3998
- const languagesCommand = program$9.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
4046
+ const program$a = getProgram();
4047
+ const languagesCommand = program$a.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
3999
4048
  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");
4000
4049
  pullCmd$3.action(async (options, command) => {
4001
4050
  konsola.title(`${commands.LANGUAGES}`, colorPalette.LANGUAGES);
@@ -4041,8 +4090,8 @@ pullCmd$3.action(async (options, command) => {
4041
4090
  konsola.br();
4042
4091
  });
4043
4092
 
4044
- const program$8 = getProgram();
4045
- const migrationsCommand = program$8.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
4093
+ const program$9 = getProgram();
4094
+ const migrationsCommand = program$9.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
4046
4095
 
4047
4096
  const getMigrationTemplate = () => {
4048
4097
  return `export default function (block) {
@@ -4146,7 +4195,7 @@ const fetchStories = async (spaceId, params) => {
4146
4195
  const fetchStory = async (spaceId, storyId) => {
4147
4196
  try {
4148
4197
  const client = getMapiClient();
4149
- const { data } = await client.stories.get(storyId, {
4198
+ const { data } = await client.stories.get(Number(storyId), {
4150
4199
  path: {
4151
4200
  space_id: Number(spaceId)
4152
4201
  },
@@ -4191,9 +4240,11 @@ const updateStory = async (spaceId, storyId, payload) => {
4191
4240
  ...payload.story,
4192
4241
  // StoryUpdate2 expects `parent_id?: number`; normalize null → undefined.
4193
4242
  parent_id: payload.story.parent_id ?? void 0
4194
- },
4195
- force_update: payload.force_update === "1" ? "1" : "0",
4196
- ...payload.publish ? { publish: payload.publish } : {}
4243
+ }
4244
+ },
4245
+ query: {
4246
+ force_update: payload.force_update === "1",
4247
+ ...payload.publish ? { publish: Boolean(payload.publish) } : {}
4197
4248
  },
4198
4249
  throwOnError: true
4199
4250
  });
@@ -4290,6 +4341,35 @@ const prefetchTargetStoriesByKeys = async (spaceId, keys, options) => {
4290
4341
  return result;
4291
4342
  };
4292
4343
 
4344
+ function parseFilterQuery(input) {
4345
+ const trimmed = input.trim();
4346
+ if (!trimmed) {
4347
+ return {};
4348
+ }
4349
+ if (trimmed.startsWith("{")) {
4350
+ return JSON.parse(trimmed);
4351
+ }
4352
+ const result = {};
4353
+ for (const clause of trimmed.split("&")) {
4354
+ if (!clause) {
4355
+ continue;
4356
+ }
4357
+ const eq = clause.indexOf("=");
4358
+ if (eq === -1) {
4359
+ continue;
4360
+ }
4361
+ const path = clause.slice(0, eq);
4362
+ const value = clause.slice(eq + 1);
4363
+ const keys = [...path.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1]);
4364
+ if (keys.length < 2) {
4365
+ continue;
4366
+ }
4367
+ const [field, operation] = keys;
4368
+ result[field] = { ...result[field], [operation]: value };
4369
+ }
4370
+ return result;
4371
+ }
4372
+
4293
4373
  const PIPELINE_BACKPRESSURE_MULTIPLIER = 2;
4294
4374
  const DEFAULT_PIPELINE_BACKPRESSURE = 12;
4295
4375
  function createPipelineBackpressureLock(limit) {
@@ -4313,16 +4393,13 @@ const ERROR_CODES = {
4313
4393
  async function* storiesIterator(spaceId, params, onTotal) {
4314
4394
  try {
4315
4395
  let perPage = 500;
4316
- const transformedParams = {
4317
- ...params
4318
- };
4319
- if (params?.componentName && typeof params.componentName === "string") {
4320
- transformedParams.contain_component = params.componentName;
4321
- delete transformedParams.componentName;
4396
+ const { componentName, query, ...rest } = params ?? {};
4397
+ const transformedParams = { ...rest };
4398
+ if (componentName) {
4399
+ transformedParams.contain_component = componentName;
4322
4400
  }
4323
- if (params?.query && typeof params.query === "string") {
4324
- transformedParams.filter_query = params.query.startsWith("filter_query") ? params.query : `filter_query${params.query}`;
4325
- delete transformedParams.query;
4401
+ if (query) {
4402
+ transformedParams.filter_query = parseFilterQuery(query);
4326
4403
  }
4327
4404
  const result = await fetchStories(spaceId, {
4328
4405
  ...transformedParams,
@@ -4647,8 +4724,8 @@ class MigrationStream extends Transform {
4647
4724
  id: story.id,
4648
4725
  name: story.name || "",
4649
4726
  content: story.content,
4650
- published: story.published,
4651
- unpublished_changes: story.unpublished_changes
4727
+ published: story.published ?? void 0,
4728
+ unpublished_changes: story.unpublished_changes ?? void 0
4652
4729
  },
4653
4730
  migrationTimestamp: this.timestamp,
4654
4731
  migrationNames
@@ -4664,8 +4741,8 @@ class MigrationStream extends Transform {
4664
4741
  storyId: story.id,
4665
4742
  name: story.name,
4666
4743
  content: storyContent,
4667
- published: story.published,
4668
- unpublished_changes: story.unpublished_changes
4744
+ published: story.published ?? void 0,
4745
+ unpublished_changes: story.unpublished_changes ?? void 0
4669
4746
  };
4670
4747
  } else if (processed && !contentChanged) {
4671
4748
  this.results.skipped.push({
@@ -4807,7 +4884,6 @@ class UpdateStream extends Writable {
4807
4884
  const payload = {
4808
4885
  story: {
4809
4886
  content,
4810
- id: storyId,
4811
4887
  name: storyName
4812
4888
  },
4813
4889
  force_update: "1"
@@ -5056,7 +5132,6 @@ rollbackCmd.action(async (migrationFile, _options, command) => {
5056
5132
  const payload = {
5057
5133
  story: {
5058
5134
  content: story.content,
5059
- id: story.storyId,
5060
5135
  name: story.name
5061
5136
  },
5062
5137
  force_update: "1"
@@ -5096,8 +5171,8 @@ rollbackCmd.action(async (migrationFile, _options, command) => {
5096
5171
  }
5097
5172
  });
5098
5173
 
5099
- const program$7 = getProgram();
5100
- const typesCommand = program$7.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5174
+ const program$8 = getProgram();
5175
+ const typesCommand = program$8.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5101
5176
 
5102
5177
  const getAssetJSONSchema = (title) => ({
5103
5178
  $id: "#/asset",
@@ -5708,7 +5783,7 @@ const getPropertyTypeAnnotation = (property, prefix, suffix) => {
5708
5783
  }
5709
5784
  };
5710
5785
  function getStoryType(property, prefix, suffix) {
5711
- return `${STORY_TYPE}<${prefix ?? ""}${capitalize(toCamelCase(property))}${suffix ?? ""}>`;
5786
+ return `${STORY_TYPE}<${prefix ?? ""}${capitalize(toCamelCase$1(property))}${suffix ?? ""}>`;
5712
5787
  }
5713
5788
  const getComponentType = (componentName, options) => {
5714
5789
  const prefix = options.typePrefix ?? "";
@@ -6099,7 +6174,7 @@ const deleteDatasourceEntry = async (spaceId, entryId) => {
6099
6174
  handleAPIError("delete_datasource_entry", error, `Failed to delete datasource entry ${entryId}`);
6100
6175
  }
6101
6176
  };
6102
- function isDatasource(item) {
6177
+ function isDatasource$1(item) {
6103
6178
  return typeof item === "object" && item !== null && "slug" in item && typeof item.slug === "string";
6104
6179
  }
6105
6180
  const readDatasourcesFiles = async (options) => {
@@ -6132,7 +6207,7 @@ const readDatasourcesFiles = async (options) => {
6132
6207
  continue;
6133
6208
  }
6134
6209
  for (const item of data) {
6135
- if (isDatasource(item)) {
6210
+ if (isDatasource$1(item)) {
6136
6211
  const existing = datasourceMap.get(item.slug);
6137
6212
  if (existing) {
6138
6213
  duplicates.push(`Datasource "${item.slug}" found in both "${existing.file}" and "${file}"`);
@@ -6232,8 +6307,8 @@ generateCmd.action(async (options, command) => {
6232
6307
  }
6233
6308
  });
6234
6309
 
6235
- const program$6 = getProgram();
6236
- const datasourcesCommand = program$6.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6310
+ const program$7 = getProgram();
6311
+ const datasourcesCommand = program$7.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6237
6312
 
6238
6313
  const DEFAULT_DATASOURCES_FILENAME = "datasources";
6239
6314
 
@@ -6722,7 +6797,62 @@ ${Object.entries(storyblokVars).map(([key, value]) => `${key}=${value}`).join("\
6722
6797
  throw new Error(`Failed to create .env file: ${error.message}`);
6723
6798
  }
6724
6799
  };
6725
- async function handleEnvFileCreation(resolvedPath, token, region) {
6800
+ const updateAngularEnvironmentFiles = async (projectPath, token, region) => {
6801
+ const environmentsDir = join(projectPath, "src", "environments");
6802
+ const envFiles = ["environment.ts", "environment.development.ts"];
6803
+ const updatedFiles = [];
6804
+ for (const envFile of envFiles) {
6805
+ const filePath = join(environmentsDir, envFile);
6806
+ try {
6807
+ let content = await fs.readFile(filePath, "utf-8");
6808
+ if (token) {
6809
+ content = content.replaceAll("STORYBLOK_DELIVERY_API_TOKEN", token);
6810
+ }
6811
+ if (region) {
6812
+ content = content.replaceAll("STORYBLOK_REGION", region);
6813
+ }
6814
+ await saveToFile(filePath, content);
6815
+ updatedFiles.push(filePath);
6816
+ } catch (error) {
6817
+ const fsError = error;
6818
+ if (fsError.code === "ENOENT") {
6819
+ continue;
6820
+ }
6821
+ throw new Error(`Failed to update ${envFile}: ${error.message}`);
6822
+ }
6823
+ }
6824
+ return { updatedFiles };
6825
+ };
6826
+ async function handleEnvFileCreation(resolvedPath, token, region, template) {
6827
+ if (template === "angular") {
6828
+ if (!token && !region) {
6829
+ ui$1.info("No environment variables to write");
6830
+ return true;
6831
+ }
6832
+ try {
6833
+ const { updatedFiles } = await updateAngularEnvironmentFiles(resolvedPath, token, region);
6834
+ if (updatedFiles.length === 0) {
6835
+ ui$1.info("No Angular environment files found to update");
6836
+ return true;
6837
+ }
6838
+ const writtenVars = [token && "accessToken", region && "region"].filter(Boolean).join(", ");
6839
+ ui$1.ok(`Updated Angular environment files with: ${writtenVars}`, true);
6840
+ return true;
6841
+ } catch (error) {
6842
+ ui$1.warn(`Failed to update Angular environment files: ${error.message}`);
6843
+ if (token) {
6844
+ ui$1.info(
6845
+ `You can manually add accessToken to src/environments/environment.ts and src/environments/environment.development.ts`
6846
+ );
6847
+ }
6848
+ if (region) {
6849
+ ui$1.info(
6850
+ `You can manually add region to src/environments/environment.ts and src/environments/environment.development.ts`
6851
+ );
6852
+ }
6853
+ return false;
6854
+ }
6855
+ }
6726
6856
  const envVars = {};
6727
6857
  if (token) {
6728
6858
  envVars.STORYBLOK_DELIVERY_API_TOKEN = token;
@@ -6844,13 +6974,13 @@ async function promptForLogin(verbose) {
6844
6974
  return null;
6845
6975
  }
6846
6976
  }
6847
- const program$5 = getProgram();
6848
- 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(
6977
+ const program$6 = getProgram();
6978
+ 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(
6849
6979
  "-r, --region <region>",
6850
6980
  `The region to apply to the generated project template (does not affect space creation).`
6851
6981
  ).action(async (projectPath, options) => {
6852
6982
  ui.title(`${commands.CREATE}`, colorPalette.CREATE);
6853
- const verbose = program$5.opts().verbose;
6983
+ const verbose = program$6.opts().verbose;
6854
6984
  const { template, blueprint, token } = options;
6855
6985
  if (options.region && !isRegion(options.region)) {
6856
6986
  handleError(new CommandError(`The provided region: ${options.region} is not valid. Please use one of the following values: ${Object.values(regions).join(" | ")}`));
@@ -6954,13 +7084,13 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
6954
7084
  let userData;
6955
7085
  let whereToCreateSpace = "personal";
6956
7086
  if (token) {
6957
- await handleEnvFileCreation(resolvedPath, token, options.region || region);
7087
+ await handleEnvFileCreation(resolvedPath, token, options.region || region, technologyTemplate);
6958
7088
  showNextSteps(technologyTemplate, finalProjectPath);
6959
7089
  return;
6960
7090
  }
6961
7091
  if (options.skipSpace) {
6962
7092
  if (options.region || region) {
6963
- await handleEnvFileCreation(resolvedPath, void 0, options.region || region);
7093
+ await handleEnvFileCreation(resolvedPath, void 0, options.region || region, technologyTemplate);
6964
7094
  }
6965
7095
  showNextSteps(technologyTemplate, finalProjectPath);
6966
7096
  return;
@@ -7023,16 +7153,16 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
7023
7153
  name: toHumanReadable(projectName),
7024
7154
  domain: blueprintDomain
7025
7155
  };
7156
+ const createSpaceQuery = {};
7026
7157
  if (whereToCreateSpace === "org") {
7027
- spaceToCreate.org = userData.org;
7028
- spaceToCreate.in_org = true;
7158
+ createSpaceQuery.in_org = true;
7029
7159
  } else if (whereToCreateSpace === "partner") {
7030
- spaceToCreate.assign_partner = true;
7160
+ createSpaceQuery.assign_partner = true;
7031
7161
  }
7032
- createdSpace = await createSpace(spaceToCreate);
7162
+ createdSpace = await createSpace(spaceToCreate, createSpaceQuery);
7033
7163
  spinnerSpace.succeed(`Space "${chalk.hex(colorPalette.PRIMARY)(toHumanReadable(projectName))}" created successfully`);
7034
7164
  if (createdSpace?.first_token) {
7035
- await handleEnvFileCreation(resolvedPath, createdSpace.first_token, region);
7165
+ await handleEnvFileCreation(resolvedPath, createdSpace.first_token, region, technologyTemplate);
7036
7166
  }
7037
7167
  if (createdSpace?.id) {
7038
7168
  try {
@@ -7069,8 +7199,8 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
7069
7199
  ui.br();
7070
7200
  });
7071
7201
 
7072
- const program$4 = getProgram();
7073
- const logsCommand = program$4.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
7202
+ const program$5 = getProgram();
7203
+ const logsCommand = program$5.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
7074
7204
 
7075
7205
  const listCmd$1 = logsCommand.command("list").description("List logs").option("-s, --space <space>", "space ID");
7076
7206
  listCmd$1.action(async (_options, command) => {
@@ -7095,8 +7225,8 @@ pruneCmd$1.action(async (options, command) => {
7095
7225
  ui.info(`Deleted ${deletedFilesCount} log file${deletedFilesCount === 1 ? "" : "s"}`);
7096
7226
  });
7097
7227
 
7098
- const program$3 = getProgram();
7099
- const reportsCommand = program$3.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
7228
+ const program$4 = getProgram();
7229
+ const reportsCommand = program$4.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
7100
7230
 
7101
7231
  const listCmd = reportsCommand.command("list").description("List reports").option("-s, --space <space>", "space ID");
7102
7232
  listCmd.action(async (_options, command) => {
@@ -7121,8 +7251,8 @@ pruneCmd.action(async (options, command) => {
7121
7251
  ui.info(`Deleted ${deletedFilesCount} report file${deletedFilesCount === 1 ? "" : "s"}`);
7122
7252
  });
7123
7253
 
7124
- const program$2 = getProgram();
7125
- const assetsCommand = program$2.command(commands.ASSETS).description(`Manage your space's assets`);
7254
+ const program$3 = getProgram();
7255
+ const assetsCommand = program$3.command(commands.ASSETS).description(`Manage your space's assets`);
7126
7256
 
7127
7257
  const fetchAssets = async ({ spaceId, params }) => {
7128
7258
  try {
@@ -7170,7 +7300,7 @@ const createAssetInternalTag = async (spaceId, name) => {
7170
7300
  const client = getMapiClient();
7171
7301
  const { data } = await client.internalTags.create({
7172
7302
  path: { space_id: Number(spaceId) },
7173
- body: { name, object_type: "asset" },
7303
+ body: { internal_tag: { name, object_type: "asset" } },
7174
7304
  throwOnError: true
7175
7305
  });
7176
7306
  const tag = data?.internal_tag;
@@ -7294,7 +7424,7 @@ const updateAsset = async (id, asset, { spaceId, fileBuffer }) => {
7294
7424
  const createAsset = async (asset, fileBuffer, { spaceId }) => {
7295
7425
  try {
7296
7426
  const client = getMapiClient();
7297
- const { id: _id, ...assetBody } = asset;
7427
+ const { id: _id, filename: _filename, ...assetBody } = asset;
7298
7428
  return await client.assets.create({
7299
7429
  body: assetBody,
7300
7430
  file: fileBuffer,
@@ -7354,6 +7484,26 @@ const parseAssetData = (raw) => {
7354
7484
  throw new Error(`Invalid --data JSON: ${toError(maybeError).message}`);
7355
7485
  }
7356
7486
  };
7487
+ const toAssetUpload = (partial, shortFilename) => {
7488
+ const nullToUndef = (value) => value ?? void 0;
7489
+ return {
7490
+ id: partial.id,
7491
+ filename: partial.filename,
7492
+ short_filename: shortFilename,
7493
+ asset_folder_id: nullToUndef(partial.asset_folder_id),
7494
+ ext_id: nullToUndef(partial.ext_id),
7495
+ alt: nullToUndef(partial.alt),
7496
+ copyright: nullToUndef(partial.copyright),
7497
+ title: nullToUndef(partial.title),
7498
+ source: nullToUndef(partial.source),
7499
+ expire_at: nullToUndef(partial.expire_at),
7500
+ publish_at: nullToUndef(partial.publish_at),
7501
+ focus: nullToUndef(partial.focus),
7502
+ is_private: nullToUndef(partial.is_private),
7503
+ internal_tag_ids: partial.internal_tag_ids,
7504
+ meta_data: nullToUndef(partial.meta_data)
7505
+ };
7506
+ };
7357
7507
  const getSidecarFilename = (assetBinaryPath) => {
7358
7508
  return join(dirname(assetBinaryPath), `${basename(assetBinaryPath, extname(assetBinaryPath))}.json`);
7359
7509
  };
@@ -7382,7 +7532,7 @@ const isRemoteSource = (assetBinaryPath) => {
7382
7532
  return false;
7383
7533
  }
7384
7534
  };
7385
- const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.old_filename && entry.new_filename);
7535
+ const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.new_filename);
7386
7536
  const loadAssetMap = async (manifestFile) => {
7387
7537
  const manifest = await loadManifest(manifestFile);
7388
7538
  const entries = manifest.filter(isValidManifestEntry).map((e) => [
@@ -7762,8 +7912,10 @@ const readLocalAssetsStream = ({
7762
7912
  const sidecar = await loadSidecarAssetData(binaryFilePath);
7763
7913
  const shortFilename = sidecar.short_filename || (sidecar.filename ? basename(sidecar.filename) : void 0) || file;
7764
7914
  const asset = {
7765
- ...sidecar,
7766
- short_filename: shortFilename
7915
+ ...toAssetUpload(sidecar, shortFilename),
7916
+ // Carry the read-only tag detail for source→target tag-name
7917
+ // translation in `processAsset`; it is stripped before the API call.
7918
+ ...sidecar.internal_tags_list ? { internal_tags_list: sidecar.internal_tags_list } : {}
7767
7919
  };
7768
7920
  const fileBuffer = await readFile$1(binaryFilePath);
7769
7921
  const sidecarPath = getSidecarFilename(binaryFilePath);
@@ -7847,7 +7999,7 @@ const mapInternalTagIds = (sourceIds, sourceTags, assetInternalTagsByName, onUnm
7847
7999
  const sourceName = sourceNamesById.get(sourceId);
7848
8000
  const targetId = typeof sourceName === "string" ? assetInternalTagsByName.get(sourceName) : void 0;
7849
8001
  if (typeof targetId === "number") {
7850
- mapped.push(String(targetId));
8002
+ mapped.push(targetId);
7851
8003
  } else {
7852
8004
  onUnmappedTag?.({ sourceId, name: sourceName });
7853
8005
  }
@@ -7899,10 +8051,11 @@ const processAsset = async ({
7899
8051
  const remoteAssetId = hasId(localAsset) ? maps.assets.get(localAsset.id)?.new.id || localAsset.id : void 0;
7900
8052
  const remoteAsset = remoteAssetId ? await transports.getAsset(remoteAssetId) : null;
7901
8053
  const sourceTags = localAsset.internal_tags_list;
7902
- const resolveInternalTagIds = (sourceIds) => maps.assetInternalTagsByName ? mapInternalTagIds(sourceIds, sourceTags, maps.assetInternalTagsByName, onUnmappedTag) : (sourceIds ?? []).map((id) => String(id));
8054
+ const resolveInternalTagIds = (sourceIds) => maps.assetInternalTagsByName ? mapInternalTagIds(sourceIds, sourceTags, maps.assetInternalTagsByName, onUnmappedTag) : (sourceIds ?? []).map((id) => Number(id));
7903
8055
  let newRemoteAsset;
7904
8056
  let status;
7905
8057
  if (remoteAsset) {
8058
+ const nullToUndef = (v) => v ?? void 0;
7906
8059
  const updatePayload = {
7907
8060
  asset_folder_id: remoteFolderId,
7908
8061
  alt: "alt" in localAsset ? localAsset.alt : remoteAsset.alt,
@@ -7911,24 +8064,28 @@ const processAsset = async ({
7911
8064
  source: "source" in localAsset ? localAsset.source : remoteAsset.source,
7912
8065
  is_private: "is_private" in localAsset ? localAsset.is_private : remoteAsset.is_private,
7913
8066
  focus: "focus" in localAsset ? localAsset.focus : remoteAsset.focus,
7914
- expire_at: "expire_at" in localAsset ? localAsset.expire_at : remoteAsset.expire_at,
7915
- publish_at: "publish_at" in localAsset ? localAsset.publish_at : remoteAsset.publish_at,
7916
- internal_tag_ids: "internal_tag_ids" in localAsset ? resolveInternalTagIds(localAsset.internal_tag_ids) : remoteAsset.internal_tag_ids,
7917
- meta_data: "meta_data" in localAsset ? localAsset.meta_data : remoteAsset.meta_data
8067
+ expire_at: nullToUndef("expire_at" in localAsset ? localAsset.expire_at : remoteAsset.expire_at),
8068
+ publish_at: nullToUndef("publish_at" in localAsset ? localAsset.publish_at : remoteAsset.publish_at),
8069
+ internal_tag_ids: localAsset.internal_tag_ids ? resolveInternalTagIds(localAsset.internal_tag_ids) : remoteAsset.internal_tag_ids,
8070
+ meta_data: nullToUndef("meta_data" in localAsset ? localAsset.meta_data : remoteAsset.meta_data)
7918
8071
  };
7919
8072
  await transports.updateAsset(
7920
8073
  remoteAsset.id,
7921
8074
  { ...updatePayload, short_filename: remoteAsset.short_filename },
7922
8075
  fileBuffer
7923
8076
  );
7924
- newRemoteAsset = { ...remoteAsset, ...updatePayload };
8077
+ newRemoteAsset = {
8078
+ ...remoteAsset,
8079
+ ...updatePayload,
8080
+ is_private: updatePayload.is_private ?? remoteAsset.is_private
8081
+ };
7925
8082
  status = "updated";
7926
8083
  } else if (hasShortFilename(localAsset)) {
7927
- const { internal_tags_list: _internalTagsList, internal_tag_ids: _sourceTagIds, ...rest } = localAsset;
7928
- const mappedTagIds = "internal_tag_ids" in localAsset ? resolveInternalTagIds(localAsset.internal_tag_ids) : void 0;
8084
+ const mappedTagIds = localAsset.internal_tag_ids ? resolveInternalTagIds(localAsset.internal_tag_ids) : void 0;
8085
+ const { internal_tag_ids: _sourceTagIds, ...uploadBase } = toAssetUpload(localAsset, localAsset.short_filename);
7929
8086
  const createPayload = {
7930
- ...rest,
7931
- asset_folder_id: remoteFolderId,
8087
+ ...uploadBase,
8088
+ asset_folder_id: remoteFolderId ?? void 0,
7932
8089
  ...mappedTagIds !== void 0 ? { internal_tag_ids: mappedTagIds } : {}
7933
8090
  };
7934
8091
  newRemoteAsset = await transports.createAsset(createPayload, fileBuffer);
@@ -8161,8 +8318,8 @@ const traverseAndMapBySchema = (data, {
8161
8318
  const dataNew = { ...data };
8162
8319
  for (const [fieldName, fieldValue] of Object.entries(data)) {
8163
8320
  const fieldSchema = schema[fieldName.replace(/__i18n__.*/, "")];
8164
- const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema && fieldSchema.type;
8165
- const fieldRefMapper = typeof fieldType === "string" && fieldRefMappers2[fieldType];
8321
+ const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema ? fieldSchema.type : void 0;
8322
+ const fieldRefMapper = typeof fieldType === "string" ? fieldRefMappers2[fieldType] : void 0;
8166
8323
  if (fieldRefMapper) {
8167
8324
  dataNew[fieldName] = fieldRefMapper(fieldValue, {
8168
8325
  schema: fieldSchema,
@@ -8227,6 +8384,9 @@ const richtextFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRef
8227
8384
  fieldRefMappers: fieldRefMappers2
8228
8385
  });
8229
8386
  const multilinkFieldRefMapper = (data, { maps }) => {
8387
+ if (!data || typeof data !== "object") {
8388
+ return data;
8389
+ }
8230
8390
  if (data.linktype !== "story") {
8231
8391
  return data;
8232
8392
  }
@@ -8246,6 +8406,9 @@ const bloksFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRefMap
8246
8406
  }));
8247
8407
  };
8248
8408
  const assetFieldRefMapper = (data, { maps }) => {
8409
+ if (!data || typeof data !== "object") {
8410
+ return data;
8411
+ }
8249
8412
  const mappedAsset = typeof data.id === "number" ? maps.assets?.get(data.id) : void 0;
8250
8413
  if (!mappedAsset) {
8251
8414
  return data;
@@ -8263,7 +8426,7 @@ const multiassetFieldRefMapper = (data, options) => {
8263
8426
  return data.map((d) => assetFieldRefMapper(d, options));
8264
8427
  };
8265
8428
  const optionsFieldRefMapper = (data, { schema, maps }) => {
8266
- if (schema.source !== "internal_stories" || !Array.isArray(data)) {
8429
+ if (!schema || !("source" in schema) || schema.source !== "internal_stories" || !Array.isArray(data)) {
8267
8430
  return data;
8268
8431
  }
8269
8432
  return data.map((d) => maps.stories?.get(d) || d);
@@ -9112,11 +9275,7 @@ pushCmd$1.action(async (assetInput, options, command) => {
9112
9275
  const sourceBasename = isRemoteSource(assetBinaryPath) ? basename(new URL(assetBinaryPath).pathname) : basename(assetBinaryPath);
9113
9276
  const shortFilename = options.shortFilename || assetDataPartial.short_filename || sourceBasename;
9114
9277
  const folderId = options.folder ? Number(options.folder) : void 0;
9115
- assetData = {
9116
- ...assetDataPartial,
9117
- short_filename: shortFilename,
9118
- asset_folder_id: folderId
9119
- };
9278
+ assetData = { ...toAssetUpload(assetDataPartial, shortFilename), asset_folder_id: folderId };
9120
9279
  }
9121
9280
  const getAssetTransport = makeGetAssetAPITransport({ spaceId: targetSpace });
9122
9281
  const createAssetTransport = options.dryRun ? async (asset) => asset : makeCreateAssetAPITransport({ spaceId: targetSpace });
@@ -9276,8 +9435,8 @@ transferCmd.action(async (assetIds, options, command) => {
9276
9435
  process.exitCode = summary.failed > 0 ? 1 : 0;
9277
9436
  });
9278
9437
 
9279
- const program$1 = getProgram();
9280
- const storiesCommand = program$1.command(commands.STORIES).description(`Manage your space's stories`);
9438
+ const program$2 = getProgram();
9439
+ const storiesCommand = program$2.command(commands.STORIES).description(`Manage your space's stories`);
9281
9440
 
9282
9441
  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.`);
9283
9442
  pullCmd.action(async (options, command) => {
@@ -9313,7 +9472,7 @@ pullCmd.action(async (options, command) => {
9313
9472
  fetchStoriesStream({
9314
9473
  spaceId: space,
9315
9474
  params: {
9316
- filter_query: options.query,
9475
+ filter_query: options.query ? parseFilterQuery(options.query) : void 0,
9317
9476
  starts_with: options.startsWith
9318
9477
  },
9319
9478
  setTotalPages: (totalPages) => {
@@ -9782,6 +9941,1747 @@ pushCmd.action(async (options, command) => {
9782
9941
  }
9783
9942
  });
9784
9943
 
9944
+ const program$1 = getProgram();
9945
+ const schemaCommand = program$1.command(commands.SCHEMA).description(`Manage your space's schema from code`);
9946
+
9947
+ function isRecord(value) {
9948
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9949
+ }
9950
+ const COMPONENT_STRIP_KEYS = /* @__PURE__ */ new Set([
9951
+ "id",
9952
+ "created_at",
9953
+ "updated_at",
9954
+ "real_name",
9955
+ // API-computed display/technical name, read-only
9956
+ "preset_id",
9957
+ // Instance-level preset selection, not part of schema definition
9958
+ "all_presets",
9959
+ // Computed list of presets, managed via /presets API
9960
+ "internal_tags_list",
9961
+ // Read-only expanded form of internal_tag_ids ({id, name} objects)
9962
+ "content_type_asset_preview",
9963
+ // Read-only, not in ComponentCreate/ComponentUpdate
9964
+ "image",
9965
+ // Read-only preview image URL
9966
+ "preview_tmpl",
9967
+ // Read-only preview template
9968
+ "metadata",
9969
+ // Not in current API types, stripped defensively
9970
+ "component_group_uuid"
9971
+ // UI grouping; stripped by default — kept for diffing only when a block opts into the group escape hatch
9972
+ ]);
9973
+ const DATASOURCE_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
9974
+ const DATASOURCE_DIMENSION_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "datasource_id", "created_at", "updated_at"]);
9975
+ const DATASOURCE_DEFAULTS = {
9976
+ dimensions: []
9977
+ };
9978
+ const COMPONENT_DEFAULTS = {
9979
+ display_name: "",
9980
+ description: "",
9981
+ color: "",
9982
+ icon: "",
9983
+ preview_field: "",
9984
+ internal_tag_ids: []
9985
+ };
9986
+ function applyDefaults(entity, defaults) {
9987
+ const result = { ...entity };
9988
+ for (const [key, defaultValue] of Object.entries(defaults)) {
9989
+ if (result[key] === void 0 || result[key] === null) {
9990
+ Object.assign(result, { [key]: defaultValue });
9991
+ }
9992
+ }
9993
+ return result;
9994
+ }
9995
+ const INDENT = " ";
9996
+ function formatValue(value, depth) {
9997
+ const indent = INDENT.repeat(depth);
9998
+ const innerIndent = INDENT.repeat(depth + 1);
9999
+ if (value === null || value === void 0) {
10000
+ return String(value);
10001
+ }
10002
+ if (typeof value === "string") {
10003
+ return `'${value.replace(/'/g, "\\'")}'`;
10004
+ }
10005
+ if (typeof value === "number" || typeof value === "boolean") {
10006
+ return String(value);
10007
+ }
10008
+ if (Array.isArray(value)) {
10009
+ if (value.length === 0) {
10010
+ return "[]";
10011
+ }
10012
+ const items = value.map((item) => `${innerIndent}${formatValue(item, depth + 1)},`);
10013
+ return `[
10014
+ ${items.join("\n")}
10015
+ ${indent}]`;
10016
+ }
10017
+ if (typeof value === "object") {
10018
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0 && v !== null).sort(([a], [b]) => a.localeCompare(b));
10019
+ if (entries.length === 0) {
10020
+ return "{}";
10021
+ }
10022
+ const props = entries.map(
10023
+ ([key, val]) => `${innerIndent}${key}: ${formatValue(val, depth + 1)},`
10024
+ );
10025
+ return `{
10026
+ ${props.join("\n")}
10027
+ ${indent}}`;
10028
+ }
10029
+ return String(value);
10030
+ }
10031
+ function fileTimestamp(iso) {
10032
+ return iso.replace(/\D/g, "").slice(0, 14);
10033
+ }
10034
+ function displayPath(filePath, userPath) {
10035
+ return userPath && isAbsolute(userPath) ? filePath : relative(process.cwd(), filePath);
10036
+ }
10037
+ function stripKeys(obj, keysToStrip) {
10038
+ const result = {};
10039
+ for (const [key, value] of Object.entries(obj)) {
10040
+ if (!keysToStrip.has(key) && value !== void 0 && value !== null) {
10041
+ result[key] = value;
10042
+ }
10043
+ }
10044
+ return result;
10045
+ }
10046
+
10047
+ function mapFieldToWire(field) {
10048
+ const { name, allow, datasource, ...rest } = field;
10049
+ const value = { ...rest };
10050
+ if (allow !== void 0) {
10051
+ value.component_whitelist = allow;
10052
+ }
10053
+ if (datasource !== void 0) {
10054
+ value.datasource_slug = datasource;
10055
+ }
10056
+ return { name: typeof name === "string" ? name : "", value };
10057
+ }
10058
+ function mapBlockToWire(block) {
10059
+ const { fields, ...rest } = block;
10060
+ const schema = {};
10061
+ if (Array.isArray(fields)) {
10062
+ for (const field of fields) {
10063
+ if (!isRecord(field)) {
10064
+ continue;
10065
+ }
10066
+ const { name, value } = mapFieldToWire(field);
10067
+ if (name) {
10068
+ schema[name] = value;
10069
+ }
10070
+ }
10071
+ }
10072
+ return { ...rest, schema };
10073
+ }
10074
+ function mapDatasourceToWire(datasource) {
10075
+ return datasource;
10076
+ }
10077
+
10078
+ function isComponent(value) {
10079
+ return isRecord(value) && typeof value.name === "string" && Array.isArray(value.fields);
10080
+ }
10081
+ function isDatasource(value) {
10082
+ return isRecord(value) && typeof value.name === "string" && typeof value.slug === "string" && !Array.isArray(value.fields);
10083
+ }
10084
+ function isSchemaObject(value) {
10085
+ return isRecord(value) && ("blocks" in value || "datasources" in value);
10086
+ }
10087
+ function emptySchemaData() {
10088
+ return { components: [], datasources: [] };
10089
+ }
10090
+ function classifyExports(moduleExports) {
10091
+ const data = emptySchemaData();
10092
+ const seenComponents = /* @__PURE__ */ new Set();
10093
+ const seenDatasources = /* @__PURE__ */ new Set();
10094
+ function collect(value) {
10095
+ if (isComponent(value)) {
10096
+ if (seenComponents.has(value.name)) {
10097
+ return;
10098
+ }
10099
+ seenComponents.add(value.name);
10100
+ data.components.push(mapBlockToWire(value));
10101
+ } else if (isDatasource(value)) {
10102
+ if (seenDatasources.has(value.name)) {
10103
+ return;
10104
+ }
10105
+ seenDatasources.add(value.name);
10106
+ data.datasources.push(mapDatasourceToWire(value));
10107
+ }
10108
+ }
10109
+ for (const value of Object.values(moduleExports)) {
10110
+ if (isSchemaObject(value)) {
10111
+ for (const group of Object.values(value)) {
10112
+ if (isRecord(group)) {
10113
+ for (const entity of Object.values(group)) {
10114
+ collect(entity);
10115
+ }
10116
+ }
10117
+ }
10118
+ } else {
10119
+ collect(value);
10120
+ }
10121
+ }
10122
+ return data;
10123
+ }
10124
+ async function loadSchema(entryPath) {
10125
+ const { createJiti } = await import('jiti');
10126
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
10127
+ const { resolve } = await import('pathe');
10128
+ const entryAbs = resolve(entryPath);
10129
+ const entryMod = await jiti.import(entryAbs);
10130
+ return classifyExports(entryMod);
10131
+ }
10132
+
10133
+ function sortSchemaByPos$1(schema) {
10134
+ const entries = Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
10135
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
10136
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
10137
+ return posA - posB;
10138
+ });
10139
+ return Object.fromEntries(
10140
+ entries.map(([key, field]) => {
10141
+ const { id, ...rest } = field;
10142
+ return [key, rest];
10143
+ })
10144
+ );
10145
+ }
10146
+ function serializeComponent(component, options = {}) {
10147
+ const stripSet = options.includeGroupUuid ? new Set([...COMPONENT_STRIP_KEYS].filter((key) => key !== "component_group_uuid")) : COMPONENT_STRIP_KEYS;
10148
+ const clean = stripKeys(component, stripSet);
10149
+ if (clean.schema && typeof clean.schema === "object") {
10150
+ clean.schema = sortSchemaByPos$1(clean.schema);
10151
+ }
10152
+ const ordered = {};
10153
+ if (clean.name !== void 0) {
10154
+ ordered.name = clean.name;
10155
+ }
10156
+ if (clean.display_name !== void 0) {
10157
+ ordered.display_name = clean.display_name;
10158
+ }
10159
+ if (clean.is_root !== void 0) {
10160
+ ordered.is_root = clean.is_root;
10161
+ }
10162
+ if (clean.is_nestable !== void 0) {
10163
+ ordered.is_nestable = clean.is_nestable;
10164
+ }
10165
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
10166
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10167
+ if (!handled.has(key)) {
10168
+ ordered[key] = value;
10169
+ }
10170
+ }
10171
+ if (clean.schema !== void 0) {
10172
+ ordered.schema = clean.schema;
10173
+ }
10174
+ return `defineBlock(${formatValue(ordered, 0)})`;
10175
+ }
10176
+ function serializeDatasource(datasource) {
10177
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
10178
+ if (Array.isArray(clean.dimensions)) {
10179
+ clean.dimensions = clean.dimensions.map(
10180
+ (dim) => typeof dim === "object" && dim !== null ? stripKeys(dim, DATASOURCE_DIMENSION_STRIP_KEYS) : dim
10181
+ );
10182
+ }
10183
+ const ordered = {};
10184
+ if (clean.name !== void 0) {
10185
+ ordered.name = clean.name;
10186
+ }
10187
+ if (clean.slug !== void 0) {
10188
+ ordered.slug = clean.slug;
10189
+ }
10190
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
10191
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10192
+ if (!handled.has(key)) {
10193
+ ordered[key] = value;
10194
+ }
10195
+ }
10196
+ return `defineDatasource(${formatValue(ordered, 0)})`;
10197
+ }
10198
+
10199
+ function diffEntity(type, name, localSerialized, remoteSerialized) {
10200
+ if (!remoteSerialized && localSerialized) {
10201
+ return { type, name, action: "create", diff: null, local: null, remote: null };
10202
+ }
10203
+ if (remoteSerialized && !localSerialized) {
10204
+ return { type, name, action: "stale", diff: null, local: null, remote: null };
10205
+ }
10206
+ if (localSerialized === remoteSerialized) {
10207
+ return { type, name, action: "unchanged", diff: null, local: null, remote: null };
10208
+ }
10209
+ const patch = createTwoFilesPatch(
10210
+ `remote/${name}`,
10211
+ `local/${name}`,
10212
+ remoteSerialized,
10213
+ localSerialized,
10214
+ "remote",
10215
+ "local"
10216
+ );
10217
+ return { type, name, action: "update", diff: patch, local: null, remote: null };
10218
+ }
10219
+ function diffSchema(local, remote) {
10220
+ const diffs = [];
10221
+ const processedComponentNames = /* @__PURE__ */ new Set();
10222
+ for (const comp of local.components) {
10223
+ processedComponentNames.add(comp.name);
10224
+ const remoteComp = remote.components.get(comp.name);
10225
+ const includeGroupUuid = typeof comp.component_group_uuid === "string";
10226
+ const localSerialized = serializeComponent(applyDefaults(comp, COMPONENT_DEFAULTS), { includeGroupUuid });
10227
+ const remoteSerialized = remoteComp ? serializeComponent(applyDefaults(remoteComp, COMPONENT_DEFAULTS), { includeGroupUuid }) : null;
10228
+ diffs.push(diffEntity("component", comp.name, localSerialized, remoteSerialized));
10229
+ }
10230
+ for (const [name] of remote.components) {
10231
+ if (!processedComponentNames.has(name)) {
10232
+ diffs.push(diffEntity("component", name, null, "stale"));
10233
+ }
10234
+ }
10235
+ const processedDatasourceNames = /* @__PURE__ */ new Set();
10236
+ for (const ds of local.datasources) {
10237
+ processedDatasourceNames.add(ds.name);
10238
+ const remoteDs = remote.datasources.get(ds.name);
10239
+ const localSerialized = serializeDatasource(applyDefaults(ds, DATASOURCE_DEFAULTS));
10240
+ const remoteSerialized = remoteDs ? serializeDatasource(applyDefaults(remoteDs, DATASOURCE_DEFAULTS)) : null;
10241
+ diffs.push(diffEntity("datasource", ds.name, localSerialized, remoteSerialized));
10242
+ }
10243
+ for (const [name] of remote.datasources) {
10244
+ if (!processedDatasourceNames.has(name)) {
10245
+ diffs.push(diffEntity("datasource", name, null, "stale"));
10246
+ }
10247
+ }
10248
+ return {
10249
+ diffs,
10250
+ creates: diffs.filter((d) => d.action === "create").length,
10251
+ updates: diffs.filter((d) => d.action === "update").length,
10252
+ unchanged: diffs.filter((d) => d.action === "unchanged").length,
10253
+ stale: diffs.filter((d) => d.action === "stale").length
10254
+ };
10255
+ }
10256
+
10257
+ async function fetchRemoteSchema(spaceId) {
10258
+ const client = getMapiClient();
10259
+ const spaceIdNum = Number(spaceId);
10260
+ const [componentsRes, foldersRes, rawDatasources] = await Promise.all([
10261
+ client.components.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
10262
+ client.componentFolders.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
10263
+ fetchAllPages(
10264
+ (page) => client.datasources.list({ path: { space_id: spaceIdNum }, query: { page }, throwOnError: true }),
10265
+ (data) => data?.datasources ?? []
10266
+ )
10267
+ ]);
10268
+ const rawComponents = componentsRes.data?.components ?? [];
10269
+ const rawComponentFolders = foldersRes.data?.component_groups ?? [];
10270
+ const remote = {
10271
+ components: new Map(rawComponents.map((c) => [c.name, c])),
10272
+ componentFolders: new Map(rawComponentFolders.map((f) => [f.name, f])),
10273
+ datasources: new Map(rawDatasources.map((d) => [d.name, d]))
10274
+ };
10275
+ return { remote, rawComponents, rawComponentFolders, rawDatasources };
10276
+ }
10277
+
10278
+ function isSchemaField(value) {
10279
+ return isRecord(value) && "type" in value;
10280
+ }
10281
+ function toSchemaRecord(schema) {
10282
+ const result = {};
10283
+ for (const [key, value] of Object.entries(schema)) {
10284
+ if (key === "_uid" || key === "component" || !isSchemaField(value)) {
10285
+ continue;
10286
+ }
10287
+ result[key] = value;
10288
+ }
10289
+ return result;
10290
+ }
10291
+ function buildComponentPayload(input) {
10292
+ if (!isRecord(input)) {
10293
+ return { name: "" };
10294
+ }
10295
+ return {
10296
+ name: typeof input.name === "string" ? input.name : "",
10297
+ // Fields in COMPONENT_DEFAULTS are always sent with their reset value so that
10298
+ // removing a field from the local schema actually clears it on the API.
10299
+ // (Root-level fields are additive on MAPI update — omitting preserves the old value.)
10300
+ display_name: typeof input.display_name === "string" ? input.display_name : "",
10301
+ description: typeof input.description === "string" ? input.description : "",
10302
+ color: typeof input.color === "string" ? input.color : "",
10303
+ icon: typeof input.icon === "string" ? input.icon : "",
10304
+ preview_field: typeof input.preview_field === "string" ? input.preview_field : "",
10305
+ internal_tag_ids: Array.isArray(input.internal_tag_ids) ? input.internal_tag_ids : [],
10306
+ // Conditionally sent: only included when explicitly set in local schema
10307
+ ...isRecord(input.schema) && { schema: toSchemaRecord(input.schema) },
10308
+ ...typeof input.is_root === "boolean" && { is_root: input.is_root },
10309
+ ...typeof input.is_nestable === "boolean" && { is_nestable: input.is_nestable },
10310
+ ...typeof input.component_group_uuid === "string" && { component_group_uuid: input.component_group_uuid }
10311
+ };
10312
+ }
10313
+ function toComponentCreate(input) {
10314
+ return buildComponentPayload(input);
10315
+ }
10316
+ function toComponentUpdate(input) {
10317
+ return buildComponentPayload(input);
10318
+ }
10319
+ function toDatasourceCreate(input) {
10320
+ if (!isRecord(input)) {
10321
+ return { name: "", slug: "" };
10322
+ }
10323
+ const result = {
10324
+ name: typeof input.name === "string" ? input.name : "",
10325
+ slug: typeof input.slug === "string" ? input.slug : ""
10326
+ };
10327
+ if (Array.isArray(input.dimensions)) {
10328
+ result.dimensions_attributes = input.dimensions.filter((d) => isRecord(d) && typeof d.name === "string" && typeof d.entry_value === "string").map((d) => ({
10329
+ name: d.name,
10330
+ entry_value: d.entry_value
10331
+ }));
10332
+ }
10333
+ return result;
10334
+ }
10335
+ function toDatasourceUpdate(input, remote) {
10336
+ const base = toDatasourceCreate(input);
10337
+ const localDims = base.dimensions_attributes ?? [];
10338
+ const remoteDims = remote.dimensions ?? [];
10339
+ if (remoteDims.length === 0) {
10340
+ return base;
10341
+ }
10342
+ const localKeys = new Set(localDims.map((d) => `${d.name}::${d.entry_value}`));
10343
+ const destroyEntries = remoteDims.filter((rd) => rd.id != null && !localKeys.has(`${rd.name}::${rd.entry_value}`)).map((rd) => ({ id: rd.id, _destroy: true }));
10344
+ if (destroyEntries.length > 0) {
10345
+ return {
10346
+ ...base,
10347
+ dimensions_attributes: [...localDims, ...destroyEntries]
10348
+ };
10349
+ }
10350
+ return base;
10351
+ }
10352
+
10353
+ function formatDiffOutput(result, options) {
10354
+ const lines = [];
10355
+ const byType = {
10356
+ component: [],
10357
+ datasource: []
10358
+ };
10359
+ for (const diff of result.diffs) {
10360
+ byType[diff.type].push(diff);
10361
+ }
10362
+ const willDelete = options?.delete ?? false;
10363
+ const icons = {
10364
+ create: chalk.green("+"),
10365
+ update: chalk.yellow("~"),
10366
+ unchanged: chalk.dim("="),
10367
+ stale: chalk.red("-")
10368
+ };
10369
+ const sections = [
10370
+ ["Components", byType.component],
10371
+ ["Datasources", byType.datasource]
10372
+ ];
10373
+ for (const [label, diffs] of sections) {
10374
+ if (diffs.length === 0) {
10375
+ continue;
10376
+ }
10377
+ lines.push(chalk.bold(label));
10378
+ for (const diff of diffs) {
10379
+ const icon = icons[diff.action] ?? " ";
10380
+ const name = diff.action === "stale" ? chalk.red(diff.name) : diff.name;
10381
+ const actionLabel = diff.action === "stale" && willDelete ? "delete" : diff.action;
10382
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${actionLabel})`)}`);
10383
+ if (diff.diff) {
10384
+ for (const line of diff.diff.split("\n")) {
10385
+ if (line.startsWith("+") && !line.startsWith("+++")) {
10386
+ lines.push(` ${chalk.green(line)}`);
10387
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
10388
+ lines.push(` ${chalk.red(line)}`);
10389
+ }
10390
+ }
10391
+ }
10392
+ }
10393
+ lines.push("");
10394
+ }
10395
+ const summary = [
10396
+ result.creates > 0 ? chalk.green(`${result.creates} to create`) : null,
10397
+ result.updates > 0 ? chalk.yellow(`${result.updates} to update`) : null,
10398
+ result.unchanged > 0 ? chalk.dim(`${result.unchanged} unchanged`) : null,
10399
+ result.stale > 0 ? chalk.red(`${result.stale} ${willDelete ? "to delete" : "stale"}`) : null
10400
+ ].filter(Boolean).join(", ");
10401
+ lines.push(`Summary: ${summary}`);
10402
+ return lines.join("\n");
10403
+ }
10404
+ async function executePush(spaceId, local, remote, diffResult, options) {
10405
+ const client = getMapiClient();
10406
+ const spaceIdNum = Number(spaceId);
10407
+ let created = 0;
10408
+ let updated = 0;
10409
+ let deleted = 0;
10410
+ const componentDiffs = diffResult.diffs.filter((d) => d.type === "component");
10411
+ const componentResults = await Promise.allSettled(
10412
+ componentDiffs.map(async (diff) => {
10413
+ const localComp = local.components.find((c) => c.name === diff.name);
10414
+ if (diff.action === "create" && localComp) {
10415
+ await client.components.create({
10416
+ path: { space_id: spaceIdNum },
10417
+ body: { component: toComponentCreate(localComp) },
10418
+ throwOnError: true
10419
+ });
10420
+ return "created";
10421
+ }
10422
+ if (diff.action === "update" && localComp) {
10423
+ const existing = remote.components.get(diff.name);
10424
+ if (existing?.id) {
10425
+ await client.components.update(existing.id, {
10426
+ path: { space_id: spaceIdNum },
10427
+ body: { component: toComponentUpdate(localComp) },
10428
+ throwOnError: true
10429
+ });
10430
+ return "updated";
10431
+ }
10432
+ }
10433
+ })
10434
+ );
10435
+ for (let i = 0; i < componentResults.length; i++) {
10436
+ const result = componentResults[i];
10437
+ const diff = componentDiffs[i];
10438
+ if (result.status === "fulfilled") {
10439
+ if (result.value === "created") {
10440
+ created++;
10441
+ } else if (result.value === "updated") {
10442
+ updated++;
10443
+ }
10444
+ } else {
10445
+ const eventId = diff.action === "create" ? "push_component" : "update_component";
10446
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} component ${diff.name}`);
10447
+ }
10448
+ }
10449
+ const datasourceDiffs = diffResult.diffs.filter((d) => d.type === "datasource");
10450
+ const datasourceResults = await Promise.allSettled(
10451
+ datasourceDiffs.map(async (diff) => {
10452
+ const localDs = local.datasources.find((d) => d.name === diff.name);
10453
+ if (diff.action === "create" && localDs) {
10454
+ await client.datasources.create({
10455
+ path: { space_id: spaceIdNum },
10456
+ body: { datasource: toDatasourceCreate(localDs) },
10457
+ throwOnError: true
10458
+ });
10459
+ return "created";
10460
+ }
10461
+ if (diff.action === "update" && localDs) {
10462
+ const existing = remote.datasources.get(diff.name);
10463
+ if (existing?.id) {
10464
+ await client.datasources.update(existing.id, {
10465
+ path: { space_id: spaceIdNum },
10466
+ body: { datasource: toDatasourceUpdate(localDs, existing) },
10467
+ throwOnError: true
10468
+ });
10469
+ return "updated";
10470
+ }
10471
+ }
10472
+ })
10473
+ );
10474
+ for (let i = 0; i < datasourceResults.length; i++) {
10475
+ const result = datasourceResults[i];
10476
+ const diff = datasourceDiffs[i];
10477
+ if (result.status === "fulfilled") {
10478
+ if (result.value === "created") {
10479
+ created++;
10480
+ } else if (result.value === "updated") {
10481
+ updated++;
10482
+ }
10483
+ } else {
10484
+ const eventId = diff.action === "create" ? "push_datasource" : "update_datasource";
10485
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} datasource ${diff.name}`);
10486
+ }
10487
+ }
10488
+ if (options.delete) {
10489
+ const staleComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
10490
+ const deleteComponentResults = await Promise.allSettled(
10491
+ staleComponents.map(async (diff) => {
10492
+ const existing = remote.components.get(diff.name);
10493
+ if (existing?.id) {
10494
+ await client.components.delete(existing.id, {
10495
+ path: { space_id: spaceIdNum },
10496
+ throwOnError: true
10497
+ });
10498
+ return true;
10499
+ }
10500
+ })
10501
+ );
10502
+ for (let i = 0; i < deleteComponentResults.length; i++) {
10503
+ const result = deleteComponentResults[i];
10504
+ if (result.status === "fulfilled") {
10505
+ if (result.value) {
10506
+ deleted++;
10507
+ }
10508
+ } else {
10509
+ handleAPIError("delete_component", result.reason, `Failed to delete component ${staleComponents[i].name}`);
10510
+ }
10511
+ }
10512
+ const staleDatasources = diffResult.diffs.filter((d) => d.type === "datasource" && d.action === "stale");
10513
+ const deleteDatasourceResults = await Promise.allSettled(
10514
+ staleDatasources.map(async (diff) => {
10515
+ const existing = remote.datasources.get(diff.name);
10516
+ if (existing?.id) {
10517
+ await client.datasources.delete(existing.id, {
10518
+ path: { space_id: spaceIdNum },
10519
+ throwOnError: true
10520
+ });
10521
+ return true;
10522
+ }
10523
+ })
10524
+ );
10525
+ for (let i = 0; i < deleteDatasourceResults.length; i++) {
10526
+ const result = deleteDatasourceResults[i];
10527
+ if (result.status === "fulfilled") {
10528
+ if (result.value) {
10529
+ deleted++;
10530
+ }
10531
+ } else {
10532
+ handleAPIError("delete_datasource", result.reason, `Failed to delete datasource ${staleDatasources[i].name}`);
10533
+ }
10534
+ }
10535
+ }
10536
+ return { created, updated, deleted };
10537
+ }
10538
+ function buildChangesetEntries(diffResult, local, remote, options) {
10539
+ const changes = [];
10540
+ for (const diff of diffResult.diffs) {
10541
+ if (diff.action === "unchanged") {
10542
+ continue;
10543
+ }
10544
+ if (diff.action === "stale" && !options.delete) {
10545
+ continue;
10546
+ }
10547
+ const action = diff.action === "stale" ? "delete" : diff.action;
10548
+ let remoteSrc;
10549
+ let localSrc;
10550
+ if (diff.type === "component") {
10551
+ remoteSrc = remote.components.get(diff.name);
10552
+ localSrc = local.components.find((c) => c.name === diff.name);
10553
+ } else if (diff.type === "datasource") {
10554
+ remoteSrc = remote.datasources.get(diff.name);
10555
+ localSrc = local.datasources.find((d) => d.name === diff.name);
10556
+ }
10557
+ changes.push({
10558
+ type: diff.type,
10559
+ name: diff.name,
10560
+ action,
10561
+ ...remoteSrc && { before: { ...remoteSrc } },
10562
+ ...localSrc && { after: { ...localSrc } }
10563
+ });
10564
+ }
10565
+ return changes;
10566
+ }
10567
+
10568
+ async function ensureDir(dir) {
10569
+ await mkdir(dir, { recursive: true });
10570
+ }
10571
+ async function saveChangeset(basePath, data) {
10572
+ const dir = join(basePath, "schema", "changesets");
10573
+ await ensureDir(dir);
10574
+ const fileName = `${fileTimestamp(data.timestamp)}.json`;
10575
+ const filePath = join(dir, fileName);
10576
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
10577
+ return filePath;
10578
+ }
10579
+
10580
+ const SENTINEL_FIELDS = /* @__PURE__ */ new Set(["_uid", "component"]);
10581
+ function classifyFieldChanges(remoteSchema, localSchema) {
10582
+ const removed = [];
10583
+ const added = [];
10584
+ const typeChanged = [];
10585
+ const requiredAdded = [];
10586
+ const requiredChanged = [];
10587
+ for (const [field, remoteField] of Object.entries(remoteSchema)) {
10588
+ if (SENTINEL_FIELDS.has(field)) {
10589
+ continue;
10590
+ }
10591
+ if (typeof remoteField.type !== "string") {
10592
+ continue;
10593
+ }
10594
+ if (!(field in localSchema)) {
10595
+ removed.push({ field, type: remoteField.type });
10596
+ }
10597
+ }
10598
+ for (const [field, localField] of Object.entries(localSchema)) {
10599
+ if (SENTINEL_FIELDS.has(field)) {
10600
+ continue;
10601
+ }
10602
+ if (typeof localField.type !== "string") {
10603
+ continue;
10604
+ }
10605
+ if (!(field in remoteSchema)) {
10606
+ if (localField.required) {
10607
+ requiredAdded.push({ field, type: localField.type });
10608
+ } else {
10609
+ added.push({ field, type: localField.type, required: false });
10610
+ }
10611
+ } else {
10612
+ const remoteField = remoteSchema[field];
10613
+ if (typeof remoteField?.type !== "string") {
10614
+ continue;
10615
+ }
10616
+ if (remoteField.type !== localField.type) {
10617
+ typeChanged.push({ field, oldType: remoteField.type, newType: localField.type });
10618
+ }
10619
+ if (localField.required && !remoteField.required) {
10620
+ requiredChanged.push({ field, type: localField.type });
10621
+ }
10622
+ }
10623
+ }
10624
+ return { removed, added, typeChanged, requiredAdded, requiredChanged };
10625
+ }
10626
+ function longestCommonSubstring(a, b) {
10627
+ let maxLen = 0;
10628
+ for (let i = 0; i < a.length; i++) {
10629
+ for (let j = 0; j < b.length; j++) {
10630
+ let len = 0;
10631
+ while (i + len < a.length && j + len < b.length && a[i + len] === b[j + len]) {
10632
+ len++;
10633
+ }
10634
+ if (len > maxLen) {
10635
+ maxLen = len;
10636
+ }
10637
+ }
10638
+ }
10639
+ return maxLen;
10640
+ }
10641
+ function nameSimilarity(a, b) {
10642
+ const longer = Math.max(a.length, b.length);
10643
+ if (longer === 0) {
10644
+ return 1;
10645
+ }
10646
+ return longestCommonSubstring(a, b) / longer;
10647
+ }
10648
+ function detectRenames(removed, added) {
10649
+ const renames = [];
10650
+ const usedRemoved = /* @__PURE__ */ new Set();
10651
+ const usedAdded = /* @__PURE__ */ new Set();
10652
+ const addedByType = /* @__PURE__ */ new Map();
10653
+ for (const addedField of added) {
10654
+ if (!addedByType.has(addedField.type)) {
10655
+ addedByType.set(addedField.type, []);
10656
+ }
10657
+ addedByType.get(addedField.type).push(addedField);
10658
+ }
10659
+ const isSinglePair = removed.length === 1 && added.length === 1;
10660
+ for (const removedField of removed) {
10661
+ const candidates = addedByType.get(removedField.type) ?? [];
10662
+ const availableCandidates = candidates.filter((c) => !usedAdded.has(c.field));
10663
+ if (availableCandidates.length === 0) {
10664
+ continue;
10665
+ }
10666
+ let bestCandidate = availableCandidates[0];
10667
+ let bestScore = nameSimilarity(removedField.field, bestCandidate.field);
10668
+ for (let i = 1; i < availableCandidates.length; i++) {
10669
+ const score = nameSimilarity(removedField.field, availableCandidates[i].field);
10670
+ if (score > bestScore) {
10671
+ bestScore = score;
10672
+ bestCandidate = availableCandidates[i];
10673
+ }
10674
+ }
10675
+ if (!isSinglePair && bestScore < 0.3) {
10676
+ continue;
10677
+ }
10678
+ renames.push({ oldField: removedField.field, newField: bestCandidate.field, fieldType: removedField.type });
10679
+ usedRemoved.add(removedField.field);
10680
+ usedAdded.add(bestCandidate.field);
10681
+ }
10682
+ const unmatchedRemoved = removed.filter((r) => !usedRemoved.has(r.field));
10683
+ const unmatchedAdded = added.filter((a) => !usedAdded.has(a.field));
10684
+ return { renames, unmatchedRemoved, unmatchedAdded };
10685
+ }
10686
+ function analyzeBreakingChanges(diffResult, local, remote) {
10687
+ const results = [];
10688
+ const updatedComponents = diffResult.diffs.filter(
10689
+ (d) => d.type === "component" && d.action === "update"
10690
+ );
10691
+ for (const diff of updatedComponents) {
10692
+ const localComp = local.components.find((c) => c.name === diff.name);
10693
+ const remoteComp = remote.components.get(diff.name);
10694
+ if (!localComp?.schema || !remoteComp?.schema) {
10695
+ continue;
10696
+ }
10697
+ const classification = classifyFieldChanges(
10698
+ remoteComp.schema,
10699
+ localComp.schema
10700
+ );
10701
+ const changes = [];
10702
+ const { renames, unmatchedRemoved } = detectRenames(classification.removed, classification.added);
10703
+ for (const rename of renames) {
10704
+ changes.push({ kind: "rename", field: rename.newField, oldField: rename.oldField });
10705
+ }
10706
+ for (const removed of unmatchedRemoved) {
10707
+ changes.push({ kind: "removed", field: removed.field });
10708
+ }
10709
+ for (const tc of classification.typeChanged) {
10710
+ changes.push({ kind: "type_changed", field: tc.field, oldType: tc.oldType, newType: tc.newType });
10711
+ }
10712
+ for (const ra of classification.requiredAdded) {
10713
+ changes.push({ kind: "required_added", field: ra.field, fieldType: ra.type });
10714
+ }
10715
+ for (const rc of classification.requiredChanged) {
10716
+ changes.push({ kind: "required_changed", field: rc.field, fieldType: rc.type });
10717
+ }
10718
+ if (changes.length > 0) {
10719
+ results.push({ componentName: diff.name, changes });
10720
+ }
10721
+ }
10722
+ return results;
10723
+ }
10724
+
10725
+ const COMPATIBLE_TYPES = /* @__PURE__ */ new Set(["text:textarea", "textarea:text"]);
10726
+ function defaultForType(fieldType) {
10727
+ switch (fieldType) {
10728
+ case "text":
10729
+ case "textarea":
10730
+ case "markdown":
10731
+ return `''`;
10732
+ case "number":
10733
+ return "0";
10734
+ case "boolean":
10735
+ return "false";
10736
+ default:
10737
+ return null;
10738
+ }
10739
+ }
10740
+ function typeConversion(field, oldType, newType) {
10741
+ const key = `${oldType}:${newType}`;
10742
+ if (COMPATIBLE_TYPES.has(key)) {
10743
+ return null;
10744
+ }
10745
+ const accessor = `block.${field}`;
10746
+ switch (key) {
10747
+ case "text:number":
10748
+ return `${accessor} = Number(${accessor}) || 0;`;
10749
+ case "number:text":
10750
+ return `${accessor} = String(${accessor});`;
10751
+ case "text:boolean":
10752
+ return `${accessor} = !!${accessor};`;
10753
+ case "boolean:text":
10754
+ return `${accessor} = String(${accessor});`;
10755
+ default:
10756
+ return `${accessor}; // TODO: convert from ${oldType} to ${newType}`;
10757
+ }
10758
+ }
10759
+ function renderMigrationCode(changes) {
10760
+ const lines = [];
10761
+ lines.push(" // Review this migration before running it against your space.");
10762
+ lines.push(" // Generated migrations are scaffolds and may need manual adjustments.");
10763
+ lines.push(" // Example rename migration:");
10764
+ lines.push(" // block.new_field = block.old_field;");
10765
+ lines.push(" // delete block.old_field;");
10766
+ lines.push("");
10767
+ for (const change of changes) {
10768
+ switch (change.kind) {
10769
+ case "rename":
10770
+ lines.push(` // Rename: ${change.oldField} \u2192 ${change.field}`);
10771
+ lines.push(` if ('${change.oldField}' in block) {`);
10772
+ lines.push(` block.${change.field} = block.${change.oldField};`);
10773
+ lines.push(` delete block.${change.oldField};`);
10774
+ lines.push(` }`);
10775
+ break;
10776
+ case "removed":
10777
+ if (change.renameHint) {
10778
+ lines.push(` // If '${change.field}' was renamed to '${change.renameHint.newField}', uncomment:`);
10779
+ lines.push(` // block.${change.renameHint.newField} = block.${change.field};`);
10780
+ } else {
10781
+ lines.push(` // Removed field: ${change.field}`);
10782
+ }
10783
+ lines.push(` delete block.${change.field};`);
10784
+ break;
10785
+ case "type_changed": {
10786
+ const conversion = typeConversion(change.field, change.oldType, change.newType);
10787
+ if (conversion) {
10788
+ lines.push(` // Type change: ${change.field} (${change.oldType} \u2192 ${change.newType})`);
10789
+ lines.push(` ${conversion}`);
10790
+ }
10791
+ break;
10792
+ }
10793
+ case "required_added": {
10794
+ const defaultValue = defaultForType(change.fieldType);
10795
+ lines.push(` // New required field: ${change.field} (${change.fieldType})`);
10796
+ if (defaultValue !== null) {
10797
+ lines.push(` // TODO: provide a meaningful default value`);
10798
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
10799
+ } else {
10800
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
10801
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
10802
+ }
10803
+ break;
10804
+ }
10805
+ case "required_changed": {
10806
+ const defaultValue = defaultForType(change.fieldType);
10807
+ lines.push(` // Field is now required: ${change.field} (${change.fieldType})`);
10808
+ lines.push(` // Existing stories may have null/undefined values \u2014 provide a default for those.`);
10809
+ if (defaultValue !== null) {
10810
+ lines.push(` // TODO: provide a meaningful default value`);
10811
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
10812
+ } else {
10813
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
10814
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
10815
+ }
10816
+ break;
10817
+ }
10818
+ }
10819
+ lines.push("");
10820
+ }
10821
+ const body = lines.length > 0 ? `
10822
+ ${lines.join("\n")}` : "\n";
10823
+ return `export default function (block) {${body} return block;
10824
+ }
10825
+ `;
10826
+ }
10827
+ async function writeMigrationFile(options) {
10828
+ const { spaceId, componentName, code, timestamp, basePath } = options;
10829
+ const dir = resolvePath(basePath, `migrations/${spaceId}`);
10830
+ await mkdir(dir, { recursive: true });
10831
+ const fileName = `${componentName}.${fileTimestamp(timestamp)}.js`;
10832
+ const filePath = join(dir, fileName);
10833
+ await writeFile(filePath, code, "utf-8");
10834
+ return filePath;
10835
+ }
10836
+
10837
+ const DEFAULT_GROUPS_FILENAME = "groups.json";
10838
+ const CONSOLIDATED_COMPONENTS_FILENAME = "components.json";
10839
+ async function writeLocalComponents({
10840
+ space,
10841
+ basePath,
10842
+ resolved,
10843
+ diffResult,
10844
+ deleteRemoved,
10845
+ ui,
10846
+ logger
10847
+ }) {
10848
+ const componentsDir = resolveCommandPath(directories.components, space, basePath);
10849
+ const consolidatedPath = join(componentsDir, CONSOLIDATED_COMPONENTS_FILENAME);
10850
+ if (await fileExists(consolidatedPath)) {
10851
+ ui.warn(
10852
+ `A consolidated ${CONSOLIDATED_COMPONENTS_FILENAME} exists at ${displayPath(componentsDir, basePath)}. 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.`
10853
+ );
10854
+ }
10855
+ for (const component of resolved.components) {
10856
+ const filePath = join(componentsDir, `${sanitizeFilename(component.name || "")}.json`);
10857
+ await saveToFile(filePath, JSON.stringify(component, null, 2));
10858
+ }
10859
+ const groupsPath = join(componentsDir, DEFAULT_GROUPS_FILENAME);
10860
+ if (await fileExists(groupsPath)) {
10861
+ try {
10862
+ await unlink(groupsPath);
10863
+ logger.info("Removed stale local groups file", { path: displayPath(groupsPath, basePath) });
10864
+ } catch (error) {
10865
+ if (error.code !== "ENOENT") {
10866
+ throw error;
10867
+ }
10868
+ }
10869
+ }
10870
+ if (deleteRemoved) {
10871
+ const staleComponents = diffResult.diffs.filter(
10872
+ (d) => d.type === "component" && d.action === "stale"
10873
+ );
10874
+ for (const stale of staleComponents) {
10875
+ const filePath = join(componentsDir, `${sanitizeFilename(stale.name)}.json`);
10876
+ try {
10877
+ await unlink(filePath);
10878
+ logger.info("Removed stale local component file", { path: displayPath(filePath, basePath) });
10879
+ } catch (error) {
10880
+ if (error.code !== "ENOENT") {
10881
+ throw error;
10882
+ }
10883
+ }
10884
+ }
10885
+ }
10886
+ logger.info("Wrote local component files", {
10887
+ space,
10888
+ componentsWritten: resolved.components.length
10889
+ });
10890
+ }
10891
+
10892
+ 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) => {
10893
+ const ui = getUI();
10894
+ const logger = getLogger();
10895
+ const reporter = getReporter();
10896
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
10897
+ const { state } = session();
10898
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Pushing schema...");
10899
+ logger.info("Schema push started", { entryFile, space });
10900
+ if (!requireAuthentication(state, verbose)) {
10901
+ return;
10902
+ }
10903
+ if (!space) {
10904
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
10905
+ return;
10906
+ }
10907
+ const summary = { total: 0, succeeded: 0, failed: 0 };
10908
+ try {
10909
+ const loadSpinner = ui.createSpinner("Resolving schema...");
10910
+ let local;
10911
+ try {
10912
+ local = await loadSchema(entryFile);
10913
+ } catch (maybeError) {
10914
+ loadSpinner.failed("Failed to resolve schema");
10915
+ handleError(toError(maybeError), verbose);
10916
+ return;
10917
+ }
10918
+ loadSpinner.succeed(`Found: ${local.components.length} components, ${local.datasources.length} datasources`);
10919
+ const totalLocal = local.components.length + local.datasources.length;
10920
+ if (totalLocal === 0) {
10921
+ ui.warn("No components or datasources found in the entry file. Verify the file exports schema definitions.");
10922
+ return;
10923
+ }
10924
+ const remoteSpinner = ui.createSpinner(`Fetching remote state from space ${space}...`);
10925
+ let remoteResult;
10926
+ try {
10927
+ remoteResult = await fetchRemoteSchema(space);
10928
+ } catch (maybeError) {
10929
+ remoteSpinner.failed("Failed to fetch remote schema");
10930
+ handleError(toError(maybeError), verbose);
10931
+ return;
10932
+ }
10933
+ const { remote, rawComponents, rawComponentFolders, rawDatasources } = remoteResult;
10934
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.datasources.size} datasources`);
10935
+ const diffResult = diffSchema(local, remote);
10936
+ ui.br();
10937
+ ui.log(formatDiffOutput(diffResult, { delete: options.delete }));
10938
+ if (options.migrations) {
10939
+ const breakingChanges = analyzeBreakingChanges(diffResult, local, remote);
10940
+ if (breakingChanges.length > 0) {
10941
+ const totalChanges = breakingChanges.reduce((sum, c) => sum + c.changes.length, 0);
10942
+ ui.br();
10943
+ ui.warn(`${totalChanges} breaking change(s) detected in ${breakingChanges.length} component(s).`);
10944
+ ui.info("Generated migrations are scaffolds. Review and adjust them before running `storyblok migrations run`.");
10945
+ if (!options.dryRun) {
10946
+ const explicitMigrations = command.getOptionValueSource("migrations") === "cli";
10947
+ const shouldGenerate = explicitMigrations || await confirm({
10948
+ message: "Generate migration files for breaking changes?",
10949
+ default: true
10950
+ });
10951
+ if (shouldGenerate) {
10952
+ const migrationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
10953
+ const resolvedBase = resolvePath(basePath, "");
10954
+ for (const comp of breakingChanges) {
10955
+ const renames = comp.changes.filter((c) => c.kind === "rename");
10956
+ if (renames.length > 0 && explicitMigrations) {
10957
+ for (const r of renames) {
10958
+ if (r.kind === "rename") {
10959
+ ui.log(` Assumed rename in '${comp.componentName}': ${r.oldField} \u2192 ${r.field}`);
10960
+ }
10961
+ }
10962
+ }
10963
+ if (renames.length > 0 && !explicitMigrations) {
10964
+ ui.br();
10965
+ ui.log(`Detected renames in '${comp.componentName}':`);
10966
+ for (const r of renames) {
10967
+ if (r.kind === "rename") {
10968
+ ui.log(` ${r.oldField} \u2192 ${r.field}`);
10969
+ }
10970
+ }
10971
+ const renameConfirmed = await confirm({
10972
+ message: "Are these renames correct?",
10973
+ default: true
10974
+ });
10975
+ if (!renameConfirmed) {
10976
+ comp.changes = comp.changes.map((c) => {
10977
+ if (c.kind === "rename") {
10978
+ return { kind: "removed", field: c.oldField, renameHint: { newField: c.field } };
10979
+ }
10980
+ return c;
10981
+ });
10982
+ }
10983
+ }
10984
+ const code = renderMigrationCode(comp.changes);
10985
+ const path = await writeMigrationFile({
10986
+ spaceId: space,
10987
+ componentName: comp.componentName,
10988
+ code,
10989
+ timestamp: migrationTimestamp,
10990
+ basePath: resolvedBase
10991
+ });
10992
+ const migrationPath = displayPath(path, basePath);
10993
+ logger.info("Migration generated", { component: comp.componentName, path: migrationPath });
10994
+ ui.log(` Generated: ${migrationPath}`);
10995
+ }
10996
+ ui.br();
10997
+ ui.info(`Run migrations when ready: storyblok migrations run --space ${space}`);
10998
+ }
10999
+ }
11000
+ }
11001
+ }
11002
+ if (options.delete) {
11003
+ const deletedComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
11004
+ for (const comp of deletedComponents) {
11005
+ ui.warn(`Component '${comp.name}' will be deleted. Stories using it will have out-of-schema content.`);
11006
+ }
11007
+ }
11008
+ if (diffResult.stale > 0 && !options.delete) {
11009
+ ui.warn(`${diffResult.stale} stale entity(s) exist remotely but not in schema. Use --delete to remove.`);
11010
+ }
11011
+ if (options.dryRun) {
11012
+ ui.info("Dry run \u2014 no changes applied.");
11013
+ logger.info("Dry run completed", { creates: diffResult.creates, updates: diffResult.updates });
11014
+ return;
11015
+ }
11016
+ const resolvedPath = resolvePath(basePath, "");
11017
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11018
+ const changesetPath = await saveChangeset(resolvedPath, {
11019
+ timestamp,
11020
+ spaceId: Number(space),
11021
+ remote: { components: rawComponents, componentFolders: rawComponentFolders, datasources: rawDatasources },
11022
+ changes: buildChangesetEntries(diffResult, local, remote, { delete: options.delete })
11023
+ });
11024
+ logger.info("Changeset saved", { path: displayPath(changesetPath, basePath) });
11025
+ const nothingToPush = diffResult.creates === 0 && diffResult.updates === 0 && (!options.delete || diffResult.stale === 0);
11026
+ if (nothingToPush) {
11027
+ ui.ok("Everything up to date \u2014 nothing to push.");
11028
+ } else {
11029
+ const pushSpinner = ui.createSpinner("Pushing schema...");
11030
+ let result;
11031
+ try {
11032
+ result = await executePush(space, local, remote, diffResult, { delete: options.delete });
11033
+ } catch (error) {
11034
+ pushSpinner.failed("Failed to push schema");
11035
+ throw error;
11036
+ }
11037
+ summary.total = result.created + result.updated + result.deleted;
11038
+ summary.succeeded = summary.total;
11039
+ pushSpinner.succeed(`Pushed ${result.created} creations, ${result.updated} updates${result.deleted > 0 ? `, ${result.deleted} deletions` : ""}.`);
11040
+ }
11041
+ if (options.writeComponents) {
11042
+ try {
11043
+ await writeLocalComponents({
11044
+ space,
11045
+ basePath,
11046
+ resolved: local,
11047
+ diffResult,
11048
+ deleteRemoved: options.delete,
11049
+ ui,
11050
+ logger
11051
+ });
11052
+ } catch (writeError) {
11053
+ ui.warn(`Failed to write local component files: ${toError(writeError).message}`);
11054
+ logger.warn("Failed to write local component files", { error: toError(writeError).message });
11055
+ }
11056
+ }
11057
+ } catch (maybeError) {
11058
+ summary.failed += 1;
11059
+ handleError(toError(maybeError), verbose);
11060
+ } finally {
11061
+ logger.info("Schema push finished", { summary });
11062
+ reporter.addSummary("schemaPushResults", summary);
11063
+ reporter.finalize();
11064
+ }
11065
+ });
11066
+
11067
+ function buildGroupPathByUuid(folders) {
11068
+ const byUuid = new Map(folders.map((folder) => [folder.uuid, folder]));
11069
+ const cache = /* @__PURE__ */ new Map();
11070
+ function pathFor(uuid, visited) {
11071
+ if (!uuid) {
11072
+ return [];
11073
+ }
11074
+ const cached = cache.get(uuid);
11075
+ if (cached) {
11076
+ return cached;
11077
+ }
11078
+ const folder = byUuid.get(uuid);
11079
+ if (!folder) {
11080
+ return [];
11081
+ }
11082
+ if (visited.has(uuid)) {
11083
+ return [];
11084
+ }
11085
+ visited.add(uuid);
11086
+ const path = [...pathFor(folder.parent_uuid, visited), slugify(folder.name)];
11087
+ cache.set(uuid, path);
11088
+ return path;
11089
+ }
11090
+ for (const folder of folders) {
11091
+ pathFor(folder.uuid, /* @__PURE__ */ new Set());
11092
+ }
11093
+ return cache;
11094
+ }
11095
+
11096
+ const FIELD_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "pos"]);
11097
+ function toCamelCase(str) {
11098
+ return str.toLowerCase().replace(/[\s_-]+(.)/g, (_, char) => char.toUpperCase());
11099
+ }
11100
+ function toKebabCase(str) {
11101
+ return str.replace(/[\s_]+/g, "-").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
11102
+ }
11103
+ function componentVarName(name) {
11104
+ return `${toCamelCase(name)}Block`;
11105
+ }
11106
+ function datasourceVarName(name) {
11107
+ return `${toCamelCase(name)}Datasource`;
11108
+ }
11109
+ function componentFileName(name) {
11110
+ return toKebabCase(name);
11111
+ }
11112
+ function datasourceFileName(datasource) {
11113
+ return toKebabCase(datasource.slug || datasource.name);
11114
+ }
11115
+ function toDslField(field) {
11116
+ const { component_whitelist, datasource_slug, ...rest } = field;
11117
+ const out = { ...rest };
11118
+ if (component_whitelist !== void 0) {
11119
+ out.allow = component_whitelist;
11120
+ }
11121
+ if (datasource_slug !== void 0) {
11122
+ out.datasource = datasource_slug;
11123
+ }
11124
+ return out;
11125
+ }
11126
+ function generateFieldCode(fieldName, fieldData, depth) {
11127
+ const clean = toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS));
11128
+ return `defineField('${fieldName.replace(/'/g, "\\'")}', ${formatValue(clean, depth)})`;
11129
+ }
11130
+ function sortSchemaByPos(schema) {
11131
+ return Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
11132
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
11133
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
11134
+ return posA - posB;
11135
+ });
11136
+ }
11137
+ function generateComponentFile(component) {
11138
+ const lines = [];
11139
+ lines.push("import {");
11140
+ lines.push(" defineBlock,");
11141
+ lines.push(" defineField,");
11142
+ lines.push("} from '@storyblok/schema';");
11143
+ lines.push("");
11144
+ const varName = componentVarName(component.name);
11145
+ lines.push(`export const ${varName} = defineBlock({`);
11146
+ const clean = stripKeys(component, COMPONENT_STRIP_KEYS);
11147
+ delete clean.component_group_uuid;
11148
+ const orderedKeys = [];
11149
+ if (clean.name !== void 0) {
11150
+ orderedKeys.push("name");
11151
+ }
11152
+ if (clean.display_name !== void 0) {
11153
+ orderedKeys.push("display_name");
11154
+ }
11155
+ if (clean.is_root !== void 0) {
11156
+ orderedKeys.push("is_root");
11157
+ }
11158
+ if (clean.is_nestable !== void 0) {
11159
+ orderedKeys.push("is_nestable");
11160
+ }
11161
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
11162
+ for (const key of Object.keys(clean).sort()) {
11163
+ if (!handled.has(key)) {
11164
+ orderedKeys.push(key);
11165
+ }
11166
+ }
11167
+ for (const key of orderedKeys) {
11168
+ lines.push(`${INDENT}${key}: ${formatValue(clean[key], 1)},`);
11169
+ }
11170
+ if (clean.schema && typeof clean.schema === "object") {
11171
+ const schema = clean.schema;
11172
+ const sortedFields = sortSchemaByPos(schema);
11173
+ if (sortedFields.length > 0) {
11174
+ lines.push(`${INDENT}fields: [`);
11175
+ for (const [fieldName, fieldData] of sortedFields) {
11176
+ const fieldCode = generateFieldCode(fieldName, fieldData, 2);
11177
+ lines.push(`${INDENT}${INDENT}${fieldCode},`);
11178
+ }
11179
+ lines.push(`${INDENT}],`);
11180
+ } else {
11181
+ lines.push(`${INDENT}fields: [],`);
11182
+ }
11183
+ }
11184
+ lines.push("});");
11185
+ lines.push("");
11186
+ return lines.join("\n");
11187
+ }
11188
+ function generateDatasourceFile(datasource) {
11189
+ const lines = [];
11190
+ lines.push("import { defineDatasource } from '@storyblok/schema';");
11191
+ lines.push("");
11192
+ const varName = datasourceVarName(datasource.name);
11193
+ lines.push(`export const ${varName} = defineDatasource({`);
11194
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
11195
+ if (clean.name !== void 0) {
11196
+ lines.push(`${INDENT}name: ${formatValue(clean.name, 1)},`);
11197
+ }
11198
+ if (clean.slug !== void 0) {
11199
+ lines.push(`${INDENT}slug: ${formatValue(clean.slug, 1)},`);
11200
+ }
11201
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
11202
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
11203
+ if (!handled.has(key)) {
11204
+ lines.push(`${INDENT}${key}: ${formatValue(value, 1)},`);
11205
+ }
11206
+ }
11207
+ lines.push("});");
11208
+ lines.push("");
11209
+ return lines.join("\n");
11210
+ }
11211
+ function generateSchemaFile(components, datasources, groupPathByComponentName = /* @__PURE__ */ new Map()) {
11212
+ const lines = [];
11213
+ lines.push("import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';");
11214
+ lines.push("import type { MapiStory as InferStoryMapi } from '@storyblok/schema';");
11215
+ lines.push("");
11216
+ for (const component of components) {
11217
+ const varName = componentVarName(component.name);
11218
+ const fileName = componentFileName(component.name);
11219
+ const segments = groupPathByComponentName.get(component.name) ?? [];
11220
+ const subPath = segments.length > 0 ? `${segments.join("/")}/` : "";
11221
+ lines.push(`import { ${varName} } from './blocks/${subPath}${fileName}';`);
11222
+ }
11223
+ for (const datasource of datasources) {
11224
+ const varName = datasourceVarName(datasource.name);
11225
+ const fileName = datasourceFileName(datasource);
11226
+ lines.push(`import { ${varName} } from './datasources/${fileName}';`);
11227
+ }
11228
+ lines.push("");
11229
+ lines.push("export const schema = {");
11230
+ if (components.length > 0) {
11231
+ lines.push(" blocks: {");
11232
+ for (const component of components) {
11233
+ const varName = componentVarName(component.name);
11234
+ lines.push(` ${varName},`);
11235
+ }
11236
+ lines.push(" },");
11237
+ }
11238
+ if (datasources.length > 0) {
11239
+ lines.push(" datasources: {");
11240
+ for (const datasource of datasources) {
11241
+ const varName = datasourceVarName(datasource.name);
11242
+ lines.push(` ${varName},`);
11243
+ }
11244
+ lines.push(" },");
11245
+ }
11246
+ lines.push("};");
11247
+ lines.push("");
11248
+ lines.push("export type Schema = InferSchema<typeof schema>;");
11249
+ lines.push("export type Blocks = Schema['blocks'];");
11250
+ lines.push("export type Story = InferStory<Blocks>;");
11251
+ lines.push("export type StoryMapi = InferStoryMapi<Blocks>;");
11252
+ lines.push("");
11253
+ return lines.join("\n");
11254
+ }
11255
+
11256
+ async function writeFileWithDirs(filePath, content) {
11257
+ const dir = dirname(filePath);
11258
+ await mkdir(dir, { recursive: true });
11259
+ await writeFile(filePath, content, "utf-8");
11260
+ }
11261
+ async function writeSchemaFiles(targetPath, components, componentFolders, datasources) {
11262
+ const writtenFiles = [];
11263
+ const groupPathByUuid = buildGroupPathByUuid(componentFolders);
11264
+ const groupPathByComponentName = /* @__PURE__ */ new Map();
11265
+ for (const comp of components) {
11266
+ const segments = comp.component_group_uuid ? groupPathByUuid.get(comp.component_group_uuid) ?? [] : [];
11267
+ if (segments.length > 0) {
11268
+ groupPathByComponentName.set(comp.name, segments);
11269
+ }
11270
+ const fileName = componentFileName(comp.name);
11271
+ const filePath = join(targetPath, "blocks", ...segments, `${fileName}.ts`);
11272
+ await writeFileWithDirs(filePath, generateComponentFile(comp));
11273
+ writtenFiles.push(filePath);
11274
+ }
11275
+ for (const ds of datasources) {
11276
+ const fileName = datasourceFileName(ds);
11277
+ const filePath = join(targetPath, "datasources", `${fileName}.ts`);
11278
+ await writeFileWithDirs(filePath, generateDatasourceFile(ds));
11279
+ writtenFiles.push(filePath);
11280
+ }
11281
+ const schemaPath = join(targetPath, "schema.ts");
11282
+ await writeFileWithDirs(schemaPath, generateSchemaFile(components, datasources, groupPathByComponentName));
11283
+ writtenFiles.push(schemaPath);
11284
+ return writtenFiles;
11285
+ }
11286
+
11287
+ async function isTargetEmpty(targetPath) {
11288
+ try {
11289
+ const entries = await readdir(targetPath);
11290
+ return entries.every((entry) => entry.startsWith("."));
11291
+ } catch (maybeError) {
11292
+ const error = maybeError;
11293
+ if (error?.code === "ENOENT") {
11294
+ return true;
11295
+ }
11296
+ throw error;
11297
+ }
11298
+ }
11299
+ 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) => {
11300
+ const ui = getUI();
11301
+ const logger = getLogger();
11302
+ const reporter = getReporter();
11303
+ const { space, verbose } = command.optsWithGlobals();
11304
+ const { state } = session();
11305
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Initializing schema...");
11306
+ logger.info("Schema init started", { space });
11307
+ if (!requireAuthentication(state, verbose)) {
11308
+ return;
11309
+ }
11310
+ if (!space) {
11311
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
11312
+ return;
11313
+ }
11314
+ const targetPath = resolve(options.outDir);
11315
+ const targetDisplayPath = displayPath(targetPath, options.outDir);
11316
+ if (!await isTargetEmpty(targetPath)) {
11317
+ handleError(
11318
+ new CommandError(`Target directory ${targetDisplayPath} 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.`),
11319
+ verbose
11320
+ );
11321
+ return;
11322
+ }
11323
+ const summary = { total: 0, succeeded: 0, failed: 0 };
11324
+ try {
11325
+ const fetchSpinner = ui.createSpinner(`Fetching schema from space ${space}...`);
11326
+ let fetchResult;
11327
+ try {
11328
+ fetchResult = await fetchRemoteSchema(space);
11329
+ } catch (maybeError) {
11330
+ fetchSpinner.failed("Failed to fetch remote schema");
11331
+ handleError(toError(maybeError), verbose);
11332
+ return;
11333
+ }
11334
+ const { rawComponents, rawComponentFolders, rawDatasources } = fetchResult;
11335
+ fetchSpinner.succeed(`Found: ${rawComponents.length} components, ${rawComponentFolders.length} component folders, ${rawDatasources.length} datasources`);
11336
+ const writeSpinner = ui.createSpinner(`Generating TypeScript files to ${targetDisplayPath}...`);
11337
+ const writtenFiles = await writeSchemaFiles(targetPath, rawComponents, rawComponentFolders, rawDatasources);
11338
+ summary.total = writtenFiles.length;
11339
+ summary.succeeded = writtenFiles.length;
11340
+ writeSpinner.succeed(`Generated ${writtenFiles.length} files`);
11341
+ ui.list(writtenFiles.map((file) => displayPath(file, options.outDir)));
11342
+ ui.warn("`schema init` is a one-time bootstrap step for adopting an existing space. Review generated files before continuing.");
11343
+ ui.info("After bootstrapping, keep your local schema as the source of truth and use `schema push` for ongoing changes.");
11344
+ ui.info("Make sure `@storyblok/schema` is installed in the project that imports these files (e.g. `pnpm add @storyblok/schema`).");
11345
+ } catch (maybeError) {
11346
+ summary.failed += 1;
11347
+ handleError(toError(maybeError), verbose);
11348
+ } finally {
11349
+ logger.info("Schema init finished", { summary });
11350
+ reporter.addSummary("schemaInitResults", summary);
11351
+ reporter.finalize();
11352
+ }
11353
+ });
11354
+
11355
+ const API_ASSIGNED_FIELDS = [
11356
+ "id",
11357
+ "created_at",
11358
+ "updated_at",
11359
+ "real_name",
11360
+ "all_presets",
11361
+ "image",
11362
+ "uuid"
11363
+ ];
11364
+ function stripApiFields(payload) {
11365
+ const result = { ...payload };
11366
+ for (const field of API_ASSIGNED_FIELDS) {
11367
+ delete result[field];
11368
+ }
11369
+ return result;
11370
+ }
11371
+ async function listChangesets(basePath) {
11372
+ const dir = join(basePath, "schema", "changesets");
11373
+ if (!await fileExists(dir)) {
11374
+ return [];
11375
+ }
11376
+ const files = await readDirectory(dir);
11377
+ return files.filter((f) => f.endsWith(".json")).sort().reverse().map((f) => join(dir, f));
11378
+ }
11379
+ async function loadChangeset(filePath) {
11380
+ const content = await readFile$1(filePath, "utf-8");
11381
+ return JSON.parse(content);
11382
+ }
11383
+ function buildRollbackOps(changeset) {
11384
+ if (changeset.changes.length === 0) {
11385
+ return [];
11386
+ }
11387
+ return changeset.changes.map((entry) => {
11388
+ switch (entry.action) {
11389
+ case "create":
11390
+ return { type: entry.type, name: entry.name, action: "delete", payload: {} };
11391
+ case "update":
11392
+ return { type: entry.type, name: entry.name, action: "update", payload: entry.before ?? {} };
11393
+ case "delete":
11394
+ return { type: entry.type, name: entry.name, action: "create", payload: entry.before ?? {} };
11395
+ default:
11396
+ return { type: entry.type, name: entry.name, action: entry.action, payload: {} };
11397
+ }
11398
+ });
11399
+ }
11400
+ function rollbackAction(original) {
11401
+ switch (original) {
11402
+ case "create":
11403
+ return "delete";
11404
+ case "update":
11405
+ return "update";
11406
+ case "delete":
11407
+ return "create";
11408
+ }
11409
+ }
11410
+ function formatRollbackOutput(changes) {
11411
+ const byType = {
11412
+ component: [],
11413
+ datasource: []
11414
+ };
11415
+ for (const entry of changes) {
11416
+ byType[entry.type]?.push(entry);
11417
+ }
11418
+ const icons = {
11419
+ create: chalk.green("+"),
11420
+ update: chalk.yellow("~"),
11421
+ delete: chalk.red("-")
11422
+ };
11423
+ const lines = [];
11424
+ const sections = [
11425
+ ["Components", byType.component],
11426
+ ["Datasources", byType.datasource]
11427
+ ];
11428
+ for (const [label, entries] of sections) {
11429
+ if (entries.length === 0) {
11430
+ continue;
11431
+ }
11432
+ lines.push(chalk.bold(label));
11433
+ for (const entry of entries) {
11434
+ const action = rollbackAction(entry.action);
11435
+ const icon = icons[action] ?? " ";
11436
+ const name = action === "delete" ? chalk.red(entry.name) : entry.name;
11437
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${action})`)}`);
11438
+ if (entry.action === "update" && entry.before && entry.after) {
11439
+ let fromStr;
11440
+ let toStr;
11441
+ if (entry.type === "component") {
11442
+ fromStr = serializeComponent(applyDefaults(entry.after, COMPONENT_DEFAULTS));
11443
+ toStr = serializeComponent(applyDefaults(entry.before, COMPONENT_DEFAULTS));
11444
+ } else {
11445
+ fromStr = serializeDatasource(entry.after);
11446
+ toStr = serializeDatasource(entry.before);
11447
+ }
11448
+ if (fromStr !== toStr) {
11449
+ const patch = createTwoFilesPatch(
11450
+ `current/${entry.name}`,
11451
+ `restore/${entry.name}`,
11452
+ fromStr,
11453
+ toStr,
11454
+ "current",
11455
+ "restore"
11456
+ );
11457
+ for (const line of patch.split("\n")) {
11458
+ if (line.startsWith("+") && !line.startsWith("+++")) {
11459
+ lines.push(` ${chalk.green(line)}`);
11460
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
11461
+ lines.push(` ${chalk.red(line)}`);
11462
+ }
11463
+ }
11464
+ }
11465
+ }
11466
+ }
11467
+ lines.push("");
11468
+ }
11469
+ return lines.join("\n").trimEnd();
11470
+ }
11471
+ async function executeRollback(spaceId, ops, remote) {
11472
+ const client = getMapiClient();
11473
+ const spaceIdNum = Number(spaceId);
11474
+ let created = 0;
11475
+ let updated = 0;
11476
+ let deleted = 0;
11477
+ const componentOps = ops.filter((op) => op.type === "component");
11478
+ const datasourceOps = ops.filter((op) => op.type === "datasource");
11479
+ for (const op of componentOps.filter((o) => o.action !== "delete")) {
11480
+ if (op.action === "create") {
11481
+ const payload = toComponentCreate(stripApiFields(op.payload));
11482
+ try {
11483
+ await client.components.create({
11484
+ path: { space_id: spaceIdNum },
11485
+ body: { component: payload },
11486
+ throwOnError: true
11487
+ });
11488
+ created++;
11489
+ } catch (error) {
11490
+ handleAPIError("push_component", error, `Failed to create component ${op.name}`);
11491
+ }
11492
+ } else if (op.action === "update") {
11493
+ const existing = remote.components.get(op.name);
11494
+ if (existing?.id) {
11495
+ const payload = toComponentUpdate(stripApiFields(op.payload));
11496
+ try {
11497
+ await client.components.update(existing.id, {
11498
+ path: { space_id: spaceIdNum },
11499
+ body: { component: payload },
11500
+ throwOnError: true
11501
+ });
11502
+ updated++;
11503
+ } catch (error) {
11504
+ handleAPIError("update_component", error, `Failed to update component ${op.name}`);
11505
+ }
11506
+ }
11507
+ }
11508
+ }
11509
+ for (const op of datasourceOps.filter((o) => o.action !== "delete")) {
11510
+ if (op.action === "create") {
11511
+ const payload = toDatasourceCreate(stripApiFields(op.payload));
11512
+ try {
11513
+ await client.datasources.create({
11514
+ path: { space_id: spaceIdNum },
11515
+ body: { datasource: payload },
11516
+ throwOnError: true
11517
+ });
11518
+ created++;
11519
+ } catch (error) {
11520
+ handleAPIError("push_datasource", error, `Failed to create datasource ${op.name}`);
11521
+ }
11522
+ } else if (op.action === "update") {
11523
+ const existing = remote.datasources.get(op.name);
11524
+ if (existing?.id) {
11525
+ const payload = toDatasourceUpdate(stripApiFields(op.payload), existing);
11526
+ try {
11527
+ await client.datasources.update(existing.id, {
11528
+ path: { space_id: spaceIdNum },
11529
+ body: { datasource: payload },
11530
+ throwOnError: true
11531
+ });
11532
+ updated++;
11533
+ } catch (error) {
11534
+ handleAPIError("update_datasource", error, `Failed to update datasource ${op.name}`);
11535
+ }
11536
+ }
11537
+ }
11538
+ }
11539
+ for (const op of datasourceOps.filter((o) => o.action === "delete")) {
11540
+ const existing = remote.datasources.get(op.name);
11541
+ if (existing?.id) {
11542
+ try {
11543
+ await client.datasources.delete(existing.id, {
11544
+ path: { space_id: spaceIdNum },
11545
+ throwOnError: true
11546
+ });
11547
+ deleted++;
11548
+ } catch (error) {
11549
+ handleAPIError("delete_datasource", error, `Failed to delete datasource ${op.name}`);
11550
+ }
11551
+ }
11552
+ }
11553
+ for (const op of componentOps.filter((o) => o.action === "delete")) {
11554
+ const existing = remote.components.get(op.name);
11555
+ if (existing?.id) {
11556
+ try {
11557
+ await client.components.delete(existing.id, {
11558
+ path: { space_id: spaceIdNum },
11559
+ throwOnError: true
11560
+ });
11561
+ deleted++;
11562
+ } catch (error) {
11563
+ handleAPIError("delete_component", error, `Failed to delete component ${op.name}`);
11564
+ }
11565
+ }
11566
+ }
11567
+ return { created, updated, deleted };
11568
+ }
11569
+
11570
+ 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) => {
11571
+ const ui = getUI();
11572
+ const logger = getLogger();
11573
+ const reporter = getReporter();
11574
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
11575
+ const { state } = session();
11576
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Rolling back schema...");
11577
+ logger.info("Schema rollback started", { changesetFile, space });
11578
+ if (!requireAuthentication(state, verbose)) {
11579
+ return;
11580
+ }
11581
+ if (!space) {
11582
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
11583
+ return;
11584
+ }
11585
+ const summary = { total: 0, succeeded: 0, failed: 0 };
11586
+ try {
11587
+ const resolvedBase = resolvePath(basePath, "");
11588
+ let resolvedFile;
11589
+ if (changesetFile) {
11590
+ resolvedFile = changesetFile;
11591
+ } else if (options.latest) {
11592
+ const available = await listChangesets(resolvedBase);
11593
+ if (available.length === 0) {
11594
+ ui.warn("No changesets found. Run `schema push` first to create one.");
11595
+ return;
11596
+ }
11597
+ resolvedFile = available[0];
11598
+ ui.info(`Using latest changeset: ${basename(resolvedFile)}`);
11599
+ } else {
11600
+ const available = await listChangesets(resolvedBase);
11601
+ if (available.length === 0) {
11602
+ ui.warn("No changesets found. Run `schema push` first to create one.");
11603
+ return;
11604
+ }
11605
+ resolvedFile = await select({
11606
+ message: "Select a changeset to roll back:",
11607
+ choices: available.map((f) => ({ name: basename(f), value: f }))
11608
+ });
11609
+ }
11610
+ let changeset;
11611
+ try {
11612
+ changeset = await loadChangeset(resolvedFile);
11613
+ } catch (maybeError) {
11614
+ handleError(toError(maybeError), verbose);
11615
+ return;
11616
+ }
11617
+ logger.info("Changeset loaded", { file: resolvedFile, changes: changeset.changes.length });
11618
+ const ops = buildRollbackOps(changeset);
11619
+ if (ops.length === 0) {
11620
+ ui.ok("Changeset has no changes \u2014 nothing to roll back.");
11621
+ return;
11622
+ }
11623
+ ui.br();
11624
+ ui.log(formatRollbackOutput(changeset.changes));
11625
+ if (options.dryRun) {
11626
+ ui.info("Dry run \u2014 no changes applied.");
11627
+ logger.info("Dry run completed", { ops: ops.length });
11628
+ return;
11629
+ }
11630
+ if (!options.yes) {
11631
+ const confirmed = await confirm({
11632
+ message: `Apply rollback of ${ops.length} change(s) from ${basename(resolvedFile)}?`,
11633
+ default: false
11634
+ });
11635
+ if (!confirmed) {
11636
+ ui.info("Rollback cancelled.");
11637
+ return;
11638
+ }
11639
+ }
11640
+ const remoteSpinner = ui.createSpinner(`Fetching current remote state from space ${space}...`);
11641
+ let remoteResult;
11642
+ try {
11643
+ remoteResult = await fetchRemoteSchema(space);
11644
+ } catch (maybeError) {
11645
+ remoteSpinner.failed("Failed to fetch remote schema");
11646
+ handleError(toError(maybeError), verbose);
11647
+ return;
11648
+ }
11649
+ const { remote } = remoteResult;
11650
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.componentFolders.size} component folders, ${remote.datasources.size} datasources`);
11651
+ const rollbackSpinner = ui.createSpinner("Applying rollback...");
11652
+ let result;
11653
+ try {
11654
+ result = await executeRollback(space, ops, remote);
11655
+ } catch (error) {
11656
+ rollbackSpinner.failed("Failed to apply rollback");
11657
+ throw error;
11658
+ }
11659
+ summary.total = result.created + result.updated + result.deleted;
11660
+ summary.succeeded = summary.total;
11661
+ rollbackSpinner.succeed(`Rolled back: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted.`);
11662
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11663
+ const rollbackChangesetPath = await saveChangeset(resolvedBase, {
11664
+ timestamp,
11665
+ spaceId: Number(space),
11666
+ remote: { components: remoteResult.rawComponents, componentFolders: remoteResult.rawComponentFolders, datasources: remoteResult.rawDatasources },
11667
+ changes: ops.map((op) => ({
11668
+ type: op.type,
11669
+ name: op.name,
11670
+ action: op.action,
11671
+ ...Object.keys(op.payload).length > 0 && { after: op.payload }
11672
+ }))
11673
+ });
11674
+ logger.info("Rollback changeset saved", { path: displayPath(rollbackChangesetPath, basePath) });
11675
+ } catch (maybeError) {
11676
+ summary.failed += 1;
11677
+ handleError(toError(maybeError), verbose);
11678
+ } finally {
11679
+ logger.info("Schema rollback finished", { summary });
11680
+ reporter.addSummary("schemaRollbackResults", summary);
11681
+ reporter.finalize();
11682
+ }
11683
+ });
11684
+
9785
11685
  const program = getProgram();
9786
11686
  konsola.br();
9787
11687
  konsola.br();