storyblok 4.19.0 → 4.20.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -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",
@@ -729,6 +733,8 @@ const API_ACTIONS = {
729
733
  push_datasource: "Failed to push datasource",
730
734
  update_datasource: "Failed to update datasource",
731
735
  delete_datasource: "Failed to delete datasource",
736
+ push_datasource_entry: "Failed to push datasource entry",
737
+ update_datasource_entry: "Failed to update datasource entry",
732
738
  delete_datasource_entry: "Failed to delete datasource entry",
733
739
  create_space: "Failed to create space",
734
740
  pull_spaces: "Failed to pull spaces",
@@ -1078,6 +1084,7 @@ function maskToken(token) {
1078
1084
  const maskedPart = "*".repeat(token.length - 4);
1079
1085
  return `${visiblePart}${maskedPart}`;
1080
1086
  }
1087
+ const slugify = (text) => text.toString().toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, "").replace(/-{2,}/g, "-").replace(/^-+/, "").replace(/-+$/, "");
1081
1088
  function createRegexFromGlob(pattern) {
1082
1089
  return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`);
1083
1090
  }
@@ -1159,6 +1166,25 @@ function getPackageJson() {
1159
1166
  return packageJson$1;
1160
1167
  }
1161
1168
 
1169
+ async function fetchAllPages(fetchFunction, extractDataFunction) {
1170
+ const items = [];
1171
+ let page = 1;
1172
+ while (true) {
1173
+ const { data, response } = await fetchFunction(page);
1174
+ const totalHeader = response.headers.get("total");
1175
+ const fetchedItems = extractDataFunction(data);
1176
+ items.push(...fetchedItems);
1177
+ if (!totalHeader) {
1178
+ return items;
1179
+ }
1180
+ const total = Number(totalHeader);
1181
+ if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
1182
+ return items;
1183
+ }
1184
+ page++;
1185
+ }
1186
+ }
1187
+
1162
1188
  const __filename$1 = fileURLToPath(import.meta.url);
1163
1189
  const __dirname$1 = dirname(__filename$1);
1164
1190
  function isRegion(value) {
@@ -1247,6 +1273,10 @@ class UI {
1247
1273
  this.br();
1248
1274
  }
1249
1275
  }
1276
+ /** Plain console.log passthrough — use for preformatted or multi-line text. */
1277
+ log(message) {
1278
+ this.console?.log(message);
1279
+ }
1250
1280
  list(items) {
1251
1281
  for (const item of items) {
1252
1282
  this.console?.log(` ${item}`);
@@ -2164,14 +2194,14 @@ async function performInteractiveLogin(options) {
2164
2194
  }
2165
2195
  }
2166
2196
 
2167
- const program$e = getProgram();
2197
+ const program$f = getProgram();
2168
2198
  const allRegionsText = Object.values(regions).join(",");
2169
- 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(
2199
+ 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(
2170
2200
  "-r, --region <region>",
2171
2201
  `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}.`
2172
2202
  ).action(async (options) => {
2173
2203
  konsola.title(`${commands.LOGIN}`, colorPalette.LOGIN);
2174
- const verbose = program$e.opts().verbose;
2204
+ const verbose = program$f.opts().verbose;
2175
2205
  const { token, region } = options;
2176
2206
  const { state, updateSession, persistCredentials } = session();
2177
2207
  if (state.isLoggedIn && !state.envLogin) {
@@ -2229,10 +2259,10 @@ program$e.command(commands.LOGIN).description("Login to the Storyblok CLI").opti
2229
2259
  konsola.br();
2230
2260
  });
2231
2261
 
2232
- const program$d = getProgram();
2233
- program$d.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2262
+ const program$e = getProgram();
2263
+ program$e.command(commands.LOGOUT).description("Logout from the Storyblok CLI").action(async () => {
2234
2264
  konsola.title(`${commands.LOGOUT}`, colorPalette.LOGOUT);
2235
- const verbose = program$d.opts().verbose;
2265
+ const verbose = program$e.opts().verbose;
2236
2266
  try {
2237
2267
  const { state } = session();
2238
2268
  if (!state.isLoggedIn || !state.password || !state.region) {
@@ -2279,10 +2309,10 @@ async function openSignupInBrowser(url) {
2279
2309
  }
2280
2310
  }
2281
2311
 
2282
- const program$c = getProgram();
2283
- program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2312
+ const program$d = getProgram();
2313
+ program$d.command(commands.SIGNUP).description("Sign up for Storyblok").action(async () => {
2284
2314
  konsola.title(`${commands.SIGNUP}`, colorPalette.SIGNUP);
2285
- const verbose = program$c.opts().verbose;
2315
+ const verbose = program$d.opts().verbose;
2286
2316
  const { state } = session();
2287
2317
  if (state.isLoggedIn && !state.envLogin) {
2288
2318
  konsola.ok(`You are already logged in. If you want to signup with a different account, please logout first.`);
@@ -2303,10 +2333,10 @@ program$c.command(commands.SIGNUP).description("Sign up for Storyblok").action(a
2303
2333
  konsola.br();
2304
2334
  });
2305
2335
 
2306
- const program$b = getProgram();
2307
- program$b.command(commands.USER).description("Get the current user").action(async () => {
2336
+ const program$c = getProgram();
2337
+ program$c.command(commands.USER).description("Get the current user").action(async () => {
2308
2338
  konsola.title(`${commands.USER}`, colorPalette.USER);
2309
- const verbose = program$b.opts().verbose;
2339
+ const verbose = program$c.opts().verbose;
2310
2340
  const { state } = session();
2311
2341
  if (!requireAuthentication(state)) {
2312
2342
  return;
@@ -2334,10 +2364,10 @@ program$b.command(commands.USER).description("Get the current user").action(asyn
2334
2364
  konsola.br();
2335
2365
  });
2336
2366
 
2337
- const program$a = getProgram();
2338
- const componentsCommand = program$a.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2367
+ const program$b = getProgram();
2368
+ const componentsCommand = program$b.command(commands.COMPONENTS).alias("comp").description(`Manage your space's block schema`);
2339
2369
 
