@sitecoreai-labs/sitecoreai-cli 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/agents/tasks/agent.js +2 -2
  2. package/dist/agents/tasks/resources.js +2 -2
  3. package/dist/agents/tasks/space.js +1 -1
  4. package/dist/brand/api/auth.d.ts +16 -9
  5. package/dist/brand/api/auth.js +29 -19
  6. package/dist/brand/credential.d.ts +71 -1
  7. package/dist/brand/credential.js +119 -2
  8. package/dist/brand/recipe/diff.js +7 -2
  9. package/dist/brand/recipe/kind.js +0 -0
  10. package/dist/brand/recipe/schema.d.ts +113 -7
  11. package/dist/brand/recipe/schema.js +137 -8
  12. package/dist/brand/seed.d.ts +9 -5
  13. package/dist/brand/seed.js +30 -5
  14. package/dist/brief/api/briefs.d.ts +8 -0
  15. package/dist/brief/api/briefs.js +49 -11
  16. package/dist/brief/index.d.ts +1 -1
  17. package/dist/brief/index.js +2 -1
  18. package/dist/brief/recipe/index.d.ts +11 -2
  19. package/dist/brief/recipe/index.js +17 -3
  20. package/dist/brief/recipe/instance-diff.d.ts +4 -0
  21. package/dist/brief/recipe/instance-diff.js +77 -0
  22. package/dist/brief/recipe/instance-kind.d.ts +4 -0
  23. package/dist/brief/recipe/instance-kind.js +190 -0
  24. package/dist/brief/recipe/instance-schema.d.ts +61 -0
  25. package/dist/brief/recipe/instance-schema.js +68 -0
  26. package/dist/brief/recipe/schema.js +4 -1
  27. package/dist/brief/tasks/index.d.ts +35 -0
  28. package/dist/brief/tasks/index.js +62 -1
  29. package/dist/campaigns/recipe/schema.d.ts +39 -8
  30. package/dist/campaigns/recipe/schema.js +40 -10
  31. package/dist/commands/agents/sync.js +2 -2
  32. package/dist/commands/brand/seed.js +1 -1
  33. package/dist/commands/brand/sync.js +2 -2
  34. package/dist/commands/brief/create.d.ts +2 -0
  35. package/dist/commands/brief/create.js +56 -0
  36. package/dist/commands/brief/index.d.ts +3 -1
  37. package/dist/commands/brief/index.js +11 -1
  38. package/dist/commands/brief/sync.d.ts +9 -6
  39. package/dist/commands/brief/sync.js +54 -22
  40. package/dist/commands/brief/update.d.ts +2 -0
  41. package/dist/commands/brief/update.js +84 -0
  42. package/dist/commands/campaign/sync.js +2 -2
  43. package/dist/mcp/descriptions.js +3 -3
  44. package/dist/mcp/tools/brief-recipe.js +67 -23
  45. package/dist/mcp/tools/brief.js +83 -6
  46. package/dist/recipe/compile/design-parameters-template.js +5 -3
  47. package/dist/recipe/compile/enumeration.js +6 -11
  48. package/dist/recipe/compile/shared.js +12 -12
  49. package/dist/recipe/compile.js +4 -4
  50. package/dist/recipe/io.d.ts +8 -3
  51. package/dist/recipe/io.js +11 -81
  52. package/dist/recipe/items/read-current.js +31 -24
  53. package/dist/recipe/schema/recipe.d.ts +167 -84
  54. package/dist/recipe/schema/recipe.js +130 -46
  55. package/dist/recipe/schema/source-fields.d.ts +20 -0
  56. package/dist/recipe/schema/source-fields.js +25 -1
  57. package/dist/recipe/validate.d.ts +3 -3
  58. package/dist/recipe/validate.js +20 -10
  59. package/dist/sync/aggregate-kinds.js +1 -0
  60. package/dist/sync/aggregate.js +2 -2
  61. package/dist/sync/io.d.ts +13 -3
  62. package/dist/sync/io.js +43 -17
  63. package/dist/sync/typescript-recipe.d.ts +30 -0
  64. package/dist/sync/typescript-recipe.js +112 -0
  65. package/package.json +1 -1
@@ -186,21 +186,21 @@ const registerBriefTools = (registry) => {
186
186
  .describe("Which Brief resource the verb targets. 'brief-type' supports create/update/delete; 'brief' supports set-status/delete; 'comment' supports create."),
187
187
  verb: zod_1.z
188
188
  .enum(["create", "update", "delete", "set-status"])
189
- .describe("Mutation verb. brief-type: create (POST), update (PUT-replace), delete (irreversible). brief: set-status, delete. comment: create."),
189
+ .describe("Mutation verb. brief-type: create (POST), update (PUT-replace), delete (irreversible). brief: create (POST), update (partial PUT), set-status, delete. comment: create."),
190
190
  briefTypeId: zod_1.z
191
191
  .string()
192
192
  .uuid()
193
193
  .optional()
194
- .describe("Brief type UUID. Required for brief-type verb='update' and verb='delete'."),
194
+ .describe("Brief type UUID. Required for brief-type verb='update' and verb='delete', and for brief verb='create' (the type to build the brief against)."),
195
195
  briefId: zod_1.z
196
196
  .string()
197
197
  .uuid()
198
198
  .optional()
199
- .describe("Brief UUID. Required for resource='brief' verb='set-status'."),
199
+ .describe("Brief UUID. Required for resource='brief' verbs 'update', 'set-status', and 'delete'."),
200
200
  status: zod_1.z
201
201
  .enum(["Draft", "InReview", "Approved", "Canceled", "Archived"])
202
202
  .optional()
203
- .describe("Target brief status for verb='set-status'. Wire form — 'InReview' is the 'In Review' UI label. A brief must leave 'Draft' before it can be linked to a campaign."),
203
+ .describe("Target brief status. Required for verb='set-status'; optional on brief 'create'/'update' (defaults to server 'Draft' on create). Wire form — 'InReview' is the 'In Review' UI label. A brief must leave 'Draft' before it can be linked to a campaign."),
204
204
  commentText: zod_1.z
205
205
  .string()
206
206
  .optional()
@@ -221,7 +221,19 @@ const registerBriefTools = (registry) => {
221
221
  .describe("Field definitions (RichText | DateTime | Timeline | Budget). Use brief_inspect verb='type' on an existing type for a worked example."),
222
222
  })
223
223
  .optional()
224
- .describe("Full BriefType body for verb='create' or verb='update'. Required on writes; ignored on delete."),
224
+ .describe("Full BriefType body for resource='brief-type' verb='create' or verb='update'. Required on brief-type writes; ignored on delete."),
225
+ brief: zod_1.z
226
+ .object({
227
+ name: zod_1.z.string().min(1).describe("Brief display name."),
228
+ locale: zod_1.z.string().optional().describe("BCP-47-ish locale, e.g. 'en-us'."),
229
+ fields: zod_1.z
230
+ .record(zod_1.z.string(), zod_1.z.unknown())
231
+ .optional()
232
+ .describe("Field values keyed by BriefField.name; the per-field shape follows the brief type's field definitions."),
233
+ isTemplate: zod_1.z.boolean().optional().describe("Whether the brief is a template."),
234
+ })
235
+ .optional()
236
+ .describe("Brief body. Required for resource='brief' verb='create' (with `briefTypeId`); optional for verb='update' (any subset of name/locale/fields/isTemplate plus the top-level `status`)."),
225
237
  ...common_1.environmentBindingShape,
226
238
  ...common_1.allowWriteShape,
227
239
  ...common_1.whatIfShape,