2340
- function isComponent(item) {
2370
+ function isComponent$1(item) {
2341
2371
  return "schema" in item;
2342
2372
  }
2343
2373
  function isPreset(item) {
@@ -2371,7 +2401,7 @@ async function loadComponents(directoryPath, options) {
2371
2401
  throw new Error('Internal tag is missing "id"!');
2372
2402
  }
2373
2403
  tagMap.set(item.id, item);
2374
- } else if (isComponent(item)) {
2404
+ } else if (isComponent$1(item)) {
2375
2405
  const existing = componentMap.get(item.name);
2376
2406
  if (existing) {
2377
2407
  duplicates.push(`Component "${item.name}" found in both "${existing.file}" and "${file}"`);
@@ -2407,44 +2437,20 @@ To fix this, either:
2407
2437
  }
2408
2438
 
2409
2439
  const DEFAULT_COMPONENTS_FILENAME = "components";
2410
- const DEFAULT_GROUPS_FILENAME = "groups";
2440
+ const DEFAULT_GROUPS_FILENAME$1 = "groups";
2411
2441
  const DEFAULT_PRESETS_FILENAME = "presets";
2412
2442
  const DEFAULT_TAGS_FILENAME = "tags";
2413
2443
 
2414
- async function fetchAllPages(fetchFunction, extractDataFunction) {
2415
- const items = [];
2416
- let page = 1;
2417
- while (true) {
2418
- const { data, response } = await fetchFunction(page);
2419
- const totalHeader = response.headers.get("total");
2420
- const fetchedItems = extractDataFunction(data);
2421
- items.push(...fetchedItems);
2422
- if (!totalHeader) {
2423
- return items;
2424
- }
2425
- const total = Number(totalHeader);
2426
- if (Number.isNaN(total) || items.length >= total || fetchedItems.length === 0) {
2427
- return items;
2428
- }
2429
- page++;
2430
- }
2431
- }
2432
-
2433
2444
  const fetchComponents = async (spaceId) => {
2434
2445
  try {
2435
2446
  const client = getMapiClient();
2436
- return await fetchAllPages(
2437
- (page) => client.components.list({
2438
- path: {
2439
- space_id: Number(spaceId)
2440
- },
2441
- query: {
2442
- page
2443
- },
2444
- throwOnError: true
2445
- }),
2446
- (data) => data?.components ?? []
2447
- );
2447
+ const { data } = await client.components.list({
2448
+ path: {
2449
+ space_id: Number(spaceId)
2450
+ },
2451
+ throwOnError: true
2452
+ });
2453
+ return data?.components ?? [];
2448
2454
  } catch (error) {
2449
2455
  handleAPIError("pull_components", error);
2450
2456
  }
@@ -2452,19 +2458,16 @@ const fetchComponents = async (spaceId) => {
2452
2458
  const fetchComponent = async (spaceId, componentName) => {
2453
2459
  try {
2454
2460
  const client = getMapiClient();
2455
- const matches = await fetchAllPages(
2456
- (page) => client.components.list({
2457
- path: {
2458
- space_id: Number(spaceId)
2459
- },
2460
- query: {
2461
- page,
2462
- search: componentName
2463
- },
2464
- throwOnError: true
2465
- }),
2466
- (data) => data?.components ?? []
2467
- );
2461
+ const { data } = await client.components.list({
2462
+ path: {
2463
+ space_id: Number(spaceId)
2464
+ },
2465
+ query: {
2466
+ search: componentName
2467
+ },
2468
+ throwOnError: true
2469
+ });
2470
+ const matches = data?.components ?? [];
2468
2471
  return matches.find((c) => c.name === componentName);
2469
2472
  } catch (error) {
2470
2473
  handleAPIError("pull_components", error, `Failed to fetch component ${componentName}`);
@@ -2531,7 +2534,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2531
2534
  const presetsFilePath = join(resolvedPath, suffix ? `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.${suffix}.json` : `${sanitizedName}.${DEFAULT_PRESETS_FILENAME}.json`);
2532
2535
  await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2));
2533
2536
  }
2534
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2537
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2535
2538
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2536
2539
  const internalTagsFilePath = join(resolvedPath, suffix ? `${DEFAULT_TAGS_FILENAME}.${suffix}.json` : `${DEFAULT_TAGS_FILENAME}.json`);
2537
2540
  await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2));
@@ -2541,7 +2544,7 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2541
2544
  const componentsFilePath = join(resolvedPath, suffix ? `${filename}.${suffix}.json` : `${filename}.json`);
2542
2545
  await saveToFile(componentsFilePath, JSON.stringify(components, null, 2));
2543
2546
  if (groups.length > 0) {
2544
- const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME}.json`);
2547
+ const groupsFilePath = join(resolvedPath, suffix ? `${DEFAULT_GROUPS_FILENAME$1}.${suffix}.json` : `${DEFAULT_GROUPS_FILENAME$1}.json`);
2545
2548
  await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2));
2546
2549
  }
2547
2550
  if (presets.length > 0) {
@@ -2557,6 +2560,27 @@ const saveComponentsToFiles = async (space, spaceData, options) => {
2557
2560
  }
2558
2561
  };
2559
2562
 
2563
+ function isSchemaField$1(value) {
2564
+ return typeof value === "object" && value !== null && "type" in value;
2565
+ }
2566
+ function toWritableSchema(schema) {
2567
+ if (!schema) {
2568
+ return void 0;
2569
+ }
2570
+ const result = {};
2571
+ for (const [key, value] of Object.entries(schema)) {
2572
+ if (isSchemaField$1(value)) {
2573
+ result[key] = value;
2574
+ }
2575
+ }
2576
+ return result;
2577
+ }
2578
+ function toRequestTagIds(tagIds) {
2579
+ if (!tagIds) {
2580
+ return void 0;
2581
+ }
2582
+ return tagIds.map((id) => Number(id));
2583
+ }
2560
2584
  const pushComponent = async (space, component) => {
2561
2585
  try {
2562
2586
  const client = getMapiClient();
@@ -2591,10 +2615,23 @@ const updateComponent = async (space, componentId, component) => {
2591
2615
  }
2592
2616
  };
2593
2617
  const upsertComponent = async (space, component, existingId) => {
2618
+ const { name, display_name, schema, is_root, is_nestable, component_group_uuid, color, icon, preview_field, internal_tag_ids } = component;
2619
+ const payload = {
2620
+ name,
2621
+ display_name: display_name ?? void 0,
2622
+ schema: toWritableSchema(schema),
2623
+ is_root,
2624
+ is_nestable,
2625
+ component_group_uuid: component_group_uuid ?? void 0,
2626
+ color: color ?? void 0,
2627
+ icon: icon ?? void 0,
2628
+ preview_field: preview_field ?? void 0,
2629
+ internal_tag_ids: toRequestTagIds(internal_tag_ids)
2630
+ };
2594
2631
  if (existingId) {
2595
- return await updateComponent(space, existingId, component);
2632
+ return await updateComponent(space, existingId, payload);
2596
2633
  } else {
2597
- return await pushComponent(space, component);
2634
+ return await pushComponent(space, payload);
2598
2635
  }
2599
2636
  };
2600
2637
  const pushComponentGroup = async (space, componentGroup) => {
@@ -2646,7 +2683,14 @@ const pushComponentPreset = async (space, preset) => {
2646
2683
  space_id: Number(space)
2647
2684
  },
2648
2685
  body: {
2649
- preset
2686
+ preset: {
2687
+ ...preset,
2688
+ preset: preset.preset ?? void 0,
2689
+ image: preset.image ?? void 0,
2690
+ color: preset.color ?? void 0,
2691
+ icon: preset.icon ?? void 0,
2692
+ description: preset.description ?? void 0
2693
+ }
2650
2694
  },
2651
2695
  throwOnError: true
2652
2696
  });
@@ -2663,7 +2707,14 @@ const updateComponentPreset = async (space, presetId, preset) => {
2663
2707
  space_id: Number(space)
2664
2708
  },
2665
2709
  body: {
2666
- preset
2710
+ preset: {
2711
+ ...preset,
2712
+ preset: preset.preset ?? void 0,
2713
+ image: preset.image ?? void 0,
2714
+ color: preset.color ?? void 0,
2715
+ icon: preset.icon ?? void 0,
2716
+ description: preset.description ?? void 0
2717
+ }
2667
2718
  },
2668
2719
  throwOnError: true
2669
2720
  });
@@ -2697,7 +2748,7 @@ const pushComponentInternalTag = async (space, componentInternalTag) => {
2697
2748
  path: {
2698
2749
  space_id: Number(space)
2699
2750
  },
2700
- body: componentInternalTag,
2751
+ body: { internal_tag: componentInternalTag },
2701
2752
  throwOnError: true
2702
2753
  });
2703
2754
  return data.internal_tag;
@@ -2712,7 +2763,7 @@ const updateComponentInternalTag = async (space, tagId, componentInternalTag) =>
2712
2763
  path: {
2713
2764
  space_id: Number(space)
2714
2765
  },
2715
- body: componentInternalTag,
2766
+ body: { internal_tag: componentInternalTag },
2716
2767
  throwOnError: true
2717
2768
  });
2718
2769
  return data.internal_tag;
@@ -3389,7 +3440,7 @@ function filterSpaceData(spaceData, selectors) {
3389
3440
  if (filter && !minimatch(component.name, filter)) {
3390
3441
  return false;
3391
3442
  }
3392
- if (groupUuids && !(component.component_group_uuid !== void 0 && groupUuids.has(component.component_group_uuid))) {
3443
+ if (groupUuids && !(component.component_group_uuid != null && groupUuids.has(component.component_group_uuid))) {
3393
3444
  return false;
3394
3445
  }
3395
3446
  if (tagIds) {
@@ -3453,7 +3504,7 @@ function resolveGroupSelector(groups, selector) {
3453
3504
  while (p && !visited.has(p)) {
3454
3505
  visited.add(p);
3455
3506
  parts.unshift(nameOf(p) ?? p);
3456
- p = byUuid.get(p)?.parent_uuid;
3507
+ p = byUuid.get(p)?.parent_uuid ?? null;
3457
3508
  }
3458
3509
  return parts.join("/");
3459
3510
  });
@@ -4057,16 +4108,14 @@ const fetchSpace = async (spaceId) => {
4057
4108
  handleAPIError("pull_spaces", error, `Failed to fetch space ${spaceId}`);
4058
4109
  }
4059
4110
  };
4060
- const createSpace = async (space) => {
4111
+ const createSpace = async (space, query) => {
4061
4112
  try {
4062
- const { in_org, assign_partner, ...spaceData } = space;
4063
4113
  const client = getMapiClient();
4064
4114
  const { data } = await client.spaces.create({
4065
4115
  body: {
4066
- space: spaceData,
4067
- ...in_org && { in_org },
4068
- ...assign_partner && { assign_partner }
4069
- }
4116
+ space
4117
+ },
4118
+ query
4070
4119
  });
4071
4120
  return data?.space;
4072
4121
  } catch (error) {
@@ -4077,10 +4126,10 @@ const createSpace = async (space) => {
4077
4126
  const fetchLanguages = async (spaceId) => {
4078
4127
  try {
4079
4128
  const space = await fetchSpace(spaceId);
4080
- if (space?.default_lang_name !== void 0 && space?.languages?.length) {
4129
+ if (space?.default_lang_name && space?.languages?.length) {
4081
4130
  return {
4082
- default_lang_name: space?.default_lang_name,
4083
- languages: space?.languages
4131
+ default_lang_name: space.default_lang_name,
4132
+ languages: space.languages
4084
4133
  };
4085
4134
  }
4086
4135
  } catch (error) {
@@ -4100,8 +4149,8 @@ const saveLanguagesToFile = async (space, internationalizationOptions, options)
4100
4149
  }
4101
4150
  };
4102
4151
 
4103
- const program$9 = getProgram();
4104
- const languagesCommand = program$9.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
4152
+ const program$a = getProgram();
4153
+ const languagesCommand = program$a.command(commands.LANGUAGES).alias("lang").description(`Manage your space's languages`);
4105
4154
  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");
4106
4155
  pullCmd$3.action(async (options, command) => {
4107
4156
  konsola.title(`${commands.LANGUAGES}`, colorPalette.LANGUAGES);
@@ -4147,8 +4196,8 @@ pullCmd$3.action(async (options, command) => {
4147
4196
  konsola.br();
4148
4197
  });
4149
4198
 
4150
- const program$8 = getProgram();
4151
- const migrationsCommand = program$8.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
4199
+ const program$9 = getProgram();
4200
+ const migrationsCommand = program$9.command(commands.MIGRATIONS).alias("mig").description(`Manage your space's migrations`);
4152
4201
 
4153
4202
  const getMigrationTemplate = () => {
4154
4203
  return `export default function (block) {
@@ -4252,7 +4301,7 @@ const fetchStories = async (spaceId, params) => {
4252
4301
  const fetchStory = async (spaceId, storyId) => {
4253
4302
  try {
4254
4303
  const client = getMapiClient();
4255
- const { data } = await client.stories.get(storyId, {
4304
+ const { data } = await client.stories.get(Number(storyId), {
4256
4305
  path: {
4257
4306
  space_id: Number(spaceId)
4258
4307
  },
@@ -4297,9 +4346,11 @@ const updateStory = async (spaceId, storyId, payload) => {
4297
4346
  ...payload.story,
4298
4347
  // StoryUpdate2 expects `parent_id?: number`; normalize null → undefined.
4299
4348
  parent_id: payload.story.parent_id ?? void 0
4300
- },
4301
- force_update: payload.force_update === "1" ? "1" : "0",
4302
- ...payload.publish ? { publish: payload.publish } : {}
4349
+ }
4350
+ },
4351
+ query: {
4352
+ force_update: payload.force_update === "1",
4353
+ ...payload.publish ? { publish: Boolean(payload.publish) } : {}
4303
4354
  },
4304
4355
  throwOnError: true
4305
4356
  });
@@ -4396,6 +4447,35 @@ const prefetchTargetStoriesByKeys = async (spaceId, keys, options) => {
4396
4447
  return result;
4397
4448
  };
4398
4449
 
4450
+ function parseFilterQuery(input) {
4451
+ const trimmed = input.trim();
4452
+ if (!trimmed) {
4453
+ return {};
4454
+ }
4455
+ if (trimmed.startsWith("{")) {
4456
+ return JSON.parse(trimmed);
4457
+ }
4458
+ const result = {};
4459
+ for (const clause of trimmed.split("&")) {
4460
+ if (!clause) {
4461
+ continue;
4462
+ }
4463
+ const eq = clause.indexOf("=");
4464
+ if (eq === -1) {
4465
+ continue;
4466
+ }
4467
+ const path = clause.slice(0, eq);
4468
+ const value = clause.slice(eq + 1);
4469
+ const keys = [...path.matchAll(/\[([^\]]+)\]/g)].map((match) => match[1]);
4470
+ if (keys.length < 2) {
4471
+ continue;
4472
+ }
4473
+ const [field, operation] = keys;
4474
+ result[field] = { ...result[field], [operation]: value };
4475
+ }
4476
+ return result;
4477
+ }
4478
+
4399
4479
  const PIPELINE_BACKPRESSURE_MULTIPLIER = 2;
4400
4480
  const DEFAULT_PIPELINE_BACKPRESSURE = 12;
4401
4481
  function createPipelineBackpressureLock(limit) {
@@ -4419,16 +4499,13 @@ const ERROR_CODES = {
4419
4499
  async function* storiesIterator(spaceId, params, onTotal) {
4420
4500
  try {
4421
4501
  let perPage = 500;
4422
- const transformedParams = {
4423
- ...params
4424
- };
4425
- if (params?.componentName && typeof params.componentName === "string") {
4426
- transformedParams.contain_component = params.componentName;
4427
- delete transformedParams.componentName;
4502
+ const { componentName, query, ...rest } = params ?? {};
4503
+ const transformedParams = { ...rest };
4504
+ if (componentName) {
4505
+ transformedParams.contain_component = componentName;
4428
4506
  }
4429
- if (params?.query && typeof params.query === "string") {
4430
- transformedParams.filter_query = params.query.startsWith("filter_query") ? params.query : `filter_query${params.query}`;
4431
- delete transformedParams.query;
4507
+ if (query) {
4508
+ transformedParams.filter_query = parseFilterQuery(query);
4432
4509
  }
4433
4510
  const result = await fetchStories(spaceId, {
4434
4511
  ...transformedParams,
@@ -4753,8 +4830,8 @@ class MigrationStream extends Transform {
4753
4830
  id: story.id,
4754
4831
  name: story.name || "",
4755
4832
  content: story.content,
4756
- published: story.published,
4757
- unpublished_changes: story.unpublished_changes
4833
+ published: story.published ?? void 0,
4834
+ unpublished_changes: story.unpublished_changes ?? void 0
4758
4835
  },
4759
4836
  migrationTimestamp: this.timestamp,
4760
4837
  migrationNames
@@ -4770,8 +4847,8 @@ class MigrationStream extends Transform {
4770
4847
  storyId: story.id,
4771
4848
  name: story.name,
4772
4849
  content: storyContent,
4773
- published: story.published,
4774
- unpublished_changes: story.unpublished_changes
4850
+ published: story.published ?? void 0,
4851
+ unpublished_changes: story.unpublished_changes ?? void 0
4775
4852
  };
4776
4853
  } else if (processed && !contentChanged) {
4777
4854
  this.results.skipped.push({
@@ -4913,7 +4990,6 @@ class UpdateStream extends Writable {
4913
4990
  const payload = {
4914
4991
  story: {
4915
4992
  content,
4916
- id: storyId,
4917
4993
  name: storyName
4918
4994
  },
4919
4995
  force_update: "1"
@@ -5162,7 +5238,6 @@ rollbackCmd.action(async (migrationFile, _options, command) => {
5162
5238
  const payload = {
5163
5239
  story: {
5164
5240
  content: story.content,
5165
- id: story.storyId,
5166
5241
  name: story.name
5167
5242
  },
5168
5243
  force_update: "1"
@@ -5202,8 +5277,8 @@ rollbackCmd.action(async (migrationFile, _options, command) => {
5202
5277
  }
5203
5278
  });
5204
5279
 
5205
- const program$7 = getProgram();
5206
- const typesCommand = program$7.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5280
+ const program$8 = getProgram();
5281
+ const typesCommand = program$8.command(commands.TYPES).alias("ts").description(`Generate types d.ts for your component schemas`);
5207
5282
 
5208
5283
  const getAssetJSONSchema = (title) => ({
5209
5284
  $id: "#/asset",
@@ -6205,7 +6280,7 @@ const deleteDatasourceEntry = async (spaceId, entryId) => {
6205
6280
  handleAPIError("delete_datasource_entry", error, `Failed to delete datasource entry ${entryId}`);
6206
6281
  }
6207
6282
  };
6208
- function isDatasource(item) {
6283
+ function isDatasource$1(item) {
6209
6284
  return typeof item === "object" && item !== null && "slug" in item && typeof item.slug === "string";
6210
6285
  }
6211
6286
  const readDatasourcesFiles = async (options) => {
@@ -6238,7 +6313,7 @@ const readDatasourcesFiles = async (options) => {
6238
6313
  continue;
6239
6314
  }
6240
6315
  for (const item of data) {
6241
- if (isDatasource(item)) {
6316
+ if (isDatasource$1(item)) {
6242
6317
  const existing = datasourceMap.get(item.slug);
6243
6318
  if (existing) {
6244
6319
  duplicates.push(`Datasource "${item.slug}" found in both "${existing.file}" and "${file}"`);
@@ -6338,8 +6413,8 @@ generateCmd.action(async (options, command) => {
6338
6413
  }
6339
6414
  });
6340
6415
 
6341
- const program$6 = getProgram();
6342
- const datasourcesCommand = program$6.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6416
+ const program$7 = getProgram();
6417
+ const datasourcesCommand = program$7.command(commands.DATASOURCES).alias("ds").description(`Manage your space's datasources`);
6343
6418
 
6344
6419
  const DEFAULT_DATASOURCES_FILENAME = "datasources";
6345
6420
 
@@ -7005,13 +7080,13 @@ async function promptForLogin(verbose) {
7005
7080
  return null;
7006
7081
  }
7007
7082
  }
7008
- const program$5 = getProgram();
7009
- 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(
7083
+ const program$6 = getProgram();
7084
+ 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(
7010
7085
  "-r, --region <region>",
7011
7086
  `The region to apply to the generated project template (does not affect space creation).`
7012
7087
  ).action(async (projectPath, options) => {
7013
7088
  ui.title(`${commands.CREATE}`, colorPalette.CREATE);
7014
- const verbose = program$5.opts().verbose;
7089
+ const verbose = program$6.opts().verbose;
7015
7090
  const { template, blueprint, token } = options;
7016
7091
  if (options.region && !isRegion(options.region)) {
7017
7092
  handleError(new CommandError(`The provided region: ${options.region} is not valid. Please use one of the following values: ${Object.values(regions).join(" | ")}`));
@@ -7184,13 +7259,13 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
7184
7259
  name: toHumanReadable(projectName),
7185
7260
  domain: blueprintDomain
7186
7261
  };
7262
+ const createSpaceQuery = {};
7187
7263
  if (whereToCreateSpace === "org") {
7188
- spaceToCreate.org = userData.org;
7189
- spaceToCreate.in_org = true;
7264
+ createSpaceQuery.in_org = true;
7190
7265
  } else if (whereToCreateSpace === "partner") {
7191
- spaceToCreate.assign_partner = true;
7266
+ createSpaceQuery.assign_partner = true;
7192
7267
  }
7193
- createdSpace = await createSpace(spaceToCreate);
7268
+ createdSpace = await createSpace(spaceToCreate, createSpaceQuery);
7194
7269
  spinnerSpace.succeed(`Space "${chalk.hex(colorPalette.PRIMARY)(toHumanReadable(projectName))}" created successfully`);
7195
7270
  if (createdSpace?.first_token) {
7196
7271
  await handleEnvFileCreation(resolvedPath, createdSpace.first_token, region, technologyTemplate);
@@ -7230,8 +7305,8 @@ program$5.command(`${commands.CREATE} [project-path]`).alias("c").description(`S
7230
7305
  ui.br();
7231
7306
  });
7232
7307
 
7233
- const program$4 = getProgram();
7234
- const logsCommand = program$4.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
7308
+ const program$5 = getProgram();
7309
+ const logsCommand = program$5.command(commands.LOGS).alias("lg").description(`Inspect and manage logs.`);
7235
7310
 
7236
7311
  const listCmd$1 = logsCommand.command("list").description("List logs").option("-s, --space <space>", "space ID");
7237
7312
  listCmd$1.action(async (_options, command) => {
@@ -7256,8 +7331,8 @@ pruneCmd$1.action(async (options, command) => {
7256
7331
  ui.info(`Deleted ${deletedFilesCount} log file${deletedFilesCount === 1 ? "" : "s"}`);
7257
7332
  });
7258
7333
 
7259
- const program$3 = getProgram();
7260
- const reportsCommand = program$3.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
7334
+ const program$4 = getProgram();
7335
+ const reportsCommand = program$4.command(commands.REPORTS).alias("rp").description("Inspect and manage reports.");
7261
7336
 
7262
7337
  const listCmd = reportsCommand.command("list").description("List reports").option("-s, --space <space>", "space ID");
7263
7338
  listCmd.action(async (_options, command) => {
@@ -7282,8 +7357,8 @@ pruneCmd.action(async (options, command) => {
7282
7357
  ui.info(`Deleted ${deletedFilesCount} report file${deletedFilesCount === 1 ? "" : "s"}`);
7283
7358
  });
7284
7359
 
7285
- const program$2 = getProgram();
7286
- const assetsCommand = program$2.command(commands.ASSETS).description(`Manage your space's assets`);
7360
+ const program$3 = getProgram();
7361
+ const assetsCommand = program$3.command(commands.ASSETS).description(`Manage your space's assets`);
7287
7362
 
7288
7363
  const fetchAssets = async ({ spaceId, params }) => {
7289
7364
  try {
@@ -7331,7 +7406,7 @@ const createAssetInternalTag = async (spaceId, name) => {
7331
7406
  const client = getMapiClient();
7332
7407
  const { data } = await client.internalTags.create({
7333
7408
  path: { space_id: Number(spaceId) },
7334
- body: { name, object_type: "asset" },
7409
+ body: { internal_tag: { name, object_type: "asset" } },
7335
7410
  throwOnError: true
7336
7411
  });
7337
7412
  const tag = data?.internal_tag;
@@ -7455,7 +7530,7 @@ const updateAsset = async (id, asset, { spaceId, fileBuffer }) => {
7455
7530
  const createAsset = async (asset, fileBuffer, { spaceId }) => {
7456
7531
  try {
7457
7532
  const client = getMapiClient();
7458
- const { id: _id, ...assetBody } = asset;
7533
+ const { id: _id, filename: _filename, ...assetBody } = asset;
7459
7534
  return await client.assets.create({
7460
7535
  body: assetBody,
7461
7536
  file: fileBuffer,
@@ -7675,6 +7750,26 @@ const parseAssetData = (raw) => {
7675
7750
  throw new Error(`Invalid --data JSON: ${toError(maybeError).message}`);
7676
7751
  }
7677
7752
  };
7753
+ const toAssetUpload = (partial, shortFilename) => {
7754
+ const nullToUndef = (value) => value ?? void 0;
7755
+ return {
7756
+ id: partial.id,
7757
+ filename: partial.filename,
7758
+ short_filename: shortFilename,
7759
+ asset_folder_id: nullToUndef(partial.asset_folder_id),
7760
+ ext_id: nullToUndef(partial.ext_id),
7761
+ alt: nullToUndef(partial.alt),
7762
+ copyright: nullToUndef(partial.copyright),
7763
+ title: nullToUndef(partial.title),
7764
+ source: nullToUndef(partial.source),
7765
+ expire_at: nullToUndef(partial.expire_at),
7766
+ publish_at: nullToUndef(partial.publish_at),
7767
+ focus: nullToUndef(partial.focus),
7768
+ is_private: nullToUndef(partial.is_private),
7769
+ internal_tag_ids: partial.internal_tag_ids,
7770
+ meta_data: nullToUndef(partial.meta_data)
7771
+ };
7772
+ };
7678
7773
  const getSidecarFilename = (assetBinaryPath) => {
7679
7774
  return join(dirname(assetBinaryPath), `${basename(assetBinaryPath, extname(assetBinaryPath))}.json`);
7680
7775
  };
@@ -7703,7 +7798,7 @@ const isRemoteSource = (assetBinaryPath) => {
7703
7798
  return false;
7704
7799
  }
7705
7800
  };
7706
- const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.old_filename && entry.new_filename);
7801
+ const isValidManifestEntry = (entry) => Boolean(typeof entry.old_id === "number" && typeof entry.new_id === "number" && entry.new_filename);
7707
7802
  const loadAssetMap = async (manifestFile) => {
7708
7803
  const manifest = await loadManifest(manifestFile);
7709
7804
  const entries = manifest.filter(isValidManifestEntry).map((e) => [
@@ -8132,8 +8227,10 @@ const readLocalAssetsStream = ({
8132
8227
  const sidecar = await loadSidecarAssetData(binaryFilePath);
8133
8228
  const shortFilename = sidecar.short_filename || (sidecar.filename ? basename(sidecar.filename) : void 0) || file;
8134
8229
  const asset = {
8135
- ...sidecar,
8136
- short_filename: shortFilename
8230
+ ...toAssetUpload(sidecar, shortFilename),
8231
+ // Carry the read-only tag detail for source→target tag-name
8232
+ // translation in `processAsset`; it is stripped before the API call.
8233
+ ...sidecar.internal_tags_list ? { internal_tags_list: sidecar.internal_tags_list } : {}
8137
8234
  };
8138
8235
  const fileBuffer = await readFile$1(binaryFilePath);
8139
8236
  const sidecarPath = getSidecarFilename(binaryFilePath);
@@ -8217,7 +8314,7 @@ const mapInternalTagIds = (sourceIds, sourceTags, assetInternalTagsByName, onUnm
8217
8314
  const sourceName = sourceNamesById.get(sourceId);
8218
8315
  const targetId = typeof sourceName === "string" ? assetInternalTagsByName.get(sourceName) : void 0;
8219
8316
  if (typeof targetId === "number") {
8220
- mapped.push(String(targetId));
8317
+ mapped.push(targetId);
8221
8318
  } else {
8222
8319
  onUnmappedTag?.({ sourceId, name: sourceName });
8223
8320
  }
@@ -8271,12 +8368,13 @@ const processAsset = async ({
8271
8368
  if (maps.resolveSharedTagIds) {
8272
8369
  return maps.resolveSharedTagIds(sourceIds, sourceTags);
8273
8370
  }
8274
- return maps.assetInternalTagsByName ? mapInternalTagIds(sourceIds, sourceTags, maps.assetInternalTagsByName, onUnmappedTag) : (sourceIds ?? []).map((id) => String(id));
8371
+ return maps.assetInternalTagsByName ? mapInternalTagIds(sourceIds, sourceTags, maps.assetInternalTagsByName, onUnmappedTag) : (sourceIds ?? []).map((id) => Number(id));
8275
8372
  };
8276
8373
  let newRemoteAsset;
8277
8374
  let status;
8278
8375
  if (remoteAsset) {
8279
- const updateTagIds = "internal_tag_ids" in localAsset ? await resolveInternalTagIds(localAsset.internal_tag_ids) : remoteAsset.internal_tag_ids;
8376
+ const updateTagIds = localAsset.internal_tag_ids ? await resolveInternalTagIds(localAsset.internal_tag_ids) : remoteAsset.internal_tag_ids;
8377
+ const nullToUndef = (v) => v ?? void 0;
8280
8378
  const updatePayload = {
8281
8379
  asset_folder_id: remoteFolderId,
8282
8380
  alt: "alt" in localAsset ? localAsset.alt : remoteAsset.alt,
@@ -8285,25 +8383,29 @@ const processAsset = async ({
8285
8383
  source: "source" in localAsset ? localAsset.source : remoteAsset.source,
8286
8384
  is_private: "is_private" in localAsset ? localAsset.is_private : remoteAsset.is_private,
8287
8385
  focus: "focus" in localAsset ? localAsset.focus : remoteAsset.focus,
8288
- expire_at: "expire_at" in localAsset ? localAsset.expire_at : remoteAsset.expire_at,
8289
- publish_at: "publish_at" in localAsset ? localAsset.publish_at : remoteAsset.publish_at,
8386
+ expire_at: nullToUndef("expire_at" in localAsset ? localAsset.expire_at : remoteAsset.expire_at),
8387
+ publish_at: nullToUndef("publish_at" in localAsset ? localAsset.publish_at : remoteAsset.publish_at),
8290
8388
  internal_tag_ids: updateTagIds,
8291
- meta_data: "meta_data" in localAsset ? localAsset.meta_data : remoteAsset.meta_data
8389
+ meta_data: nullToUndef("meta_data" in localAsset ? localAsset.meta_data : remoteAsset.meta_data)
8292
8390
  };
8293
8391
  await transports.updateAsset(
8294
8392
  remoteAsset.id,
8295
8393
  { ...updatePayload, short_filename: remoteAsset.short_filename },
8296
8394
  fileBuffer
8297
8395
  );
8298
- newRemoteAsset = { ...remoteAsset, ...updatePayload };
8396
+ newRemoteAsset = {
8397
+ ...remoteAsset,
8398
+ ...updatePayload,
8399
+ is_private: updatePayload.is_private ?? remoteAsset.is_private
8400
+ };
8299
8401
  status = "updated";
8300
8402
  } else if (hasProp(localAsset, "short_filename")) {
8301
- const { internal_tags_list: _internalTagsList, internal_tag_ids: _sourceTagIds, ...rest } = localAsset;
8302
- const mappedTagIds = "internal_tag_ids" in localAsset ? await resolveInternalTagIds(localAsset.internal_tag_ids) : void 0;
8303
- const size = hasProp(rest, "size") ? rest.size : hasProp(localAsset, "filename") ? extractAssetSizeFromFilename(localAsset.filename) : void 0;
8403
+ const mappedTagIds = localAsset.internal_tag_ids ? await resolveInternalTagIds(localAsset.internal_tag_ids) : void 0;
8404
+ const size = hasProp(localAsset, "size") ? localAsset.size : hasProp(localAsset, "filename") ? extractAssetSizeFromFilename(localAsset.filename) : void 0;
8405
+ const { internal_tag_ids: _sourceTagIds, ...uploadBase } = toAssetUpload(localAsset, localAsset.short_filename);
8304
8406
  const createPayload = {
8305
- ...rest,
8306
- asset_folder_id: remoteFolderId,
8407
+ ...uploadBase,
8408
+ asset_folder_id: remoteFolderId ?? void 0,
8307
8409
  ...mappedTagIds !== void 0 ? { internal_tag_ids: mappedTagIds } : {},
8308
8410
  ...size !== void 0 ? { size } : {}
8309
8411
  };
@@ -8419,7 +8521,7 @@ const makeSharedTagResolver = ({ spaceId, libraryId }) => {
8419
8521
  }
8420
8522
  }
8421
8523
  if (sharedId !== void 0) {
8422
- remapped.push(String(sharedId));
8524
+ remapped.push(sharedId);
8423
8525
  }
8424
8526
  }
8425
8527
  return remapped;
@@ -8829,8 +8931,8 @@ const traverseAndMapBySchema = (data, {
8829
8931
  const dataNew = { ...data };
8830
8932
  for (const [fieldName, fieldValue] of Object.entries(data)) {
8831
8933
  const fieldSchema = schema[fieldName.replace(/__i18n__.*/, "")];
8832
- const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema && fieldSchema.type;
8833
- const fieldRefMapper = typeof fieldType === "string" && fieldRefMappers2[fieldType];
8934
+ const fieldType = fieldSchema && typeof fieldSchema === "object" && "type" in fieldSchema ? fieldSchema.type : void 0;
8935
+ const fieldRefMapper = typeof fieldType === "string" ? fieldRefMappers2[fieldType] : void 0;
8834
8936
  if (fieldRefMapper) {
8835
8937
  dataNew[fieldName] = fieldRefMapper(fieldValue, {
8836
8938
  schema: fieldSchema,
@@ -8895,6 +8997,9 @@ const richtextFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRef
8895
8997
  fieldRefMappers: fieldRefMappers2
8896
8998
  });
8897
8999
  const multilinkFieldRefMapper = (data, { maps }) => {
9000
+ if (!data || typeof data !== "object") {
9001
+ return data;
9002
+ }
8898
9003
  if (data.linktype !== "story") {
8899
9004
  return data;
8900
9005
  }
@@ -8914,6 +9019,9 @@ const bloksFieldRefMapper = (data, { schemas, maps, fieldRefMappers: fieldRefMap
8914
9019
  }));
8915
9020
  };
8916
9021
  const assetFieldRefMapper = (data, { maps }) => {
9022
+ if (!data || typeof data !== "object") {
9023
+ return data;
9024
+ }
8917
9025
  const mappedAsset = typeof data.id === "number" ? maps.assets?.get(data.id) : void 0;
8918
9026
  if (!mappedAsset) {
8919
9027
  return data;
@@ -8931,7 +9039,7 @@ const multiassetFieldRefMapper = (data, options) => {
8931
9039
  return data.map((d) => assetFieldRefMapper(d, options));
8932
9040
  };
8933
9041
  const optionsFieldRefMapper = (data, { schema, maps }) => {
8934
- if (schema.source !== "internal_stories" || !Array.isArray(data)) {
9042
+ if (!schema || !("source" in schema) || schema.source !== "internal_stories" || !Array.isArray(data)) {
8935
9043
  return data;
8936
9044
  }
8937
9045
  return data.map((d) => maps.stories?.get(d) || d);
@@ -9824,11 +9932,7 @@ pushCmd$1.action(async (assetInput, options, command) => {
9824
9932
  const sourceBasename = isRemoteSource(assetBinaryPath) ? basename(new URL(assetBinaryPath).pathname) : basename(assetBinaryPath);
9825
9933
  const shortFilename = options.shortFilename || assetDataPartial.short_filename || sourceBasename;
9826
9934
  const folderId = options.folder ? Number(options.folder) : void 0;
9827
- assetData = {
9828
- ...assetDataPartial,
9829
- short_filename: shortFilename,
9830
- asset_folder_id: folderId
9831
- };
9935
+ assetData = { ...toAssetUpload(assetDataPartial, shortFilename), asset_folder_id: folderId };
9832
9936
  }
9833
9937
  try {
9834
9938
  for (const scope of scopes) {
@@ -10036,8 +10140,8 @@ transferCmd.action(async (assetIds, options, command) => {
10036
10140
  process.exitCode = summary.failed > 0 ? 1 : 0;
10037
10141
  });
10038
10142
 
10039
- const program$1 = getProgram();
10040
- const storiesCommand = program$1.command(commands.STORIES).description(`Manage your space's stories`);
10143
+ const program$2 = getProgram();
10144
+ const storiesCommand = program$2.command(commands.STORIES).description(`Manage your space's stories`);
10041
10145
 
10042
10146
  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.`);
10043
10147
  pullCmd.action(async (options, command) => {
@@ -10073,7 +10177,7 @@ pullCmd.action(async (options, command) => {
10073
10177
  fetchStoriesStream({
10074
10178
  spaceId: space,
10075
10179
  params: {
10076
- filter_query: options.query,
10180
+ filter_query: options.query ? parseFilterQuery(options.query) : void 0,
10077
10181
  starts_with: options.startsWith
10078
10182
  },
10079
10183
  setTotalPages: (totalPages) => {
@@ -10542,6 +10646,1814 @@ pushCmd.action(async (options, command) => {
10542
10646
  }
10543
10647
  });
10544
10648
 
10649
+ const program$1 = getProgram();
10650
+ const schemaCommand = program$1.command(commands.SCHEMA).description(`Manage your space's schema from code`);
10651
+
10652
+ function isRecord(value) {
10653
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10654
+ }
10655
+ const COMPONENT_STRIP_KEYS = /* @__PURE__ */ new Set([
10656
+ "id",
10657
+ "created_at",
10658
+ "updated_at",
10659
+ "real_name",
10660
+ // API-computed display/technical name, read-only
10661
+ "preset_id",
10662
+ // Instance-level preset selection, not part of schema definition
10663
+ "all_presets",
10664
+ // Computed list of presets, managed via /presets API
10665
+ "internal_tags_list",
10666
+ // Read-only expanded form of internal_tag_ids ({id, name} objects)
10667
+ "content_type_asset_preview",
10668
+ // Read-only, not in ComponentCreate/ComponentUpdate
10669
+ "image",
10670
+ // Read-only preview image URL
10671
+ "preview_tmpl",
10672
+ // Read-only preview template
10673
+ "metadata",
10674
+ // Not in current API types, stripped defensively
10675
+ "component_group_uuid"
10676
+ // UI grouping; stripped by default — kept for diffing only when a block opts into the group escape hatch
10677
+ ]);
10678
+ const DATASOURCE_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
10679
+ const DATASOURCE_DIMENSION_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "datasource_id", "created_at", "updated_at"]);
10680
+ const DATASOURCE_DEFAULTS = {
10681
+ dimensions: []
10682
+ };
10683
+ const COMPONENT_DEFAULTS = {
10684
+ display_name: "",
10685
+ description: "",
10686
+ color: "",
10687
+ icon: "",
10688
+ preview_field: "",
10689
+ internal_tag_ids: []
10690
+ };
10691
+ function applyDefaults(entity, defaults) {
10692
+ const result = { ...entity };
10693
+ for (const [key, defaultValue] of Object.entries(defaults)) {
10694
+ if (result[key] === void 0 || result[key] === null) {
10695
+ Object.assign(result, { [key]: defaultValue });
10696
+ }
10697
+ }
10698
+ return result;
10699
+ }
10700
+ const INDENT = " ";
10701
+ function quoteString(value) {
10702
+ const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
10703
+ return `'${escaped}'`;
10704
+ }
10705
+ function formatValue(value, depth) {
10706
+ const indent = INDENT.repeat(depth);
10707
+ const innerIndent = INDENT.repeat(depth + 1);
10708
+ if (value === null || value === void 0) {
10709
+ return String(value);
10710
+ }
10711
+ if (typeof value === "string") {
10712
+ return quoteString(value);
10713
+ }
10714
+ if (typeof value === "number" || typeof value === "boolean") {
10715
+ return String(value);
10716
+ }
10717
+ if (Array.isArray(value)) {
10718
+ if (value.length === 0) {
10719
+ return "[]";
10720
+ }
10721
+ const items = value.map((item) => `${innerIndent}${formatValue(item, depth + 1)},`);
10722
+ return `[
10723
+ ${items.join("\n")}
10724
+ ${indent}]`;
10725
+ }
10726
+ if (typeof value === "object") {
10727
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0 && v !== null).sort(([a], [b]) => a.localeCompare(b));
10728
+ if (entries.length === 0) {
10729
+ return "{}";
10730
+ }
10731
+ const props = entries.map(
10732
+ ([key, val]) => `${innerIndent}${key}: ${formatValue(val, depth + 1)},`
10733
+ );
10734
+ return `{
10735
+ ${props.join("\n")}
10736
+ ${indent}}`;
10737
+ }
10738
+ return String(value);
10739
+ }
10740
+ function fileTimestamp(iso) {
10741
+ return iso.replace(/\D/g, "").slice(0, 14);
10742
+ }
10743
+ function displayPath(filePath, userPath) {
10744
+ return userPath && isAbsolute(userPath) ? filePath : relative(process.cwd(), filePath);
10745
+ }
10746
+ function stripKeys(obj, keysToStrip) {
10747
+ const result = {};
10748
+ for (const [key, value] of Object.entries(obj)) {
10749
+ if (!keysToStrip.has(key) && value !== void 0 && value !== null) {
10750
+ result[key] = value;
10751
+ }
10752
+ }
10753
+ return result;
10754
+ }
10755
+
10756
+ function mapFieldToWire(field) {
10757
+ const { name, allow, datasource, ...rest } = field;
10758
+ const value = { ...rest };
10759
+ if (allow !== void 0) {
10760
+ value.component_whitelist = allow;
10761
+ if (rest.type === "bloks") {
10762
+ value.restrict_components = true;
10763
+ value.restrict_type = "";
10764
+ }
10765
+ }
10766
+ if (datasource !== void 0) {
10767
+ value.datasource_slug = datasource;
10768
+ }
10769
+ return { name: typeof name === "string" ? name : "", value };
10770
+ }
10771
+ function mapBlockToWire(block) {
10772
+ const { fields, ...rest } = block;
10773
+ const schema = {};
10774
+ if (Array.isArray(fields)) {
10775
+ for (const field of fields) {
10776
+ if (!isRecord(field)) {
10777
+ continue;
10778
+ }
10779
+ const { name, value } = mapFieldToWire(field);
10780
+ if (name) {
10781
+ schema[name] = value;
10782
+ }
10783
+ }
10784
+ }
10785
+ return { ...rest, schema };
10786
+ }
10787
+ function mapDatasourceToWire(datasource) {
10788
+ return datasource;
10789
+ }
10790
+
10791
+ function isComponent(value) {
10792
+ return isRecord(value) && typeof value.name === "string" && Array.isArray(value.fields);
10793
+ }
10794
+ function isDatasource(value) {
10795
+ return isRecord(value) && typeof value.name === "string" && typeof value.slug === "string" && !Array.isArray(value.fields);
10796
+ }
10797
+ function isSchemaObject(value) {
10798
+ return isRecord(value) && ("blocks" in value || "datasources" in value);
10799
+ }
10800
+ function emptySchemaData() {
10801
+ return { components: [], datasources: [] };
10802
+ }
10803
+ function classifyExports(moduleExports) {
10804
+ const data = emptySchemaData();
10805
+ const seenComponents = /* @__PURE__ */ new Set();
10806
+ const seenDatasources = /* @__PURE__ */ new Set();
10807
+ function collect(value) {
10808
+ if (isComponent(value)) {
10809
+ if (seenComponents.has(value.name)) {
10810
+ return;
10811
+ }
10812
+ seenComponents.add(value.name);
10813
+ data.components.push(mapBlockToWire(value));
10814
+ } else if (isDatasource(value)) {
10815
+ if (seenDatasources.has(value.name)) {
10816
+ return;
10817
+ }
10818
+ seenDatasources.add(value.name);
10819
+ data.datasources.push(mapDatasourceToWire(value));
10820
+ }
10821
+ }
10822
+ for (const value of Object.values(moduleExports)) {
10823
+ if (isSchemaObject(value)) {
10824
+ for (const group of Object.values(value)) {
10825
+ if (isRecord(group)) {
10826
+ for (const entity of Object.values(group)) {
10827
+ collect(entity);
10828
+ }
10829
+ }
10830
+ }
10831
+ } else {
10832
+ collect(value);
10833
+ }
10834
+ }
10835
+ return data;
10836
+ }
10837
+ async function loadSchema(entryPath) {
10838
+ const { createJiti } = await import('jiti');
10839
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
10840
+ const { resolve } = await import('pathe');
10841
+ const entryAbs = resolve(entryPath);
10842
+ const entryMod = await jiti.import(entryAbs);
10843
+ return classifyExports(entryMod);
10844
+ }
10845
+
10846
+ function sortSchemaByPos$1(schema) {
10847
+ const entries = Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
10848
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
10849
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
10850
+ return posA - posB;
10851
+ });
10852
+ return Object.fromEntries(
10853
+ entries.map(([key, field]) => {
10854
+ const { id, ...rest } = field;
10855
+ if (rest.restrict_type === "") {
10856
+ delete rest.restrict_type;
10857
+ }
10858
+ return [key, rest];
10859
+ })
10860
+ );
10861
+ }
10862
+ function serializeComponent(component, options = {}) {
10863
+ const stripSet = options.includeGroupUuid ? new Set([...COMPONENT_STRIP_KEYS].filter((key) => key !== "component_group_uuid")) : COMPONENT_STRIP_KEYS;
10864
+ const clean = stripKeys(component, stripSet);
10865
+ if (clean.schema && typeof clean.schema === "object") {
10866
+ clean.schema = sortSchemaByPos$1(clean.schema);
10867
+ }
10868
+ const ordered = {};
10869
+ if (clean.name !== void 0) {
10870
+ ordered.name = clean.name;
10871
+ }
10872
+ if (clean.display_name !== void 0) {
10873
+ ordered.display_name = clean.display_name;
10874
+ }
10875
+ if (clean.is_root !== void 0) {
10876
+ ordered.is_root = clean.is_root;
10877
+ }
10878
+ if (clean.is_nestable !== void 0) {
10879
+ ordered.is_nestable = clean.is_nestable;
10880
+ }
10881
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
10882
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10883
+ if (!handled.has(key)) {
10884
+ ordered[key] = value;
10885
+ }
10886
+ }
10887
+ if (clean.schema !== void 0) {
10888
+ ordered.schema = clean.schema;
10889
+ }
10890
+ return `defineBlock(${formatValue(ordered, 0)})`;
10891
+ }
10892
+ function serializeDatasource(datasource) {
10893
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
10894
+ if (Array.isArray(clean.dimensions)) {
10895
+ clean.dimensions = clean.dimensions.map(
10896
+ (dim) => typeof dim === "object" && dim !== null ? stripKeys(dim, DATASOURCE_DIMENSION_STRIP_KEYS) : dim
10897
+ );
10898
+ }
10899
+ const ordered = {};
10900
+ if (clean.name !== void 0) {
10901
+ ordered.name = clean.name;
10902
+ }
10903
+ if (clean.slug !== void 0) {
10904
+ ordered.slug = clean.slug;
10905
+ }
10906
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
10907
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
10908
+ if (!handled.has(key)) {
10909
+ ordered[key] = value;
10910
+ }
10911
+ }
10912
+ return `defineDatasource(${formatValue(ordered, 0)})`;
10913
+ }
10914
+
10915
+ function diffEntity(type, name, localSerialized, remoteSerialized) {
10916
+ if (!remoteSerialized && localSerialized) {
10917
+ return { type, name, action: "create", diff: null, local: null, remote: null };
10918
+ }
10919
+ if (remoteSerialized && !localSerialized) {
10920
+ return { type, name, action: "stale", diff: null, local: null, remote: null };
10921
+ }
10922
+ if (localSerialized === remoteSerialized) {
10923
+ return { type, name, action: "unchanged", diff: null, local: null, remote: null };
10924
+ }
10925
+ const patch = createTwoFilesPatch(
10926
+ `remote/${name}`,
10927
+ `local/${name}`,
10928
+ remoteSerialized,
10929
+ localSerialized,
10930
+ "remote",
10931
+ "local"
10932
+ );
10933
+ return { type, name, action: "update", diff: patch, local: null, remote: null };
10934
+ }
10935
+ function diffSchema(local, remote) {
10936
+ const diffs = [];
10937
+ const processedComponentNames = /* @__PURE__ */ new Set();
10938
+ for (const comp of local.components) {
10939
+ processedComponentNames.add(comp.name);
10940
+ const remoteComp = remote.components.get(comp.name);
10941
+ const includeGroupUuid = typeof comp.component_group_uuid === "string";
10942
+ const localSerialized = serializeComponent(applyDefaults(comp, COMPONENT_DEFAULTS), { includeGroupUuid });
10943
+ const remoteSerialized = remoteComp ? serializeComponent(applyDefaults(remoteComp, COMPONENT_DEFAULTS), { includeGroupUuid }) : null;
10944
+ diffs.push(diffEntity("component", comp.name, localSerialized, remoteSerialized));
10945
+ }
10946
+ for (const [name] of remote.components) {
10947
+ if (!processedComponentNames.has(name)) {
10948
+ diffs.push(diffEntity("component", name, null, "stale"));
10949
+ }
10950
+ }
10951
+ const processedDatasourceNames = /* @__PURE__ */ new Set();
10952
+ for (const ds of local.datasources) {
10953
+ processedDatasourceNames.add(ds.name);
10954
+ const remoteDs = remote.datasources.get(ds.name);
10955
+ const localSerialized = serializeDatasource(applyDefaults(ds, DATASOURCE_DEFAULTS));
10956
+ const remoteSerialized = remoteDs ? serializeDatasource(applyDefaults(remoteDs, DATASOURCE_DEFAULTS)) : null;
10957
+ diffs.push(diffEntity("datasource", ds.name, localSerialized, remoteSerialized));
10958
+ }
10959
+ for (const [name] of remote.datasources) {
10960
+ if (!processedDatasourceNames.has(name)) {
10961
+ diffs.push(diffEntity("datasource", name, null, "stale"));
10962
+ }
10963
+ }
10964
+ return {
10965
+ diffs,
10966
+ creates: diffs.filter((d) => d.action === "create").length,
10967
+ updates: diffs.filter((d) => d.action === "update").length,
10968
+ unchanged: diffs.filter((d) => d.action === "unchanged").length,
10969
+ stale: diffs.filter((d) => d.action === "stale").length
10970
+ };
10971
+ }
10972
+
10973
+ async function fetchRemoteSchema(spaceId) {
10974
+ const client = getMapiClient();
10975
+ const spaceIdNum = Number(spaceId);
10976
+ const [componentsRes, foldersRes, rawDatasources] = await Promise.all([
10977
+ client.components.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
10978
+ client.componentFolders.list({ path: { space_id: spaceIdNum }, throwOnError: true }),
10979
+ fetchAllPages(
10980
+ (page) => client.datasources.list({ path: { space_id: spaceIdNum }, query: { page }, throwOnError: true }),
10981
+ (data) => data?.datasources ?? []
10982
+ )
10983
+ ]);
10984
+ const rawComponents = componentsRes.data?.components ?? [];
10985
+ const rawComponentFolders = foldersRes.data?.component_groups ?? [];
10986
+ const remote = {
10987
+ components: new Map(rawComponents.map((c) => [c.name, c])),
10988
+ componentFolders: new Map(rawComponentFolders.map((f) => [f.name, f])),
10989
+ datasources: new Map(rawDatasources.map((d) => [d.name, d]))
10990
+ };
10991
+ return { remote, rawComponents, rawComponentFolders, rawDatasources };
10992
+ }
10993
+
10994
+ function isSchemaField(value) {
10995
+ return isRecord(value) && "type" in value;
10996
+ }
10997
+ function toSchemaRecord(schema) {
10998
+ const result = {};
10999
+ for (const [key, value] of Object.entries(schema)) {
11000
+ if (key === "_uid" || key === "component" || !isSchemaField(value)) {
11001
+ continue;
11002
+ }
11003
+ result[key] = value;
11004
+ }
11005
+ return result;
11006
+ }
11007
+ function buildComponentPayload(input) {
11008
+ if (!isRecord(input)) {
11009
+ return { name: "" };
11010
+ }
11011
+ return {
11012
+ name: typeof input.name === "string" ? input.name : "",
11013
+ // Fields in COMPONENT_DEFAULTS are always sent with their reset value so that
11014
+ // removing a field from the local schema actually clears it on the API.
11015
+ // (Root-level fields are additive on MAPI update — omitting preserves the old value.)
11016
+ display_name: typeof input.display_name === "string" ? input.display_name : "",
11017
+ description: typeof input.description === "string" ? input.description : "",
11018
+ color: typeof input.color === "string" ? input.color : "",
11019
+ icon: typeof input.icon === "string" ? input.icon : "",
11020
+ preview_field: typeof input.preview_field === "string" ? input.preview_field : "",
11021
+ internal_tag_ids: Array.isArray(input.internal_tag_ids) ? input.internal_tag_ids : [],
11022
+ // Conditionally sent: only included when explicitly set in local schema
11023
+ ...isRecord(input.schema) && { schema: toSchemaRecord(input.schema) },
11024
+ ...typeof input.is_root === "boolean" && { is_root: input.is_root },
11025
+ ...typeof input.is_nestable === "boolean" && { is_nestable: input.is_nestable },
11026
+ ...typeof input.component_group_uuid === "string" && { component_group_uuid: input.component_group_uuid }
11027
+ };
11028
+ }
11029
+ function toComponentCreate(input) {
11030
+ return buildComponentPayload(input);
11031
+ }
11032
+ function toComponentUpdate(input) {
11033
+ return buildComponentPayload(input);
11034
+ }
11035
+ function toDatasourceCreate(input) {
11036
+ if (!isRecord(input)) {
11037
+ return { name: "", slug: "" };
11038
+ }
11039
+ const result = {
11040
+ name: typeof input.name === "string" ? input.name : "",
11041
+ slug: typeof input.slug === "string" ? input.slug : ""
11042
+ };
11043
+ if (Array.isArray(input.dimensions)) {
11044
+ result.dimensions_attributes = input.dimensions.filter((d) => isRecord(d) && typeof d.name === "string" && typeof d.entry_value === "string").map((d) => ({
11045
+ name: d.name,
11046
+ entry_value: d.entry_value
11047
+ }));
11048
+ }
11049
+ return result;
11050
+ }
11051
+ function toDatasourceUpdate(input, remote) {
11052
+ const base = toDatasourceCreate(input);
11053
+ const localDims = base.dimensions_attributes ?? [];
11054
+ const remoteDims = remote.dimensions ?? [];
11055
+ if (remoteDims.length === 0) {
11056
+ return base;
11057
+ }
11058
+ const localKeys = new Set(localDims.map((d) => `${d.name}::${d.entry_value}`));
11059
+ const destroyEntries = remoteDims.filter((rd) => rd.id != null && !localKeys.has(`${rd.name}::${rd.entry_value}`)).map((rd) => ({ id: rd.id, _destroy: true }));
11060
+ if (destroyEntries.length > 0) {
11061
+ return {
11062
+ ...base,
11063
+ dimensions_attributes: [...localDims, ...destroyEntries]
11064
+ };
11065
+ }
11066
+ return base;
11067
+ }
11068
+
11069
+ function formatDiffOutput(result, options) {
11070
+ const lines = [];
11071
+ const byType = {
11072
+ component: [],
11073
+ datasource: []
11074
+ };
11075
+ for (const diff of result.diffs) {
11076
+ byType[diff.type].push(diff);
11077
+ }
11078
+ const willDelete = options?.delete ?? false;
11079
+ const icons = {
11080
+ create: chalk.green("+"),
11081
+ update: chalk.yellow("~"),
11082
+ unchanged: chalk.dim("="),
11083
+ stale: chalk.red("-")
11084
+ };
11085
+ const sections = [
11086
+ ["Components", byType.component],
11087
+ ["Datasources", byType.datasource]
11088
+ ];
11089
+ for (const [label, diffs] of sections) {
11090
+ if (diffs.length === 0) {
11091
+ continue;
11092
+ }
11093
+ lines.push(chalk.bold(label));
11094
+ for (const diff of diffs) {
11095
+ const icon = icons[diff.action] ?? " ";
11096
+ const name = diff.action === "stale" ? chalk.red(diff.name) : diff.name;
11097
+ const actionLabel = diff.action === "stale" && willDelete ? "delete" : diff.action;
11098
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${actionLabel})`)}`);
11099
+ if (diff.diff) {
11100
+ for (const line of diff.diff.split("\n")) {
11101
+ if (line.startsWith("+") && !line.startsWith("+++")) {
11102
+ lines.push(` ${chalk.green(line)}`);
11103
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
11104
+ lines.push(` ${chalk.red(line)}`);
11105
+ }
11106
+ }
11107
+ }
11108
+ }
11109
+ lines.push("");
11110
+ }
11111
+ const summary = [
11112
+ result.creates > 0 ? chalk.green(`${result.creates} to create`) : null,
11113
+ result.updates > 0 ? chalk.yellow(`${result.updates} to update`) : null,
11114
+ result.unchanged > 0 ? chalk.dim(`${result.unchanged} unchanged`) : null,
11115
+ result.stale > 0 ? chalk.red(`${result.stale} ${willDelete ? "to delete" : "stale"}`) : null
11116
+ ].filter(Boolean).join(", ");
11117
+ lines.push(`Summary: ${summary}`);
11118
+ return lines.join("\n");
11119
+ }
11120
+ async function executePush(spaceId, local, remote, diffResult, options) {
11121
+ const client = getMapiClient();
11122
+ const spaceIdNum = Number(spaceId);
11123
+ let created = 0;
11124
+ let updated = 0;
11125
+ let deleted = 0;
11126
+ const componentDiffs = diffResult.diffs.filter((d) => d.type === "component");
11127
+ const componentResults = await Promise.allSettled(
11128
+ componentDiffs.map(async (diff) => {
11129
+ const localComp = local.components.find((c) => c.name === diff.name);
11130
+ if (diff.action === "create" && localComp) {
11131
+ await client.components.create({
11132
+ path: { space_id: spaceIdNum },
11133
+ body: { component: toComponentCreate(localComp) },
11134
+ throwOnError: true
11135
+ });
11136
+ return "created";
11137
+ }
11138
+ if (diff.action === "update" && localComp) {
11139
+ const existing = remote.components.get(diff.name);
11140
+ if (existing?.id) {
11141
+ await client.components.update(existing.id, {
11142
+ path: { space_id: spaceIdNum },
11143
+ body: { component: toComponentUpdate(localComp) },
11144
+ throwOnError: true
11145
+ });
11146
+ return "updated";
11147
+ }
11148
+ }
11149
+ })
11150
+ );
11151
+ for (let i = 0; i < componentResults.length; i++) {
11152
+ const result = componentResults[i];
11153
+ const diff = componentDiffs[i];
11154
+ if (result.status === "fulfilled") {
11155
+ if (result.value === "created") {
11156
+ created++;
11157
+ } else if (result.value === "updated") {
11158
+ updated++;
11159
+ }
11160
+ } else {
11161
+ const eventId = diff.action === "create" ? "push_component" : "update_component";
11162
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} component ${diff.name}`);
11163
+ }
11164
+ }
11165
+ const datasourceDiffs = diffResult.diffs.filter((d) => d.type === "datasource");
11166
+ const datasourceResults = await Promise.allSettled(
11167
+ datasourceDiffs.map(async (diff) => {
11168
+ const localDs = local.datasources.find((d) => d.name === diff.name);
11169
+ if (diff.action === "create" && localDs) {
11170
+ await client.datasources.create({
11171
+ path: { space_id: spaceIdNum },
11172
+ body: { datasource: toDatasourceCreate(localDs) },
11173
+ throwOnError: true
11174
+ });
11175
+ return "created";
11176
+ }
11177
+ if (diff.action === "update" && localDs) {
11178
+ const existing = remote.datasources.get(diff.name);
11179
+ if (existing?.id) {
11180
+ await client.datasources.update(existing.id, {
11181
+ path: { space_id: spaceIdNum },
11182
+ body: { datasource: toDatasourceUpdate(localDs, existing) },
11183
+ throwOnError: true
11184
+ });
11185
+ return "updated";
11186
+ }
11187
+ }
11188
+ })
11189
+ );
11190
+ for (let i = 0; i < datasourceResults.length; i++) {
11191
+ const result = datasourceResults[i];
11192
+ const diff = datasourceDiffs[i];
11193
+ if (result.status === "fulfilled") {
11194
+ if (result.value === "created") {
11195
+ created++;
11196
+ } else if (result.value === "updated") {
11197
+ updated++;
11198
+ }
11199
+ } else {
11200
+ const eventId = diff.action === "create" ? "push_datasource" : "update_datasource";
11201
+ handleAPIError(eventId, result.reason, `Failed to ${diff.action} datasource ${diff.name}`);
11202
+ }
11203
+ }
11204
+ if (options.delete) {
11205
+ const staleComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
11206
+ const deleteComponentResults = await Promise.allSettled(
11207
+ staleComponents.map(async (diff) => {
11208
+ const existing = remote.components.get(diff.name);
11209
+ if (existing?.id) {
11210
+ await client.components.delete(existing.id, {
11211
+ path: { space_id: spaceIdNum },
11212
+ throwOnError: true
11213
+ });
11214
+ return true;
11215
+ }
11216
+ })
11217
+ );
11218
+ for (let i = 0; i < deleteComponentResults.length; i++) {
11219
+ const result = deleteComponentResults[i];
11220
+ if (result.status === "fulfilled") {
11221
+ if (result.value) {
11222
+ deleted++;
11223
+ }
11224
+ } else {
11225
+ handleAPIError("delete_component", result.reason, `Failed to delete component ${staleComponents[i].name}`);
11226
+ }
11227
+ }
11228
+ const staleDatasources = diffResult.diffs.filter((d) => d.type === "datasource" && d.action === "stale");
11229
+ const deleteDatasourceResults = await Promise.allSettled(
11230
+ staleDatasources.map(async (diff) => {
11231
+ const existing = remote.datasources.get(diff.name);
11232
+ if (existing?.id) {
11233
+ await client.datasources.delete(existing.id, {
11234
+ path: { space_id: spaceIdNum },
11235
+ throwOnError: true
11236
+ });
11237
+ return true;
11238
+ }
11239
+ })
11240
+ );
11241
+ for (let i = 0; i < deleteDatasourceResults.length; i++) {
11242
+ const result = deleteDatasourceResults[i];
11243
+ if (result.status === "fulfilled") {
11244
+ if (result.value) {
11245
+ deleted++;
11246
+ }
11247
+ } else {
11248
+ handleAPIError("delete_datasource", result.reason, `Failed to delete datasource ${staleDatasources[i].name}`);
11249
+ }
11250
+ }
11251
+ }
11252
+ return { created, updated, deleted };
11253
+ }
11254
+ function buildChangesetEntries(diffResult, local, remote, options) {
11255
+ const changes = [];
11256
+ for (const diff of diffResult.diffs) {
11257
+ if (diff.action === "unchanged") {
11258
+ continue;
11259
+ }
11260
+ if (diff.action === "stale" && !options.delete) {
11261
+ continue;
11262
+ }
11263
+ const action = diff.action === "stale" ? "delete" : diff.action;
11264
+ let remoteSrc;
11265
+ let localSrc;
11266
+ if (diff.type === "component") {
11267
+ remoteSrc = remote.components.get(diff.name);
11268
+ localSrc = local.components.find((c) => c.name === diff.name);
11269
+ } else if (diff.type === "datasource") {
11270
+ remoteSrc = remote.datasources.get(diff.name);
11271
+ localSrc = local.datasources.find((d) => d.name === diff.name);
11272
+ }
11273
+ changes.push({
11274
+ type: diff.type,
11275
+ name: diff.name,
11276
+ action,
11277
+ ...remoteSrc && { before: { ...remoteSrc } },
11278
+ ...localSrc && { after: { ...localSrc } }
11279
+ });
11280
+ }
11281
+ return changes;
11282
+ }
11283
+
11284
+ async function ensureDir(dir) {
11285
+ await mkdir(dir, { recursive: true });
11286
+ }
11287
+ async function saveChangeset(basePath, data) {
11288
+ const dir = join(basePath, "schema", "changesets");
11289
+ await ensureDir(dir);
11290
+ const fileName = `${fileTimestamp(data.timestamp)}.json`;
11291
+ const filePath = join(dir, fileName);
11292
+ await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
11293
+ return filePath;
11294
+ }
11295
+
11296
+ const SENTINEL_FIELDS = /* @__PURE__ */ new Set(["_uid", "component"]);
11297
+ function classifyFieldChanges(remoteSchema, localSchema) {
11298
+ const removed = [];
11299
+ const added = [];
11300
+ const typeChanged = [];
11301
+ const requiredAdded = [];
11302
+ const requiredChanged = [];
11303
+ for (const [field, remoteField] of Object.entries(remoteSchema)) {
11304
+ if (SENTINEL_FIELDS.has(field)) {
11305
+ continue;
11306
+ }
11307
+ if (typeof remoteField.type !== "string") {
11308
+ continue;
11309
+ }
11310
+ if (!(field in localSchema)) {
11311
+ removed.push({ field, type: remoteField.type });
11312
+ }
11313
+ }
11314
+ for (const [field, localField] of Object.entries(localSchema)) {
11315
+ if (SENTINEL_FIELDS.has(field)) {
11316
+ continue;
11317
+ }
11318
+ if (typeof localField.type !== "string") {
11319
+ continue;
11320
+ }
11321
+ if (!(field in remoteSchema)) {
11322
+ if (localField.required) {
11323
+ requiredAdded.push({ field, type: localField.type });
11324
+ } else {
11325
+ added.push({ field, type: localField.type, required: false });
11326
+ }
11327
+ } else {
11328
+ const remoteField = remoteSchema[field];
11329
+ if (typeof remoteField?.type !== "string") {
11330
+ continue;
11331
+ }
11332
+ if (remoteField.type !== localField.type) {
11333
+ typeChanged.push({ field, oldType: remoteField.type, newType: localField.type });
11334
+ }
11335
+ if (localField.required && !remoteField.required) {
11336
+ requiredChanged.push({ field, type: localField.type });
11337
+ }
11338
+ }
11339
+ }
11340
+ return { removed, added, typeChanged, requiredAdded, requiredChanged };
11341
+ }
11342
+ function longestCommonSubstring(a, b) {
11343
+ let maxLen = 0;
11344
+ for (let i = 0; i < a.length; i++) {
11345
+ for (let j = 0; j < b.length; j++) {
11346
+ let len = 0;
11347
+ while (i + len < a.length && j + len < b.length && a[i + len] === b[j + len]) {
11348
+ len++;
11349
+ }
11350
+ if (len > maxLen) {
11351
+ maxLen = len;
11352
+ }
11353
+ }
11354
+ }
11355
+ return maxLen;
11356
+ }
11357
+ function nameSimilarity(a, b) {
11358
+ const longer = Math.max(a.length, b.length);
11359
+ if (longer === 0) {
11360
+ return 1;
11361
+ }
11362
+ return longestCommonSubstring(a, b) / longer;
11363
+ }
11364
+ function detectRenames(removed, added) {
11365
+ const renames = [];
11366
+ const usedRemoved = /* @__PURE__ */ new Set();
11367
+ const usedAdded = /* @__PURE__ */ new Set();
11368
+ const addedByType = /* @__PURE__ */ new Map();
11369
+ for (const addedField of added) {
11370
+ if (!addedByType.has(addedField.type)) {
11371
+ addedByType.set(addedField.type, []);
11372
+ }
11373
+ addedByType.get(addedField.type).push(addedField);
11374
+ }
11375
+ const isSinglePair = removed.length === 1 && added.length === 1;
11376
+ for (const removedField of removed) {
11377
+ const candidates = addedByType.get(removedField.type) ?? [];
11378
+ const availableCandidates = candidates.filter((c) => !usedAdded.has(c.field));
11379
+ if (availableCandidates.length === 0) {
11380
+ continue;
11381
+ }
11382
+ let bestCandidate = availableCandidates[0];
11383
+ let bestScore = nameSimilarity(removedField.field, bestCandidate.field);
11384
+ for (let i = 1; i < availableCandidates.length; i++) {
11385
+ const score = nameSimilarity(removedField.field, availableCandidates[i].field);
11386
+ if (score > bestScore) {
11387
+ bestScore = score;
11388
+ bestCandidate = availableCandidates[i];
11389
+ }
11390
+ }
11391
+ if (!isSinglePair && bestScore < 0.3) {
11392
+ continue;
11393
+ }
11394
+ renames.push({ oldField: removedField.field, newField: bestCandidate.field, fieldType: removedField.type });
11395
+ usedRemoved.add(removedField.field);
11396
+ usedAdded.add(bestCandidate.field);
11397
+ }
11398
+ const unmatchedRemoved = removed.filter((r) => !usedRemoved.has(r.field));
11399
+ const unmatchedAdded = added.filter((a) => !usedAdded.has(a.field));
11400
+ return { renames, unmatchedRemoved, unmatchedAdded };
11401
+ }
11402
+ function analyzeBreakingChanges(diffResult, local, remote) {
11403
+ const results = [];
11404
+ const updatedComponents = diffResult.diffs.filter(
11405
+ (d) => d.type === "component" && d.action === "update"
11406
+ );
11407
+ for (const diff of updatedComponents) {
11408
+ const localComp = local.components.find((c) => c.name === diff.name);
11409
+ const remoteComp = remote.components.get(diff.name);
11410
+ if (!localComp?.schema || !remoteComp?.schema) {
11411
+ continue;
11412
+ }
11413
+ const classification = classifyFieldChanges(
11414
+ remoteComp.schema,
11415
+ localComp.schema
11416
+ );
11417
+ const changes = [];
11418
+ const { renames, unmatchedRemoved } = detectRenames(classification.removed, classification.added);
11419
+ for (const rename of renames) {
11420
+ changes.push({ kind: "rename", field: rename.newField, oldField: rename.oldField });
11421
+ }
11422
+ for (const removed of unmatchedRemoved) {
11423
+ changes.push({ kind: "removed", field: removed.field });
11424
+ }
11425
+ for (const tc of classification.typeChanged) {
11426
+ changes.push({ kind: "type_changed", field: tc.field, oldType: tc.oldType, newType: tc.newType });
11427
+ }
11428
+ for (const ra of classification.requiredAdded) {
11429
+ changes.push({ kind: "required_added", field: ra.field, fieldType: ra.type });
11430
+ }
11431
+ for (const rc of classification.requiredChanged) {
11432
+ changes.push({ kind: "required_changed", field: rc.field, fieldType: rc.type });
11433
+ }
11434
+ if (changes.length > 0) {
11435
+ results.push({ componentName: diff.name, changes });
11436
+ }
11437
+ }
11438
+ return results;
11439
+ }
11440
+
11441
+ const COMPATIBLE_TYPES = /* @__PURE__ */ new Set(["text:textarea", "textarea:text"]);
11442
+ function defaultForType(fieldType) {
11443
+ switch (fieldType) {
11444
+ case "text":
11445
+ case "textarea":
11446
+ case "markdown":
11447
+ return `''`;
11448
+ case "number":
11449
+ return "0";
11450
+ case "boolean":
11451
+ return "false";
11452
+ default:
11453
+ return null;
11454
+ }
11455
+ }
11456
+ function typeConversion(field, oldType, newType) {
11457
+ const key = `${oldType}:${newType}`;
11458
+ if (COMPATIBLE_TYPES.has(key)) {
11459
+ return null;
11460
+ }
11461
+ const accessor = `block.${field}`;
11462
+ switch (key) {
11463
+ case "text:number":
11464
+ return `${accessor} = Number(${accessor}) || 0;`;
11465
+ case "number:text":
11466
+ return `${accessor} = String(${accessor});`;
11467
+ case "text:boolean":
11468
+ return `${accessor} = !!${accessor};`;
11469
+ case "boolean:text":
11470
+ return `${accessor} = String(${accessor});`;
11471
+ default:
11472
+ return `${accessor}; // TODO: convert from ${oldType} to ${newType}`;
11473
+ }
11474
+ }
11475
+ function renderMigrationCode(changes) {
11476
+ const lines = [];
11477
+ lines.push(" // Review this migration before running it against your space.");
11478
+ lines.push(" // Generated migrations are scaffolds and may need manual adjustments.");
11479
+ lines.push(" // Example rename migration:");
11480
+ lines.push(" // block.new_field = block.old_field;");
11481
+ lines.push(" // delete block.old_field;");
11482
+ lines.push("");
11483
+ for (const change of changes) {
11484
+ switch (change.kind) {
11485
+ case "rename":
11486
+ lines.push(` // Rename: ${change.oldField} \u2192 ${change.field}`);
11487
+ lines.push(` if ('${change.oldField}' in block) {`);
11488
+ lines.push(` block.${change.field} = block.${change.oldField};`);
11489
+ lines.push(` delete block.${change.oldField};`);
11490
+ lines.push(` }`);
11491
+ break;
11492
+ case "removed":
11493
+ if (change.renameHint) {
11494
+ lines.push(` // If '${change.field}' was renamed to '${change.renameHint.newField}', uncomment:`);
11495
+ lines.push(` // block.${change.renameHint.newField} = block.${change.field};`);
11496
+ } else {
11497
+ lines.push(` // Removed field: ${change.field}`);
11498
+ }
11499
+ lines.push(` delete block.${change.field};`);
11500
+ break;
11501
+ case "type_changed": {
11502
+ const conversion = typeConversion(change.field, change.oldType, change.newType);
11503
+ if (conversion) {
11504
+ lines.push(` // Type change: ${change.field} (${change.oldType} \u2192 ${change.newType})`);
11505
+ lines.push(` ${conversion}`);
11506
+ }
11507
+ break;
11508
+ }
11509
+ case "required_added": {
11510
+ const defaultValue = defaultForType(change.fieldType);
11511
+ lines.push(` // New required field: ${change.field} (${change.fieldType})`);
11512
+ if (defaultValue !== null) {
11513
+ lines.push(` // TODO: provide a meaningful default value`);
11514
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
11515
+ } else {
11516
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
11517
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
11518
+ }
11519
+ break;
11520
+ }
11521
+ case "required_changed": {
11522
+ const defaultValue = defaultForType(change.fieldType);
11523
+ lines.push(` // Field is now required: ${change.field} (${change.fieldType})`);
11524
+ lines.push(` // Existing stories may have null/undefined values \u2014 provide a default for those.`);
11525
+ if (defaultValue !== null) {
11526
+ lines.push(` // TODO: provide a meaningful default value`);
11527
+ lines.push(` block.${change.field} = block.${change.field} ?? ${defaultValue};`);
11528
+ } else {
11529
+ lines.push(` // TODO: provide a default value appropriate for the '${change.fieldType}' type`);
11530
+ lines.push(` // block.${change.field} = block.${change.field} ?? <default>;`);
11531
+ }
11532
+ break;
11533
+ }
11534
+ }
11535
+ lines.push("");
11536
+ }
11537
+ const body = lines.length > 0 ? `
11538
+ ${lines.join("\n")}` : "\n";
11539
+ return `export default function (block) {${body} return block;
11540
+ }
11541
+ `;
11542
+ }
11543
+ async function writeMigrationFile(options) {
11544
+ const { spaceId, componentName, code, timestamp, basePath } = options;
11545
+ const dir = resolvePath(basePath, `migrations/${spaceId}`);
11546
+ await mkdir(dir, { recursive: true });
11547
+ const fileName = `${componentName}.${fileTimestamp(timestamp)}.js`;
11548
+ const filePath = join(dir, fileName);
11549
+ await writeFile(filePath, code, "utf-8");
11550
+ return filePath;
11551
+ }
11552
+
11553
+ const DEFAULT_GROUPS_FILENAME = "groups.json";
11554
+ const CONSOLIDATED_COMPONENTS_FILENAME = "components.json";
11555
+ async function writeLocalComponents({
11556
+ space,
11557
+ basePath,
11558
+ resolved,
11559
+ diffResult,
11560
+ deleteRemoved,
11561
+ ui,
11562
+ logger
11563
+ }) {
11564
+ const componentsDir = resolveCommandPath(directories.components, space, basePath);
11565
+ const consolidatedPath = join(componentsDir, CONSOLIDATED_COMPONENTS_FILENAME);
11566
+ if (await fileExists(consolidatedPath)) {
11567
+ ui.warn(
11568
+ `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.`
11569
+ );
11570
+ }
11571
+ for (const component of resolved.components) {
11572
+ const filePath = join(componentsDir, `${sanitizeFilename(component.name || "")}.json`);
11573
+ await saveToFile(filePath, JSON.stringify(component, null, 2));
11574
+ }
11575
+ const groupsPath = join(componentsDir, DEFAULT_GROUPS_FILENAME);
11576
+ if (await fileExists(groupsPath)) {
11577
+ try {
11578
+ await unlink(groupsPath);
11579
+ logger.info("Removed stale local groups file", { path: displayPath(groupsPath, basePath) });
11580
+ } catch (error) {
11581
+ if (error.code !== "ENOENT") {
11582
+ throw error;
11583
+ }
11584
+ }
11585
+ }
11586
+ if (deleteRemoved) {
11587
+ const staleComponents = diffResult.diffs.filter(
11588
+ (d) => d.type === "component" && d.action === "stale"
11589
+ );
11590
+ for (const stale of staleComponents) {
11591
+ const filePath = join(componentsDir, `${sanitizeFilename(stale.name)}.json`);
11592
+ try {
11593
+ await unlink(filePath);
11594
+ logger.info("Removed stale local component file", { path: displayPath(filePath, basePath) });
11595
+ } catch (error) {
11596
+ if (error.code !== "ENOENT") {
11597
+ throw error;
11598
+ }
11599
+ }
11600
+ }
11601
+ }
11602
+ logger.info("Wrote local component files", {
11603
+ space,
11604
+ componentsWritten: resolved.components.length
11605
+ });
11606
+ }
11607
+
11608
+ 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) => {
11609
+ const ui = getUI();
11610
+ const logger = getLogger();
11611
+ const reporter = getReporter();
11612
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
11613
+ const { state } = session();
11614
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Pushing schema...");
11615
+ logger.info("Schema push started", { entryFile, space });
11616
+ if (!requireAuthentication(state, verbose)) {
11617
+ return;
11618
+ }
11619
+ if (!space) {
11620
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
11621
+ return;
11622
+ }
11623
+ const summary = { total: 0, succeeded: 0, failed: 0 };
11624
+ try {
11625
+ const loadSpinner = ui.createSpinner("Resolving schema...");
11626
+ let local;
11627
+ try {
11628
+ local = await loadSchema(entryFile);
11629
+ } catch (maybeError) {
11630
+ loadSpinner.failed("Failed to resolve schema");
11631
+ handleError(toError(maybeError), verbose);
11632
+ return;
11633
+ }
11634
+ loadSpinner.succeed(`Found: ${local.components.length} components, ${local.datasources.length} datasources`);
11635
+ const totalLocal = local.components.length + local.datasources.length;
11636
+ if (totalLocal === 0) {
11637
+ ui.warn("No components or datasources found in the entry file. Verify the file exports schema definitions.");
11638
+ return;
11639
+ }
11640
+ const remoteSpinner = ui.createSpinner(`Fetching 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, rawComponents, rawComponentFolders, rawDatasources } = remoteResult;
11650
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.datasources.size} datasources`);
11651
+ const diffResult = diffSchema(local, remote);
11652
+ ui.br();
11653
+ ui.log(formatDiffOutput(diffResult, { delete: options.delete }));
11654
+ if (options.migrations) {
11655
+ const breakingChanges = analyzeBreakingChanges(diffResult, local, remote);
11656
+ if (breakingChanges.length > 0) {
11657
+ const totalChanges = breakingChanges.reduce((sum, c) => sum + c.changes.length, 0);
11658
+ ui.br();
11659
+ ui.warn(`${totalChanges} breaking change(s) detected in ${breakingChanges.length} component(s).`);
11660
+ ui.info("Generated migrations are scaffolds. Review and adjust them before running `storyblok migrations run`.");
11661
+ if (!options.dryRun) {
11662
+ const explicitMigrations = command.getOptionValueSource("migrations") === "cli";
11663
+ const shouldGenerate = explicitMigrations || await confirm({
11664
+ message: "Generate migration files for breaking changes?",
11665
+ default: true
11666
+ });
11667
+ if (shouldGenerate) {
11668
+ const migrationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
11669
+ const resolvedBase = resolvePath(basePath, "");
11670
+ for (const comp of breakingChanges) {
11671
+ const renames = comp.changes.filter((c) => c.kind === "rename");
11672
+ if (renames.length > 0 && explicitMigrations) {
11673
+ for (const r of renames) {
11674
+ if (r.kind === "rename") {
11675
+ ui.log(` Assumed rename in '${comp.componentName}': ${r.oldField} \u2192 ${r.field}`);
11676
+ }
11677
+ }
11678
+ }
11679
+ if (renames.length > 0 && !explicitMigrations) {
11680
+ ui.br();
11681
+ ui.log(`Detected renames in '${comp.componentName}':`);
11682
+ for (const r of renames) {
11683
+ if (r.kind === "rename") {
11684
+ ui.log(` ${r.oldField} \u2192 ${r.field}`);
11685
+ }
11686
+ }
11687
+ const renameConfirmed = await confirm({
11688
+ message: "Are these renames correct?",
11689
+ default: true
11690
+ });
11691
+ if (!renameConfirmed) {
11692
+ comp.changes = comp.changes.map((c) => {
11693
+ if (c.kind === "rename") {
11694
+ return { kind: "removed", field: c.oldField, renameHint: { newField: c.field } };
11695
+ }
11696
+ return c;
11697
+ });
11698
+ }
11699
+ }
11700
+ const code = renderMigrationCode(comp.changes);
11701
+ const path = await writeMigrationFile({
11702
+ spaceId: space,
11703
+ componentName: comp.componentName,
11704
+ code,
11705
+ timestamp: migrationTimestamp,
11706
+ basePath: resolvedBase
11707
+ });
11708
+ const migrationPath = displayPath(path, basePath);
11709
+ logger.info("Migration generated", { component: comp.componentName, path: migrationPath });
11710
+ ui.log(` Generated: ${migrationPath}`);
11711
+ }
11712
+ ui.br();
11713
+ ui.info(`Run migrations when ready: storyblok migrations run --space ${space}`);
11714
+ }
11715
+ }
11716
+ }
11717
+ }
11718
+ if (options.delete) {
11719
+ const deletedComponents = diffResult.diffs.filter((d) => d.type === "component" && d.action === "stale");
11720
+ for (const comp of deletedComponents) {
11721
+ ui.warn(`Component '${comp.name}' will be deleted. Stories using it will have out-of-schema content.`);
11722
+ }
11723
+ }
11724
+ if (diffResult.stale > 0 && !options.delete) {
11725
+ ui.warn(`${diffResult.stale} stale entity(s) exist remotely but not in schema. Use --delete to remove.`);
11726
+ }
11727
+ if (options.dryRun) {
11728
+ ui.info("Dry run \u2014 no changes applied.");
11729
+ logger.info("Dry run completed", { creates: diffResult.creates, updates: diffResult.updates });
11730
+ return;
11731
+ }
11732
+ const resolvedPath = resolvePath(basePath, "");
11733
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11734
+ const changesetPath = await saveChangeset(resolvedPath, {
11735
+ timestamp,
11736
+ spaceId: Number(space),
11737
+ remote: { components: rawComponents, componentFolders: rawComponentFolders, datasources: rawDatasources },
11738
+ changes: buildChangesetEntries(diffResult, local, remote, { delete: options.delete })
11739
+ });
11740
+ logger.info("Changeset saved", { path: displayPath(changesetPath, basePath) });
11741
+ const nothingToPush = diffResult.creates === 0 && diffResult.updates === 0 && (!options.delete || diffResult.stale === 0);
11742
+ if (nothingToPush) {
11743
+ ui.ok("Everything up to date \u2014 nothing to push.");
11744
+ } else {
11745
+ const pushSpinner = ui.createSpinner("Pushing schema...");
11746
+ let result;
11747
+ try {
11748
+ result = await executePush(space, local, remote, diffResult, { delete: options.delete });
11749
+ } catch (error) {
11750
+ pushSpinner.failed("Failed to push schema");
11751
+ throw error;
11752
+ }
11753
+ summary.total = result.created + result.updated + result.deleted;
11754
+ summary.succeeded = summary.total;
11755
+ pushSpinner.succeed(`Pushed ${result.created} creations, ${result.updated} updates${result.deleted > 0 ? `, ${result.deleted} deletions` : ""}.`);
11756
+ }
11757
+ if (options.writeComponents) {
11758
+ try {
11759
+ await writeLocalComponents({
11760
+ space,
11761
+ basePath,
11762
+ resolved: local,
11763
+ diffResult,
11764
+ deleteRemoved: options.delete,
11765
+ ui,
11766
+ logger
11767
+ });
11768
+ } catch (writeError) {
11769
+ ui.warn(`Failed to write local component files: ${toError(writeError).message}`);
11770
+ logger.warn("Failed to write local component files", { error: toError(writeError).message });
11771
+ }
11772
+ }
11773
+ } catch (maybeError) {
11774
+ summary.failed += 1;
11775
+ handleError(toError(maybeError), verbose);
11776
+ } finally {
11777
+ logger.info("Schema push finished", { summary });
11778
+ reporter.addSummary("schemaPushResults", summary);
11779
+ reporter.finalize();
11780
+ }
11781
+ });
11782
+
11783
+ function buildGroupPathByUuid(folders) {
11784
+ const byUuid = new Map(folders.map((folder) => [folder.uuid, folder]));
11785
+ const cache = /* @__PURE__ */ new Map();
11786
+ function pathFor(uuid, visited) {
11787
+ if (!uuid) {
11788
+ return [];
11789
+ }
11790
+ const cached = cache.get(uuid);
11791
+ if (cached) {
11792
+ return cached;
11793
+ }
11794
+ const folder = byUuid.get(uuid);
11795
+ if (!folder) {
11796
+ return [];
11797
+ }
11798
+ if (visited.has(uuid)) {
11799
+ return [];
11800
+ }
11801
+ visited.add(uuid);
11802
+ const path = [...pathFor(folder.parent_uuid, visited), slugify(folder.name)];
11803
+ cache.set(uuid, path);
11804
+ return path;
11805
+ }
11806
+ for (const folder of folders) {
11807
+ pathFor(folder.uuid, /* @__PURE__ */ new Set());
11808
+ }
11809
+ return cache;
11810
+ }
11811
+
11812
+ const FIELD_STRIP_KEYS = /* @__PURE__ */ new Set(["id", "pos"]);
11813
+ function toCamelCaseIdentifier(str) {
11814
+ const camel = slugify(str).replace(/^[_-]+/, "").replace(/[_-]+(.)/g, (_, char) => char.toUpperCase());
11815
+ if (!camel) {
11816
+ return "_";
11817
+ }
11818
+ return /^\d/.test(camel) ? `_${camel}` : camel;
11819
+ }
11820
+ function toKebabCase(str) {
11821
+ return str.replace(/[\s_]+/g, "-").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
11822
+ }
11823
+ function componentVarName(name) {
11824
+ return `${toCamelCaseIdentifier(name)}Block`;
11825
+ }
11826
+ function datasourceVarName(name) {
11827
+ return `${toCamelCaseIdentifier(name)}Datasource`;
11828
+ }
11829
+ function resolveVarNames(rawNames, baseVarName) {
11830
+ const used = /* @__PURE__ */ new Set();
11831
+ return rawNames.map((raw) => {
11832
+ const base = baseVarName(raw);
11833
+ let candidate = base;
11834
+ let n = 2;
11835
+ while (used.has(candidate)) {
11836
+ candidate = `${base}${n++}`;
11837
+ }
11838
+ used.add(candidate);
11839
+ return candidate;
11840
+ });
11841
+ }
11842
+ function resolveFileNames(baseNames, dirKeys) {
11843
+ const usedByDir = /* @__PURE__ */ new Map();
11844
+ return baseNames.map((base, i) => {
11845
+ const dir = dirKeys?.[i] ?? "";
11846
+ let used = usedByDir.get(dir);
11847
+ if (!used) {
11848
+ used = /* @__PURE__ */ new Set();
11849
+ usedByDir.set(dir, used);
11850
+ }
11851
+ let candidate = base;
11852
+ let n = 2;
11853
+ while (used.has(candidate)) {
11854
+ candidate = `${base}-${n++}`;
11855
+ }
11856
+ used.add(candidate);
11857
+ return candidate;
11858
+ });
11859
+ }
11860
+ function componentFileName(name) {
11861
+ return toKebabCase(name);
11862
+ }
11863
+ function datasourceFileName(datasource) {
11864
+ return toKebabCase(datasource.slug || datasource.name);
11865
+ }
11866
+ function resolveComponents(components, segmentsByIndex) {
11867
+ const varNames = resolveVarNames(components.map((c) => c.name), componentVarName);
11868
+ const fileNames = resolveFileNames(
11869
+ components.map((c) => componentFileName(c.name)),
11870
+ segmentsByIndex.map((segments) => segments.join("/"))
11871
+ );
11872
+ return components.map((component, i) => ({
11873
+ component,
11874
+ varName: varNames[i],
11875
+ fileName: fileNames[i],
11876
+ segments: segmentsByIndex[i]
11877
+ }));
11878
+ }
11879
+ function resolveDatasources(datasources) {
11880
+ const varNames = resolveVarNames(datasources.map((d) => d.name), datasourceVarName);
11881
+ const fileNames = resolveFileNames(datasources.map((d) => datasourceFileName(d)));
11882
+ return datasources.map((datasource, i) => ({
11883
+ datasource,
11884
+ varName: varNames[i],
11885
+ fileName: fileNames[i]
11886
+ }));
11887
+ }
11888
+ function toDslField(field) {
11889
+ const { component_whitelist, datasource_slug, restrict_components, restrict_type, ...rest } = field;
11890
+ const out = { ...rest };
11891
+ if (component_whitelist !== void 0) {
11892
+ out.allow = component_whitelist;
11893
+ } else {
11894
+ if (restrict_components !== void 0) {
11895
+ out.restrict_components = restrict_components;
11896
+ }
11897
+ if (restrict_type !== void 0) {
11898
+ out.restrict_type = restrict_type;
11899
+ }
11900
+ }
11901
+ if (datasource_slug !== void 0) {
11902
+ out.datasource = datasource_slug;
11903
+ }
11904
+ return out;
11905
+ }
11906
+ function generateFieldCode(fieldName, fieldData, depth) {
11907
+ const clean = toDslField(stripKeys(fieldData, FIELD_STRIP_KEYS));
11908
+ return `defineField(${quoteString(fieldName)}, ${formatValue(clean, depth)})`;
11909
+ }
11910
+ function sortSchemaByPos(schema) {
11911
+ return Object.entries(schema).filter(([key]) => key !== "_uid" && key !== "component").sort(([, a], [, b]) => {
11912
+ const posA = typeof a.pos === "number" ? a.pos : Infinity;
11913
+ const posB = typeof b.pos === "number" ? b.pos : Infinity;
11914
+ return posA - posB;
11915
+ });
11916
+ }
11917
+ function generateComponentFile(component, varName) {
11918
+ const lines = [];
11919
+ lines.push("import {");
11920
+ lines.push(" defineBlock,");
11921
+ lines.push(" defineField,");
11922
+ lines.push("} from '@storyblok/schema';");
11923
+ lines.push("");
11924
+ const resolvedVarName = varName ?? componentVarName(component.name);
11925
+ lines.push(`export const ${resolvedVarName} = defineBlock({`);
11926
+ const clean = stripKeys(component, COMPONENT_STRIP_KEYS);
11927
+ delete clean.component_group_uuid;
11928
+ const orderedKeys = [];
11929
+ if (clean.name !== void 0) {
11930
+ orderedKeys.push("name");
11931
+ }
11932
+ if (clean.display_name !== void 0) {
11933
+ orderedKeys.push("display_name");
11934
+ }
11935
+ if (clean.is_root !== void 0) {
11936
+ orderedKeys.push("is_root");
11937
+ }
11938
+ if (clean.is_nestable !== void 0) {
11939
+ orderedKeys.push("is_nestable");
11940
+ }
11941
+ const handled = /* @__PURE__ */ new Set(["name", "display_name", "is_root", "is_nestable", "schema"]);
11942
+ for (const key of Object.keys(clean).sort()) {
11943
+ if (!handled.has(key)) {
11944
+ orderedKeys.push(key);
11945
+ }
11946
+ }
11947
+ for (const key of orderedKeys) {
11948
+ lines.push(`${INDENT}${key}: ${formatValue(clean[key], 1)},`);
11949
+ }
11950
+ if (clean.schema && typeof clean.schema === "object") {
11951
+ const schema = clean.schema;
11952
+ const sortedFields = sortSchemaByPos(schema);
11953
+ if (sortedFields.length > 0) {
11954
+ lines.push(`${INDENT}fields: [`);
11955
+ for (const [fieldName, fieldData] of sortedFields) {
11956
+ const fieldCode = generateFieldCode(fieldName, fieldData, 2);
11957
+ lines.push(`${INDENT}${INDENT}${fieldCode},`);
11958
+ }
11959
+ lines.push(`${INDENT}],`);
11960
+ } else {
11961
+ lines.push(`${INDENT}fields: [],`);
11962
+ }
11963
+ }
11964
+ lines.push("});");
11965
+ lines.push("");
11966
+ return lines.join("\n");
11967
+ }
11968
+ function generateDatasourceFile(datasource, varName) {
11969
+ const lines = [];
11970
+ lines.push("import { defineDatasource } from '@storyblok/schema';");
11971
+ lines.push("");
11972
+ const resolvedVarName = varName ?? datasourceVarName(datasource.name);
11973
+ lines.push(`export const ${resolvedVarName} = defineDatasource({`);
11974
+ const clean = stripKeys(datasource, DATASOURCE_STRIP_KEYS);
11975
+ if (clean.name !== void 0) {
11976
+ lines.push(`${INDENT}name: ${formatValue(clean.name, 1)},`);
11977
+ }
11978
+ if (clean.slug !== void 0) {
11979
+ lines.push(`${INDENT}slug: ${formatValue(clean.slug, 1)},`);
11980
+ }
11981
+ const handled = /* @__PURE__ */ new Set(["name", "slug"]);
11982
+ for (const [key, value] of Object.entries(clean).sort(([a], [b]) => a.localeCompare(b))) {
11983
+ if (!handled.has(key)) {
11984
+ lines.push(`${INDENT}${key}: ${formatValue(value, 1)},`);
11985
+ }
11986
+ }
11987
+ lines.push("});");
11988
+ lines.push("");
11989
+ return lines.join("\n");
11990
+ }
11991
+ function generateSchemaFile(components, datasources) {
11992
+ const lines = [];
11993
+ lines.push("import { defineSchema } from '@storyblok/schema';");
11994
+ lines.push("import type { Schema as InferSchema, Story as InferStory } from '@storyblok/schema';");
11995
+ lines.push("import type { MapiStory as InferStoryMapi } from '@storyblok/schema';");
11996
+ lines.push("");
11997
+ for (const { varName, fileName, segments } of components) {
11998
+ const subPath = segments.length > 0 ? `${segments.join("/")}/` : "";
11999
+ lines.push(`import { ${varName} } from './blocks/${subPath}${fileName}';`);
12000
+ }
12001
+ for (const { varName, fileName } of datasources) {
12002
+ lines.push(`import { ${varName} } from './datasources/${fileName}';`);
12003
+ }
12004
+ lines.push("");
12005
+ lines.push("export const schema = defineSchema({");
12006
+ if (components.length > 0) {
12007
+ lines.push(" blocks: {");
12008
+ for (const { varName } of components) {
12009
+ lines.push(` ${varName},`);
12010
+ }
12011
+ lines.push(" },");
12012
+ }
12013
+ if (datasources.length > 0) {
12014
+ lines.push(" datasources: {");
12015
+ for (const { varName } of datasources) {
12016
+ lines.push(` ${varName},`);
12017
+ }
12018
+ lines.push(" },");
12019
+ }
12020
+ lines.push("});");
12021
+ lines.push("");
12022
+ lines.push("export type Schema = InferSchema<typeof schema>;");
12023
+ lines.push("export type Blocks = Schema['blocks'];");
12024
+ lines.push("export type Story = InferStory<Blocks>;");
12025
+ lines.push("export type StoryMapi = InferStoryMapi<Blocks>;");
12026
+ lines.push("");
12027
+ return lines.join("\n");
12028
+ }
12029
+
12030
+ async function writeFileWithDirs(filePath, content) {
12031
+ const dir = dirname(filePath);
12032
+ await mkdir(dir, { recursive: true });
12033
+ await writeFile(filePath, content, "utf-8");
12034
+ }
12035
+ async function writeSchemaFiles(targetPath, components, componentFolders, datasources) {
12036
+ const writtenFiles = [];
12037
+ const groupPathByUuid = buildGroupPathByUuid(componentFolders);
12038
+ const componentSegments = components.map(
12039
+ (comp) => comp.component_group_uuid ? groupPathByUuid.get(comp.component_group_uuid) ?? [] : []
12040
+ );
12041
+ const resolvedComponents = resolveComponents(components, componentSegments);
12042
+ const resolvedDatasources = resolveDatasources(datasources);
12043
+ for (const { component, varName, fileName, segments } of resolvedComponents) {
12044
+ const filePath = join(targetPath, "blocks", ...segments, `${fileName}.ts`);
12045
+ await writeFileWithDirs(filePath, generateComponentFile(component, varName));
12046
+ writtenFiles.push(filePath);
12047
+ }
12048
+ for (const { datasource, varName, fileName } of resolvedDatasources) {
12049
+ const filePath = join(targetPath, "datasources", `${fileName}.ts`);
12050
+ await writeFileWithDirs(filePath, generateDatasourceFile(datasource, varName));
12051
+ writtenFiles.push(filePath);
12052
+ }
12053
+ const schemaPath = join(targetPath, "schema.ts");
12054
+ await writeFileWithDirs(schemaPath, generateSchemaFile(resolvedComponents, resolvedDatasources));
12055
+ writtenFiles.push(schemaPath);
12056
+ return writtenFiles;
12057
+ }
12058
+
12059
+ async function isTargetEmpty(targetPath) {
12060
+ try {
12061
+ const entries = await readdir(targetPath);
12062
+ return entries.every((entry) => entry.startsWith("."));
12063
+ } catch (maybeError) {
12064
+ const error = maybeError;
12065
+ if (error?.code === "ENOENT") {
12066
+ return true;
12067
+ }
12068
+ throw error;
12069
+ }
12070
+ }
12071
+ 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) => {
12072
+ const ui = getUI();
12073
+ const logger = getLogger();
12074
+ const reporter = getReporter();
12075
+ const { space, verbose } = command.optsWithGlobals();
12076
+ const { state } = session();
12077
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Initializing schema...");
12078
+ logger.info("Schema init started", { space });
12079
+ if (!requireAuthentication(state, verbose)) {
12080
+ return;
12081
+ }
12082
+ if (!space) {
12083
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
12084
+ return;
12085
+ }
12086
+ const targetPath = resolve(options.outDir);
12087
+ const targetDisplayPath = displayPath(targetPath, options.outDir);
12088
+ if (!await isTargetEmpty(targetPath)) {
12089
+ handleError(
12090
+ 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.`),
12091
+ verbose
12092
+ );
12093
+ return;
12094
+ }
12095
+ const summary = { total: 0, succeeded: 0, failed: 0 };
12096
+ try {
12097
+ const fetchSpinner = ui.createSpinner(`Fetching schema from space ${space}...`);
12098
+ let fetchResult;
12099
+ try {
12100
+ fetchResult = await fetchRemoteSchema(space);
12101
+ } catch (maybeError) {
12102
+ fetchSpinner.failed("Failed to fetch remote schema");
12103
+ handleError(toError(maybeError), verbose);
12104
+ return;
12105
+ }
12106
+ const { rawComponents, rawComponentFolders, rawDatasources } = fetchResult;
12107
+ fetchSpinner.succeed(`Found: ${rawComponents.length} components, ${rawComponentFolders.length} component folders, ${rawDatasources.length} datasources`);
12108
+ const writeSpinner = ui.createSpinner(`Generating TypeScript files to ${targetDisplayPath}...`);
12109
+ const writtenFiles = await writeSchemaFiles(targetPath, rawComponents, rawComponentFolders, rawDatasources);
12110
+ summary.total = writtenFiles.length;
12111
+ summary.succeeded = writtenFiles.length;
12112
+ writeSpinner.succeed(`Generated ${writtenFiles.length} files`);
12113
+ ui.list(writtenFiles.map((file) => displayPath(file, options.outDir)));
12114
+ ui.warn("`schema init` is a one-time bootstrap step for adopting an existing space. Review generated files before continuing.");
12115
+ ui.info("After bootstrapping, keep your local schema as the source of truth and use `schema push` for ongoing changes.");
12116
+ ui.info("Make sure `@storyblok/schema` is installed in the project that imports these files (e.g. `pnpm add @storyblok/schema`).");
12117
+ } catch (maybeError) {
12118
+ summary.failed += 1;
12119
+ handleError(toError(maybeError), verbose);
12120
+ } finally {
12121
+ logger.info("Schema init finished", { summary });
12122
+ reporter.addSummary("schemaInitResults", summary);
12123
+ reporter.finalize();
12124
+ }
12125
+ });
12126
+
12127
+ const API_ASSIGNED_FIELDS = [
12128
+ "id",
12129
+ "created_at",
12130
+ "updated_at",
12131
+ "real_name",
12132
+ "all_presets",
12133
+ "image",
12134
+ "uuid"
12135
+ ];
12136
+ function stripApiFields(payload) {
12137
+ const result = { ...payload };
12138
+ for (const field of API_ASSIGNED_FIELDS) {
12139
+ delete result[field];
12140
+ }
12141
+ return result;
12142
+ }
12143
+ async function listChangesets(basePath) {
12144
+ const dir = join(basePath, "schema", "changesets");
12145
+ if (!await fileExists(dir)) {
12146
+ return [];
12147
+ }
12148
+ const files = await readDirectory(dir);
12149
+ return files.filter((f) => f.endsWith(".json")).sort().reverse().map((f) => join(dir, f));
12150
+ }
12151
+ async function loadChangeset(filePath) {
12152
+ const content = await readFile$1(filePath, "utf-8");
12153
+ return JSON.parse(content);
12154
+ }
12155
+ function buildRollbackOps(changeset) {
12156
+ if (changeset.changes.length === 0) {
12157
+ return [];
12158
+ }
12159
+ return changeset.changes.map((entry) => {
12160
+ switch (entry.action) {
12161
+ case "create":
12162
+ return { type: entry.type, name: entry.name, action: "delete", payload: {} };
12163
+ case "update":
12164
+ return { type: entry.type, name: entry.name, action: "update", payload: entry.before ?? {} };
12165
+ case "delete":
12166
+ return { type: entry.type, name: entry.name, action: "create", payload: entry.before ?? {} };
12167
+ default:
12168
+ return { type: entry.type, name: entry.name, action: entry.action, payload: {} };
12169
+ }
12170
+ });
12171
+ }
12172
+ function rollbackAction(original) {
12173
+ switch (original) {
12174
+ case "create":
12175
+ return "delete";
12176
+ case "update":
12177
+ return "update";
12178
+ case "delete":
12179
+ return "create";
12180
+ }
12181
+ }
12182
+ function formatRollbackOutput(changes) {
12183
+ const byType = {
12184
+ component: [],
12185
+ datasource: []
12186
+ };
12187
+ for (const entry of changes) {
12188
+ byType[entry.type]?.push(entry);
12189
+ }
12190
+ const icons = {
12191
+ create: chalk.green("+"),
12192
+ update: chalk.yellow("~"),
12193
+ delete: chalk.red("-")
12194
+ };
12195
+ const lines = [];
12196
+ const sections = [
12197
+ ["Components", byType.component],
12198
+ ["Datasources", byType.datasource]
12199
+ ];
12200
+ for (const [label, entries] of sections) {
12201
+ if (entries.length === 0) {
12202
+ continue;
12203
+ }
12204
+ lines.push(chalk.bold(label));
12205
+ for (const entry of entries) {
12206
+ const action = rollbackAction(entry.action);
12207
+ const icon = icons[action] ?? " ";
12208
+ const name = action === "delete" ? chalk.red(entry.name) : entry.name;
12209
+ lines.push(` ${icon} ${name} ${chalk.dim(`(${action})`)}`);
12210
+ if (entry.action === "update" && entry.before && entry.after) {
12211
+ let fromStr;
12212
+ let toStr;
12213
+ if (entry.type === "component") {
12214
+ fromStr = serializeComponent(applyDefaults(entry.after, COMPONENT_DEFAULTS));
12215
+ toStr = serializeComponent(applyDefaults(entry.before, COMPONENT_DEFAULTS));
12216
+ } else {
12217
+ fromStr = serializeDatasource(entry.after);
12218
+ toStr = serializeDatasource(entry.before);
12219
+ }
12220
+ if (fromStr !== toStr) {
12221
+ const patch = createTwoFilesPatch(
12222
+ `current/${entry.name}`,
12223
+ `restore/${entry.name}`,
12224
+ fromStr,
12225
+ toStr,
12226
+ "current",
12227
+ "restore"
12228
+ );
12229
+ for (const line of patch.split("\n")) {
12230
+ if (line.startsWith("+") && !line.startsWith("+++")) {
12231
+ lines.push(` ${chalk.green(line)}`);
12232
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
12233
+ lines.push(` ${chalk.red(line)}`);
12234
+ }
12235
+ }
12236
+ }
12237
+ }
12238
+ }
12239
+ lines.push("");
12240
+ }
12241
+ return lines.join("\n").trimEnd();
12242
+ }
12243
+ async function executeRollback(spaceId, ops, remote) {
12244
+ const client = getMapiClient();
12245
+ const spaceIdNum = Number(spaceId);
12246
+ let created = 0;
12247
+ let updated = 0;
12248
+ let deleted = 0;
12249
+ const componentOps = ops.filter((op) => op.type === "component");
12250
+ const datasourceOps = ops.filter((op) => op.type === "datasource");
12251
+ for (const op of componentOps.filter((o) => o.action !== "delete")) {
12252
+ if (op.action === "create") {
12253
+ const payload = toComponentCreate(stripApiFields(op.payload));
12254
+ try {
12255
+ await client.components.create({
12256
+ path: { space_id: spaceIdNum },
12257
+ body: { component: payload },
12258
+ throwOnError: true
12259
+ });
12260
+ created++;
12261
+ } catch (error) {
12262
+ handleAPIError("push_component", error, `Failed to create component ${op.name}`);
12263
+ }
12264
+ } else if (op.action === "update") {
12265
+ const existing = remote.components.get(op.name);
12266
+ if (existing?.id) {
12267
+ const payload = toComponentUpdate(stripApiFields(op.payload));
12268
+ try {
12269
+ await client.components.update(existing.id, {
12270
+ path: { space_id: spaceIdNum },
12271
+ body: { component: payload },
12272
+ throwOnError: true
12273
+ });
12274
+ updated++;
12275
+ } catch (error) {
12276
+ handleAPIError("update_component", error, `Failed to update component ${op.name}`);
12277
+ }
12278
+ }
12279
+ }
12280
+ }
12281
+ for (const op of datasourceOps.filter((o) => o.action !== "delete")) {
12282
+ if (op.action === "create") {
12283
+ const payload = toDatasourceCreate(stripApiFields(op.payload));
12284
+ try {
12285
+ await client.datasources.create({
12286
+ path: { space_id: spaceIdNum },
12287
+ body: { datasource: payload },
12288
+ throwOnError: true
12289
+ });
12290
+ created++;
12291
+ } catch (error) {
12292
+ handleAPIError("push_datasource", error, `Failed to create datasource ${op.name}`);
12293
+ }
12294
+ } else if (op.action === "update") {
12295
+ const existing = remote.datasources.get(op.name);
12296
+ if (existing?.id) {
12297
+ const payload = toDatasourceUpdate(stripApiFields(op.payload), existing);
12298
+ try {
12299
+ await client.datasources.update(existing.id, {
12300
+ path: { space_id: spaceIdNum },
12301
+ body: { datasource: payload },
12302
+ throwOnError: true
12303
+ });
12304
+ updated++;
12305
+ } catch (error) {
12306
+ handleAPIError("update_datasource", error, `Failed to update datasource ${op.name}`);
12307
+ }
12308
+ }
12309
+ }
12310
+ }
12311
+ for (const op of datasourceOps.filter((o) => o.action === "delete")) {
12312
+ const existing = remote.datasources.get(op.name);
12313
+ if (existing?.id) {
12314
+ try {
12315
+ await client.datasources.delete(existing.id, {
12316
+ path: { space_id: spaceIdNum },
12317
+ throwOnError: true
12318
+ });
12319
+ deleted++;
12320
+ } catch (error) {
12321
+ handleAPIError("delete_datasource", error, `Failed to delete datasource ${op.name}`);
12322
+ }
12323
+ }
12324
+ }
12325
+ for (const op of componentOps.filter((o) => o.action === "delete")) {
12326
+ const existing = remote.components.get(op.name);
12327
+ if (existing?.id) {
12328
+ try {
12329
+ await client.components.delete(existing.id, {
12330
+ path: { space_id: spaceIdNum },
12331
+ throwOnError: true
12332
+ });
12333
+ deleted++;
12334
+ } catch (error) {
12335
+ handleAPIError("delete_component", error, `Failed to delete component ${op.name}`);
12336
+ }
12337
+ }
12338
+ }
12339
+ return { created, updated, deleted };
12340
+ }
12341
+
12342
+ 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) => {
12343
+ const ui = getUI();
12344
+ const logger = getLogger();
12345
+ const reporter = getReporter();
12346
+ const { space, path: basePath, verbose } = command.optsWithGlobals();
12347
+ const { state } = session();
12348
+ ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Rolling back schema...");
12349
+ logger.info("Schema rollback started", { changesetFile, space });
12350
+ if (!requireAuthentication(state, verbose)) {
12351
+ return;
12352
+ }
12353
+ if (!space) {
12354
+ handleError(new CommandError("Please provide the space as argument --space SPACE_ID."), verbose);
12355
+ return;
12356
+ }
12357
+ const summary = { total: 0, succeeded: 0, failed: 0 };
12358
+ try {
12359
+ const resolvedBase = resolvePath(basePath, "");
12360
+ let resolvedFile;
12361
+ if (changesetFile) {
12362
+ resolvedFile = changesetFile;
12363
+ } else if (options.latest) {
12364
+ const available = await listChangesets(resolvedBase);
12365
+ if (available.length === 0) {
12366
+ ui.warn("No changesets found. Run `schema push` first to create one.");
12367
+ return;
12368
+ }
12369
+ resolvedFile = available[0];
12370
+ ui.info(`Using latest changeset: ${basename(resolvedFile)}`);
12371
+ } else {
12372
+ const available = await listChangesets(resolvedBase);
12373
+ if (available.length === 0) {
12374
+ ui.warn("No changesets found. Run `schema push` first to create one.");
12375
+ return;
12376
+ }
12377
+ resolvedFile = await select({
12378
+ message: "Select a changeset to roll back:",
12379
+ choices: available.map((f) => ({ name: basename(f), value: f }))
12380
+ });
12381
+ }
12382
+ let changeset;
12383
+ try {
12384
+ changeset = await loadChangeset(resolvedFile);
12385
+ } catch (maybeError) {
12386
+ handleError(toError(maybeError), verbose);
12387
+ return;
12388
+ }
12389
+ logger.info("Changeset loaded", { file: resolvedFile, changes: changeset.changes.length });
12390
+ const ops = buildRollbackOps(changeset);
12391
+ if (ops.length === 0) {
12392
+ ui.ok("Changeset has no changes \u2014 nothing to roll back.");
12393
+ return;
12394
+ }
12395
+ ui.br();
12396
+ ui.log(formatRollbackOutput(changeset.changes));
12397
+ if (options.dryRun) {
12398
+ ui.info("Dry run \u2014 no changes applied.");
12399
+ logger.info("Dry run completed", { ops: ops.length });
12400
+ return;
12401
+ }
12402
+ if (!options.yes) {
12403
+ const confirmed = await confirm({
12404
+ message: `Apply rollback of ${ops.length} change(s) from ${basename(resolvedFile)}?`,
12405
+ default: false
12406
+ });
12407
+ if (!confirmed) {
12408
+ ui.info("Rollback cancelled.");
12409
+ return;
12410
+ }
12411
+ }
12412
+ const remoteSpinner = ui.createSpinner(`Fetching current remote state from space ${space}...`);
12413
+ let remoteResult;
12414
+ try {
12415
+ remoteResult = await fetchRemoteSchema(space);
12416
+ } catch (maybeError) {
12417
+ remoteSpinner.failed("Failed to fetch remote schema");
12418
+ handleError(toError(maybeError), verbose);
12419
+ return;
12420
+ }
12421
+ const { remote } = remoteResult;
12422
+ remoteSpinner.succeed(`Remote: ${remote.components.size} components, ${remote.componentFolders.size} component folders, ${remote.datasources.size} datasources`);
12423
+ const rollbackSpinner = ui.createSpinner("Applying rollback...");
12424
+ let result;
12425
+ try {
12426
+ result = await executeRollback(space, ops, remote);
12427
+ } catch (error) {
12428
+ rollbackSpinner.failed("Failed to apply rollback");
12429
+ throw error;
12430
+ }
12431
+ summary.total = result.created + result.updated + result.deleted;
12432
+ summary.succeeded = summary.total;
12433
+ rollbackSpinner.succeed(`Rolled back: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted.`);
12434
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
12435
+ const rollbackChangesetPath = await saveChangeset(resolvedBase, {
12436
+ timestamp,
12437
+ spaceId: Number(space),
12438
+ remote: { components: remoteResult.rawComponents, componentFolders: remoteResult.rawComponentFolders, datasources: remoteResult.rawDatasources },
12439
+ changes: ops.map((op) => ({
12440
+ type: op.type,
12441
+ name: op.name,
12442
+ action: op.action,
12443
+ ...Object.keys(op.payload).length > 0 && { after: op.payload }
12444
+ }))
12445
+ });
12446
+ logger.info("Rollback changeset saved", { path: displayPath(rollbackChangesetPath, basePath) });
12447
+ } catch (maybeError) {
12448
+ summary.failed += 1;
12449
+ handleError(toError(maybeError), verbose);
12450
+ } finally {
12451
+ logger.info("Schema rollback finished", { summary });
12452
+ reporter.addSummary("schemaRollbackResults", summary);
12453
+ reporter.finalize();
12454
+ }
12455
+ });
12456
+
10545
12457
  const program = getProgram();
10546
12458
  konsola.br();
10547
12459
  konsola.br();