@@ -230,6 +242,71 @@ const registerBriefTools = (registry) => {
230
242
  const taskOpts = baseTaskOptions(context.configPath, input.environmentName ?? context.envName);
231
243
  const whatIf = input.whatIf;
232
244
  if (input.resource === "brief") {
245
+ if (input.verb === "create") {
246
+ if (!input.brief) {
247
+ throw (0, errors_1.createScaiError)("verb='create' on resource='brief' requires `brief`.", "INPUT_INVALID");
248
+ }
249
+ if (!input.briefTypeId) {
250
+ throw (0, errors_1.createScaiError)("verb='create' on resource='brief' requires `briefTypeId`.", "INPUT_INVALID");
251
+ }
252
+ const createInput = {
253
+ name: input.brief.name,
254
+ briefTypeId: input.briefTypeId,
255
+ ...(input.brief.locale !== undefined && { locale: input.brief.locale }),
256
+ ...(input.brief.fields !== undefined && { fields: input.brief.fields }),
257
+ ...(input.brief.isTemplate !== undefined && { isTemplate: input.brief.isTemplate }),
258
+ };
259
+ const result = await (0, tasks_1.runBriefCreate)({
260
+ ...taskOpts,
261
+ input: createInput,
262
+ whatIf,
263
+ });
264
+ const isPlan = "plan" in result;
265
+ return {
266
+ content: [
267
+ {
268
+ type: "text",
269
+ text: isPlan
270
+ ? `Plan: create brief '${input.brief.name}'.`
271
+ : `Created brief '${input.brief.name}'.`,
272
+ },
273
+ ],
274
+ structuredContent: { resource: input.resource, verb: input.verb, result },
275
+ };
276
+ }
277
+ if (input.verb === "update") {
278
+ if (!input.briefId) {
279
+ throw (0, errors_1.createScaiError)("verb='update' on resource='brief' requires `briefId`.", "INPUT_INVALID");
280
+ }
281
+ const patch = {
282
+ ...(input.brief?.name !== undefined && { name: input.brief.name }),
283
+ ...(input.brief?.locale !== undefined && { locale: input.brief.locale }),
284
+ ...(input.brief?.fields !== undefined && { fields: input.brief.fields }),
285
+ ...(input.brief?.isTemplate !== undefined && { isTemplate: input.brief.isTemplate }),
286
+ ...(input.status !== undefined && { status: input.status }),
287
+ };
288
+ if (Object.keys(patch).length === 0) {
289
+ throw (0, errors_1.createScaiError)("verb='update' on resource='brief' requires at least one field in `brief` or a top-level `status`.", "INPUT_INVALID");
290
+ }
291
+ const result = await (0, tasks_1.runBriefUpdate)({
292
+ ...taskOpts,
293
+ briefId: input.briefId,
294
+ patch,
295
+ whatIf,
296
+ });
297
+ const isPlan = "plan" in result;
298
+ return {
299
+ content: [
300
+ {
301
+ type: "text",
302
+ text: isPlan
303
+ ? `Plan: update brief ${input.briefId} (${Object.keys(patch).join(", ")}).`
304
+ : `Updated brief ${input.briefId}.`,
305
+ },
306
+ ],
307
+ structuredContent: { resource: input.resource, verb: input.verb, result },
308
+ };
309
+ }
233
310
  if (input.verb === "set-status") {
234
311
  if (!input.briefId) {
235
312
  throw (0, errors_1.createScaiError)("verb='set-status' requires `briefId`.", "INPUT_INVALID");
@@ -277,7 +354,7 @@ const registerBriefTools = (registry) => {
277
354
  structuredContent: { resource: input.resource, verb: input.verb, result },
278
355
  };
279
356
  }
280
- throw (0, errors_1.createScaiError)("resource='brief' supports verb='set-status' and verb='delete'.", "INPUT_INVALID");
357
+ throw (0, errors_1.createScaiError)("resource='brief' supports verbs 'create', 'update', 'set-status', and 'delete'.", "INPUT_INVALID");
281
358
  }
282
359
  if (input.resource === "comment") {
283
360
  if (input.verb !== "create") {
@@ -6,6 +6,7 @@ const operations_1 = require("../ir/operations");
6
6
  const policy_1 = require("../runtime/policy");
7
7
  const sitecore_templates_1 = require("../ir/sitecore-templates");
8
8
  const recipe_1 = require("../schema/recipe");
9
+ const component_section_1 = require("./component-section");
9
10
  const shared_1 = require("./shared");
10
11
  /**
11
12
  * Compile a standalone `DesignParametersTemplateRecipe` to an Operation IR.
@@ -22,9 +23,10 @@ function compileDesignParametersTemplateRecipe(input, context, emittedFolders =
22
23
  const policy = (0, policy_1.defaultPolicyForRecipe)(recipe.kind);
23
24
  const icon = recipe.icon ?? sitecore_templates_1.DEFAULT_ICON;
24
25
  const site = (0, shared_1.siteOf)(context);
25
- (0, shared_1.ensureSectionFolder)(operations, context, recipe.section, emittedFolders);
26
- const bucketRefKey = (0, shared_1.ensurePresentationDesignParametersBucket)(operations, context, recipe.section, emittedFolders);
27
- const parentPath = (0, shared_1.resolvePresentationDesignParametersBucketPath)(context, recipe.section);
26
+ const sectionName = (0, component_section_1.resolveSectionRecipe)(recipe.handle, recipe.section.handle, context.sectionsByHandle).name;
27
+ (0, shared_1.ensureSectionFolder)(operations, context, sectionName, emittedFolders);
28
+ const bucketRefKey = (0, shared_1.ensurePresentationDesignParametersBucket)(operations, context, sectionName, emittedFolders);
29
+ const parentPath = (0, shared_1.resolvePresentationDesignParametersBucketPath)(context, sectionName);
28
30
  // The standalone parameters template lands at the same identity
29
31
  // (designParametersTemplateId) as inline-hoisted ones — keeps re-pushes
30
32
  // idempotent if a recipe migrates from inline to standalone.
@@ -118,17 +118,12 @@ function compileEnumerationRecipe(input, context, emittedFolders = new Set()) {
118
118
  kind: "ref-path",
119
119
  value: context.enumerationsRoot,
120
120
  };
121
- const folder = recipe.location?.folder;
122
- if (folder) {
123
- const folderSegments = folder
124
- .split("/")
125
- .map((s) => s.trim())
126
- .filter(Boolean);
127
- if (folderSegments.length === 0) {
128
- throw (0, errors_1.createScaiError)(`Recipe '${recipe.handle}' declares location.folder that is empty after trimming.`, "INPUT_INVALID", {
129
- hint: "Use a non-empty folder string like 'Theme' or 'Components/Card'.",
130
- });
131
- }
121
+ // `recipe.location.folder` is normalized to `string[]` by the schema
122
+ // (`FolderPath` in schema/recipe.ts) — both `"Theme/Color"` and
123
+ // `["Theme", "Color"]` input shapes land here as `["Theme", "Color"]`,
124
+ // already trimmed and non-empty.
125
+ const folderSegments = recipe.location?.folder;
126
+ if (folderSegments && folderSegments.length > 0) {
132
127
  const cumulativeSegments = [];
133
128
  for (const segment of folderSegments) {
134
129
  cumulativeSegments.push(segment);
@@ -50,9 +50,12 @@ const resolveEnumFolderPath = (context, enumHandle, consumerHandle) => {
50
50
  hint: "Add an `EnumerationRecipe` (kind: 'enumeration') with the matching handle to the recipe set, or change the field's `sitecore.enumHandle` to point at an existing one.",
51
51
  });
52
52
  }
53
- const folder = enumRecipe.location?.folder;
54
- return folder
55
- ? (0, exports.joinPath)((0, exports.joinPath)(context.enumerationsRoot, folder), enumRecipe.name)
53
+ // `location.folder` is a `string[]` of grouping segments after the
54
+ // schema's `FolderPath` normalisation — join with `/` here to build
55
+ // the cumulative path under enumerationsRoot.
56
+ const folderSegments = enumRecipe.location?.folder;
57
+ return folderSegments && folderSegments.length > 0
58
+ ? (0, exports.joinPath)((0, exports.joinPath)(context.enumerationsRoot, folderSegments.join("/")), enumRecipe.name)
56
59
  : (0, exports.joinPath)(context.enumerationsRoot, enumRecipe.name);
57
60
  };
58
61
  exports.resolveEnumFolderPath = resolveEnumFolderPath;
@@ -799,19 +802,16 @@ function encodeStandardValueDefault(raw, type) {
799
802
  function resolveFieldSource(field, type, site, recipeHandle, context) {
800
803
  const sc = field.sitecore;
801
804
  if (sc) {
802
- const fields = {
803
- sourceTypes: sc.sourceTypes,
804
- sourceQuery: sc.sourceQuery,
805
- sourceScope: sc.sourceScope,
806
- sourceRaw: sc.sourceRaw,
807
- };
805
+ const fields = (0, source_fields_1.augmentSourceToFields)(sc.source);
808
806
  if ((0, source_fields_1.sourceFieldsNeedHandleResolution)(fields)) {
807
+ // `types` is non-empty here because `sourceFieldsNeedHandleResolution`
808
+ // returned true; the cast is to satisfy the IR's `.min(1)` constraint.
809
809
  return {
810
810
  kind: "ref-source-fields",
811
811
  site,
812
- sourceTypes: sc.sourceTypes,
813
- sourceQuery: sc.sourceQuery,
814
- sourceScope: sc.sourceScope,
812
+ sourceTypes: fields.sourceTypes,
813
+ sourceQuery: fields.sourceQuery,
814
+ sourceScope: fields.sourceScope,
815
815
  };
816
816
  }
817
817
  const rendered = (0, source_fields_1.renderSourceFields)(fields, () => {
@@ -720,10 +720,10 @@ const buildPlaceholderSettingsAggregate = (recipes, context, site) => {
720
720
  // parent ref + path the leaf placeholder item lands under.
721
721
  const emittedFolders = new Set();
722
722
  const resolveFolderParent = (folder) => {
723
- const segments = (folder ?? "")
724
- .split("/")
725
- .map((segment) => segment.trim())
726
- .filter(Boolean);
723
+ // `folder` is normalised to `string[]` at the schema boundary
724
+ // (`FolderPath` in schema/recipe.ts) — both array and legacy
725
+ // slash-string inputs land here as a clean segment list.
726
+ const segments = folder ?? [];
727
727
  let parentPath = root;
728
728
  let parentRef = { kind: "ref-path", value: root };
729
729
  let cumulative = "";
@@ -4,11 +4,16 @@ import type { Plan } from "./runtime/plan";
4
4
  /**
5
5
  * Load a recipe file. Supports both shapes:
6
6
  *
7
- * - `.recipe.ts` — TypeScript source compiled at runtime via `tsx/cjs/api`.
8
- * Recipes export the recipe object as either the default export or a
9
- * named export (the first one found wins).
7
+ * - `.recipe.ts` (or `.tsx`/`.mts`/`.cts`) — TypeScript source compiled
8
+ * and executed in the recipe sandbox. Recipes export the recipe
9
+ * object as either the default export or a named export (the first
10
+ * one found wins).
10
11
  * - `.recipe.json` — pre-serialized recipe JSON.
11
12
  *
13
+ * The TypeScript path is shared with the schema-aware loader at
14
+ * `@/sync/typescript-recipe` so brand-kit, agent, campaign, and brief
15
+ * recipes can also be authored as `.recipe.ts`.
16
+ *
12
17
  * Validates against `RecipeSchema` (the `Recipe = ComponentTemplateRecipe |
13
18
  * ContentTemplateRecipe` discriminated union) so both kinds parse uniformly.
14
19
  */
package/dist/recipe/io.js CHANGED
@@ -7,9 +7,9 @@ exports.defaultPlanPath = exports.defaultIrPath = exports.writePlan = exports.lo
7
7
  const node_fs_1 = require("node:fs");
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const errors_1 = require("../shared/errors");
10
+ const typescript_recipe_1 = require("../sync/typescript-recipe");
10
11
  const recipe_1 = require("./schema/recipe");
11
12
  const operations_1 = require("./ir/operations");
12
- const load_1 = require("./sandbox/load");
13
13
  /**
14
14
  * Recipe + IR + Plan I/O.
15
15
  *
@@ -46,18 +46,22 @@ const writeJson = async (filePath, value) => {
46
46
  /**
47
47
  * Load a recipe file. Supports both shapes:
48
48
  *
49
- * - `.recipe.ts` — TypeScript source compiled at runtime via `tsx/cjs/api`.
50
- * Recipes export the recipe object as either the default export or a
51
- * named export (the first one found wins).
49
+ * - `.recipe.ts` (or `.tsx`/`.mts`/`.cts`) — TypeScript source compiled
50
+ * and executed in the recipe sandbox. Recipes export the recipe
51
+ * object as either the default export or a named export (the first
52
+ * one found wins).
52
53
  * - `.recipe.json` — pre-serialized recipe JSON.
53
54
  *
55
+ * The TypeScript path is shared with the schema-aware loader at
56
+ * `@/sync/typescript-recipe` so brand-kit, agent, campaign, and brief
57
+ * recipes can also be authored as `.recipe.ts`.
58
+ *
54
59
  * Validates against `RecipeSchema` (the `Recipe = ComponentTemplateRecipe |
55
60
  * ContentTemplateRecipe` discriminated union) so both kinds parse uniformly.
56
61
  */
57
62
  const loadRecipe = async (filePath) => {
58
- const ext = node_path_1.default.extname(filePath).toLowerCase();
59
- const raw = ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts"
60
- ? await loadRecipeFromTypeScript(filePath)
63
+ const raw = (0, typescript_recipe_1.isTypeScriptRecipePath)(filePath)
64
+ ? await (0, typescript_recipe_1.loadTypeScriptRecipe)(filePath)
61
65
  : await readJson(filePath);
62
66
  const result = recipe_1.RecipeSchema.safeParse(raw);
63
67
  if (!result.success) {
@@ -69,80 +73,6 @@ const loadRecipe = async (filePath) => {
69
73
  return result.data;
70
74
  };
71
75
  exports.loadRecipe = loadRecipe;
72
- /**
73
- * Lazily install tsx's CommonJS require hook *once* per process. Earlier
74
- * versions of this file called `register()` and the matching `unregister`
75
- * cleanup on every `loadRecipe` call — fine for one-shot CLI runs but
76
- * wasteful in long-running surfaces like `scai cli shell` where N recipe
77
- * loads = N register/unregister cycles. Once installed, the hook stays
78
- * for the rest of the process lifetime; recipe changes mid-shell-session
79
- * therefore require an exit (the per-recipe `require.cache` clear below
80
- * handles re-imports of the *same* file path within the same process).
81
- */
82
- let tsxRegistered = false;
83
- const ensureTsxRegistered = () => {
84
- if (tsxRegistered) {
85
- return;
86
- }
87
- /* eslint-disable @typescript-eslint/no-require-imports */
88
- const tsxApi = require("tsx/cjs/api");
89
- /* eslint-enable @typescript-eslint/no-require-imports */
90
- tsxApi.register();
91
- tsxRegistered = true;
92
- };
93
- let sandboxOptOutWarned = false;
94
- const warnSandboxDisabled = () => {
95
- if (sandboxOptOutWarned) {
96
- return;
97
- }
98
- sandboxOptOutWarned = true;
99
- process.stderr.write("scai: SITECOREAI_RECIPE_SANDBOX=0 — loading .recipe.ts in-process; " +
100
- "a hostile recipe runs with scai's full privileges.\n");
101
- };
102
- /**
103
- * Load a `.recipe.ts` and return its exported recipe object. By default the
104
- * file is loaded in a confined child process (see docs/recipe-sandbox.md) so
105
- * a hostile recipe a weaponized config could point at cannot run with scai's
106
- * privileges. `SITECOREAI_RECIPE_SANDBOX=0` forces the legacy in-process load.
107
- */
108
- const loadRecipeFromTypeScript = async (filePath) => {
109
- if ((0, load_1.isRecipeSandboxEnabled)()) {
110
- return (0, load_1.loadRecipeInSandbox)(filePath);
111
- }
112
- warnSandboxDisabled();
113
- return loadRecipeInProcess(filePath);
114
- };
115
- const loadRecipeInProcess = async (filePath) => {
116
- const absolute = node_path_1.default.resolve(filePath);
117
- try {
118
- ensureTsxRegistered();
119
- if (require.cache[absolute]) {
120
- delete require.cache[absolute];
121
- }
122
- /* eslint-disable @typescript-eslint/no-require-imports */
123
- const mod = require(absolute);
124
- /* eslint-enable @typescript-eslint/no-require-imports */
125
- if (mod.default !== undefined) {
126
- return mod.default;
127
- }
128
- // Fall back to the first non-default exported value.
129
- for (const key of Object.keys(mod)) {
130
- if (key !== "default" && mod[key] !== undefined) {
131
- return mod[key];
132
- }
133
- }
134
- throw (0, errors_1.createScaiError)(`Recipe at ${filePath} has no exports.`, "INPUT_INVALID", {
135
- hint: "Add `export default <recipe>` (or any named export) to the file.",
136
- });
137
- }
138
- catch (error) {
139
- // Don't double-wrap our own CliErrors (e.g. "Recipe at <path> has no exports.").
140
- if (error instanceof errors_1.ScaiError) {
141
- throw error;
142
- }
143
- throw (0, errors_1.createScaiError)(`Failed to load recipe TypeScript file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, "INPUT_INVALID", { hint: "Confirm the file compiles standalone (try `pnpm exec tsx <file>`)." });
144
- }
145
- };
146
76
  const writeIr = async (filePath, ir) => {
147
77
  await writeJson(filePath, ir);
148
78
  };
@@ -237,11 +237,11 @@ const shapeFromSitecoreType = (type) => {
237
237
  * `sitecore.type`), the section it lives under (`sitecore.section`),
238
238
  * `sitecore.sortOrder`, and the storage axis (`sitecore.storage`, recovered
239
239
  * from the field's `Shared` / `Unversioned` flags). The `Source` value is
240
- * preserved verbatim via `sitecore.sourceRaw` — the structured
241
- * `sourceTypes`/`sourceQuery`/`sourceScope` decomposition is intentionally
242
- * NOT reverse-engineered (it would require parsing the URL-encoded Source
243
- * and resolving GUIDs back to handles); `sourceRaw` round-trips to the
244
- * identical wire string.
240
+ * preserved verbatim via `sitecore.source = { kind: "raw", value }` —
241
+ * the structured `filter` decomposition (`types`/`query`/`scope`) is
242
+ * intentionally NOT reverse-engineered (it would require parsing the
243
+ * URL-encoded Source and resolving GUIDs back to recipe handles);
244
+ * `kind: "raw"` round-trips to the identical wire string.
245
245
  *
246
246
  * LOSSY / omitted: `required`, `hint`, `default`, `enumHandle`, and the
247
247
  * abstract `multiple` flag are not recoverable from a field item alone and
@@ -259,8 +259,9 @@ const fieldFromItem = (fieldItem, sectionName) => {
259
259
  augment.type = sitecoreType;
260
260
  const source = fieldValue(fieldItem, sitecore_templates_1.TEMPLATE_FIELD_FIELDS.SOURCE, "Source");
261
261
  if (source !== undefined && source !== "") {
262
- // Verbatim round-trip: sourceRaw re-emits the identical Source string.
263
- augment.sourceRaw = source;
262
+ // Verbatim round-trip: `source: { kind: "raw", value }` re-emits
263
+ // the identical Source string at compile time.
264
+ augment.source = { kind: "raw", value: source };
264
265
  }
265
266
  // Field storage axis — `Shared` / `Unversioned` are shared flags on the
266
267
  // field item. `versioned` is the Sitecore default; omit it rather than
@@ -473,7 +474,7 @@ const componentSectionFromItem = (folderItem) => {
473
474
  * schema requires `values.min(1)`) — such a container is skipped by the
474
475
  * orchestrator with no error.
475
476
  */
476
- const enumerationFromItem = async (containerItem, folder, client) => {
477
+ const enumerationFromItem = async (containerItem, folderSegments, client) => {
477
478
  const valueItems = (await client.getChildren({ itemId: containerItem.itemId }))
478
479
  .filter((child) => child.name !== "__Standard Values")
479
480
  .sort(byTreeOrder);
@@ -507,8 +508,9 @@ const enumerationFromItem = async (containerItem, folder, client) => {
507
508
  }
508
509
  if (description !== undefined && description !== "")
509
510
  recipe.description = description;
510
- if (folder)
511
- recipe.location = { scope: "site", folder };
511
+ if (folderSegments.length > 0) {
512
+ recipe.location = { scope: "site", folder: folderSegments };
513
+ }
512
514
  // Only carry `default` when it names a real value — the compiler rejects
513
515
  // an out-of-range default, and a stale container `Value` is not intent.
514
516
  if (defaultValue !== undefined &&
@@ -666,7 +668,11 @@ const walkEnumerationsTree = async (rootPath, client) => {
666
668
  * a container. `Enumerations Folder` items are recursed; everything else
667
669
  * with ≥1 child is treated as a container.
668
670
  */
669
- const walk = async (parent, folderPath) => {
671
+ // `folderSegments` carries the grouping-folder path as `string[]` —
672
+ // matches the canonical array shape on `EnumerationRecipe.location.folder`
673
+ // so the reverse-projected recipe emits the same wire shape authors hand
674
+ // to scai (no slash-joined fallback).
675
+ const walk = async (parent, folderSegments) => {
670
676
  const children = (await client.getChildren({ itemId: parent.itemId }))
671
677
  .filter((c) => c.name !== "__Standard Values")
672
678
  .sort(byTreeOrder);
@@ -690,17 +696,16 @@ const walkEnumerationsTree = async (rootPath, client) => {
690
696
  }
691
697
  }
692
698
  if (groupsContainers) {
693
- const nextFolder = folderPath ? `${folderPath}/${child.name}` : child.name;
694
- await walk(child, nextFolder);
699
+ await walk(child, [...folderSegments, child.name]);
695
700
  }
696
701
  else {
697
- const recipe = await enumerationFromItem(child, folderPath, client);
702
+ const recipe = await enumerationFromItem(child, folderSegments, client);
698
703
  if (recipe)
699
704
  recipes.push(recipe);
700
705
  }
701
706
  }
702
707
  };
703
- await walk(root, undefined);
708
+ await walk(root, []);
704
709
  return recipes;
705
710
  };
706
711
  /**
@@ -1022,7 +1027,7 @@ const pageFromItem = (item, templateHandle, guidIndex) => {
1022
1027
  * `PlaceholderRecipe` REQUIRES a non-empty `key`, and a key-less
1023
1028
  * Placeholder Settings item is not reverse-projectable.
1024
1029
  */
1025
- const placeholderFromItem = (item, folder, guidIndex) => {
1030
+ const placeholderFromItem = (item, folderSegments, guidIndex) => {
1026
1031
  const key = fieldValue(item, sitecore_templates_1.PLACEHOLDER_FIELDS.PLACEHOLDER_KEY, "Placeholder Key");
1027
1032
  if (key === undefined || key.trim() === "") {
1028
1033
  // No Placeholder Key — schema requires `key.min(1)`. Skip.
@@ -1056,8 +1061,8 @@ const placeholderFromItem = (item, folder, guidIndex) => {
1056
1061
  recipe.description = description;
1057
1062
  if (icon !== undefined && icon !== "")
1058
1063
  recipe.icon = icon;
1059
- if (folder)
1060
- recipe.folder = folder;
1064
+ if (folderSegments.length > 0)
1065
+ recipe.folder = folderSegments;
1061
1066
  return recipe;
1062
1067
  };
1063
1068
  /**
@@ -1171,24 +1176,26 @@ const walkPlaceholderSettingsTree = async (rootPath, client, guidIndex) => {
1171
1176
  const root = rootPath ? await client.getItem({ path: rootPath }) : null;
1172
1177
  if (!root)
1173
1178
  return recipes;
1174
- const visit = async (parent, folderPath) => {
1179
+ // `folderSegments` carries the grouping-folder path as `string[]` so
1180
+ // reverse-projected placeholder recipes emit the canonical array
1181
+ // shape that schemas/recipe.ts's `FolderPath` accepts.
1182
+ const visit = async (parent, folderSegments) => {
1175
1183
  const children = (await client.getChildren({ itemId: parent.itemId }))
1176
1184
  .filter((c) => c.name !== "__Standard Values")
1177
1185
  .sort(byTreeOrder);
1178
1186
  for (const child of children) {
1179
1187
  if (conformsTo(child, sitecore_templates_1.PLACEHOLDER_TEMPLATE_ID)) {
1180
- const recipe = placeholderFromItem(child, folderPath, guidIndex);
1188
+ const recipe = placeholderFromItem(child, folderSegments, guidIndex);
1181
1189
  if (recipe)
1182
1190
  recipes.push(recipe);
1183
1191
  continue;
1184
1192
  }
1185
1193
  // Anything that isn't a Placeholder leaf is a grouping folder —
1186
- // descend, extending the cumulative folder path.
1187
- const nextFolder = folderPath ? `${folderPath}/${child.name}` : child.name;
1188
- await visit(child, nextFolder);
1194
+ // descend, extending the cumulative segment list.
1195
+ await visit(child, [...folderSegments, child.name]);
1189
1196
  }
1190
1197
  };
1191
- await visit(root, undefined);
1198
+ await visit(root, []);
1192
1199
  return recipes;
1193
1200
  };
1194
1201
  /**