@sitecoreai-labs/sitecoreai-cli 0.1.2 → 0.2.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.
Files changed (40) 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/recipe/schema.js +4 -1
  15. package/dist/campaigns/recipe/schema.d.ts +39 -8
  16. package/dist/campaigns/recipe/schema.js +40 -10
  17. package/dist/commands/agents/sync.js +2 -2
  18. package/dist/commands/brand/seed.js +1 -1
  19. package/dist/commands/brand/sync.js +2 -2
  20. package/dist/commands/brief/sync.js +2 -2
  21. package/dist/commands/campaign/sync.js +2 -2
  22. package/dist/recipe/compile/design-parameters-template.js +5 -3
  23. package/dist/recipe/compile/enumeration.js +6 -11
  24. package/dist/recipe/compile/shared.js +12 -12
  25. package/dist/recipe/compile.js +4 -4
  26. package/dist/recipe/io.d.ts +8 -3
  27. package/dist/recipe/io.js +11 -81
  28. package/dist/recipe/items/read-current.js +31 -24
  29. package/dist/recipe/schema/recipe.d.ts +167 -84
  30. package/dist/recipe/schema/recipe.js +130 -46
  31. package/dist/recipe/schema/source-fields.d.ts +20 -0
  32. package/dist/recipe/schema/source-fields.js +25 -1
  33. package/dist/recipe/validate.d.ts +3 -3
  34. package/dist/recipe/validate.js +20 -10
  35. package/dist/sync/aggregate.js +2 -2
  36. package/dist/sync/io.d.ts +13 -3
  37. package/dist/sync/io.js +43 -17
  38. package/dist/sync/typescript-recipe.d.ts +30 -0
  39. package/dist/sync/typescript-recipe.js +112 -0
  40. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BrandKitRecipeSchema = exports.BrandDocumentSchema = exports.BrandFieldValueSchema = exports.BrandRichEntrySchema = void 0;
3
+ exports.BrandKitRecipeSchema = exports.BrandDocumentSchema = exports.BrandRegistryFileDocumentSchema = exports.BrandUrlDocumentSchema = exports.BRAND_KIT_CANONICAL_SECTIONS = exports.BrandFieldValueSchema = exports.BrandRichEntrySchema = void 0;
4
4
  /**
5
5
  * `BrandKitRecipe` — the declarative definition of a Sitecore Brand
6
6
  * brand kit.
@@ -8,9 +8,26 @@ exports.BrandKitRecipeSchema = exports.BrandDocumentSchema = exports.BrandFieldV
8
8
  * This schema is the single source of truth for the `brand-kit` recipe
9
9
  * kind: it validates recipe files, drives the `sync` CLI, and becomes
10
10
  * the MCP tool input schema. Keep the `.describe()` text accurate — the
11
- * model reads it. See docs/recipe-sync-architecture.md.
11
+ * model reads it.
12
+ *
13
+ * Two authoring shapes share this schema:
14
+ *
15
+ * - **scai-native** — flat object with `name`, `documents`, `sections`.
16
+ * No `kind` / `schemaVersion` / `handle`. Used by hand-authored
17
+ * `.brandkit.yaml` files and the `scai brand sync pull` capture
18
+ * path. Stays valid: the discriminator fields are optional here.
19
+ * - **registry-superset** — the richer shape the `@registry`
20
+ * `sitecore-recipes.ts` exports use: adds `kind: "brandkit"`,
21
+ * `schemaVersion: "1"`, `handle`, `displayName`, and a
22
+ * discriminated `documents[]` shape (`url` | `registry-file`)
23
+ * with `tags` / `sections` hints per document. The orchestrator
24
+ * passes recipes through unchanged from the registry to scai;
25
+ * scai's parser accepts the extra fields without stripping them.
26
+ *
27
+ * See docs/recipe-sync-architecture.md.
12
28
  */
13
29
  const zod_1 = require("zod");
30
+ const HANDLE_PATTERN = /^[a-z][a-z0-9-]*@\d+$/;
14
31
  /** A `richArray`-field entry: text plus optional tags and a constraint. */
15
32
  exports.BrandRichEntrySchema = zod_1.z.object({
16
33
  name: zod_1.z.string().min(1).describe("The entry's text."),
@@ -31,29 +48,141 @@ exports.BrandRichEntrySchema = zod_1.z.object({
31
48
  exports.BrandFieldValueSchema = zod_1.z
32
49
  .union([zod_1.z.string(), zod_1.z.array(zod_1.z.string()), zod_1.z.array(exports.BrandRichEntrySchema)])
33
50
  .describe("A text value, a list of names, or a list of rich entries.");
34
- /** A brand document to ingest. Sitecore fetches the URL server-side. */
35
- exports.BrandDocumentSchema = zod_1.z.object({
51
+ /**
52
+ * Canonical Sitecore AI brand-kit section names — the seven buckets
53
+ * the EnrichSections pipeline produces. Mirrors
54
+ * `BRAND_KIT_CANONICAL_SECTIONS` in the registry's recipe definitions.
55
+ * Exported so callers building recipes have a stable list to bias
56
+ * `documents[].sections` against.
57
+ */
58
+ exports.BRAND_KIT_CANONICAL_SECTIONS = [
59
+ "Brand Context",
60
+ "Global Goals",
61
+ "Tone of Voice",
62
+ "Glossary and Localization",
63
+ "Do's and Don'ts",
64
+ "Grammar Checklists",
65
+ "Visual Guidelines",
66
+ ];
67
+ /**
68
+ * Optional ingestion hints — `tags` and `sections` — shared by both
69
+ * document variants. Hoisted into a shape so the URL and registry-file
70
+ * shapes stay in lockstep without duplicating field-by-field.
71
+ */
72
+ const DocumentHints = {
73
+ title: zod_1.z.string().optional().describe('Document title. Defaults to "brand guidelines".'),
74
+ summary: zod_1.z.string().optional().describe("Short document summary."),
75
+ tags: zod_1.z
76
+ .array(zod_1.z.string())
77
+ .optional()
78
+ .describe('Free-form tags surfaced to the EnrichSections pipeline (e.g. "voice").'),
79
+ sections: zod_1.z
80
+ .array(zod_1.z.string())
81
+ .optional()
82
+ .describe('Canonical section names to bias ingestion toward (e.g. ["Tone of Voice"]). Prefer values from BRAND_KIT_CANONICAL_SECTIONS.'),
83
+ };
84
+ /**
85
+ * A brand document referenced by URL. Sitecore's Documents API fetches
86
+ * the URL server-side and copies the bytes into MMS. The default
87
+ * variant — what `scai brand sync pull` emits when capturing a live
88
+ * kit.
89
+ */
90
+ exports.BrandUrlDocumentSchema = zod_1.z.object({
91
+ kind: zod_1.z.literal("url"),
36
92
  url: zod_1.z
37
93
  .string()
38
94
  .min(1)
39
95
  .describe("Publicly reachable URL of a brand document (PDF). Sitecore fetches it server-side."),
40
- title: zod_1.z.string().optional().describe('Document title. Defaults to "brand guidelines".'),
41
- summary: zod_1.z.string().optional().describe("Short document summary."),
96
+ ...DocumentHints,
42
97
  });
98
+ /**
99
+ * A brand document stored alongside the recipe in the registry repo.
100
+ * The `path` is relative to the recipe file's directory. scai itself
101
+ * does **not** upload these — the Sitecore Documents API has no
102
+ * working bytes-upload path (see
103
+ * `src/brand/documents/upload.ts::LOCAL_UPLOAD_UNSUPPORTED_MESSAGE`),
104
+ * so the orchestrator (or whoever owns the recipe before scai sees
105
+ * it) MUST translate `registry-file` entries to `url` entries
106
+ * pointing at an HTTP-reachable host before invoking `scai brand
107
+ * sync push`. The seed runner rejects unresolved `registry-file`
108
+ * documents with a clear hint; this stays in the schema as an
109
+ * authoring-time shape so registry-side recipes round-trip cleanly.
110
+ */
111
+ exports.BrandRegistryFileDocumentSchema = zod_1.z.object({
112
+ kind: zod_1.z.literal("registry-file"),
113
+ path: zod_1.z.string().min(1).describe("Path to the document, relative to the recipe file."),
114
+ ...DocumentHints,
115
+ });
116
+ /**
117
+ * A brand document to ingest. Two variants — `{ kind: "url", url }` and
118
+ * `{ kind: "registry-file", path }` — sharing optional `title` /
119
+ * `summary` / `tags` / `sections` ingestion hints.
120
+ *
121
+ * Back-compat: scai-native recipes that pre-date the discriminator wrote
122
+ * documents as the flat `{ url, title?, summary? }` shape. The
123
+ * `z.preprocess` step defaults a missing `kind` to `"url"` whenever
124
+ * `url` is present, so legacy recipes keep parsing without a migration
125
+ * step. Zod 4's discriminated unions require the discriminator to be
126
+ * present BEFORE routing, so `.default("url")` inline on the literal
127
+ * doesn't work — preprocess is the correct seam.
128
+ */
129
+ exports.BrandDocumentSchema = zod_1.z.preprocess((raw) => {
130
+ if (raw !== null &&
131
+ typeof raw === "object" &&
132
+ !Array.isArray(raw) &&
133
+ !("kind" in raw) &&
134
+ "url" in raw) {
135
+ return { kind: "url", ...raw };
136
+ }
137
+ return raw;
138
+ }, zod_1.z.discriminatedUnion("kind", [exports.BrandUrlDocumentSchema, exports.BrandRegistryFileDocumentSchema]));
43
139
  /** The full brand-kit recipe. */
44
140
  exports.BrandKitRecipeSchema = zod_1.z.object({
141
+ /**
142
+ * Registry-superset discriminator — `"brandkit"`. Optional: scai-
143
+ * native YAML/JSON recipes pre-date this field and don't carry it.
144
+ * Defaulting in (rather than requiring) keeps the legacy parse
145
+ * path intact. Registry-authored recipes set this explicitly.
146
+ */
147
+ kind: zod_1.z.literal("brandkit").optional(),
148
+ /**
149
+ * Registry-superset schema version pin — `"1"`. Optional for the
150
+ * same back-compat reason as `kind`. When set, the registry's
151
+ * discriminated-union loader picks this recipe up.
152
+ */
153
+ schemaVersion: zod_1.z.literal("1").optional(),
154
+ /**
155
+ * Stable handle of the form `<kebab-name>@<major>` (e.g.
156
+ * `acme-brand@1`). Registry-side identifier used to wire brand
157
+ * kits to themes. Optional in scai because scai-native recipes
158
+ * identify kits by `name`, not handle.
159
+ */
160
+ handle: zod_1.z
161
+ .string()
162
+ .regex(HANDLE_PATTERN, {
163
+ message: "handle must match `<kebab-name>@<major>` (e.g. acme-brand@1)",
164
+ })
165
+ .optional()
166
+ .describe("Stable handle `<kebab-name>@<major>`. Required for registry-authored recipes."),
45
167
  name: zod_1.z
46
168
  .string()
47
169
  .min(1)
48
170
  .describe("Display name of the brand kit. Identifies the kit when pushing."),
171
+ /**
172
+ * Author-facing label, mirrors the registry's superset. When absent
173
+ * the registry's loader treats it as identical to `name`; scai's
174
+ * `apply()` likewise ignores it (the API has no separate display
175
+ * name slot beyond `name`).
176
+ */
177
+ displayName: zod_1.z.string().min(1).optional().describe("Author-facing label; defaults to `name`."),
49
178
  description: zod_1.z.string().optional().describe("Human description of the kit."),
50
179
  industry: zod_1.z.string().optional().describe('Industry label, e.g. "retail" or "developer-tools".'),
51
180
  documents: zod_1.z
52
181
  .array(exports.BrandDocumentSchema)
53
182
  .default([])
54
- .describe("Brand documents to ingest. On push, when the kit has no populated sections, each is uploaded and run through the ingestion + enrichment pipelines."),
183
+ .describe("Brand documents to ingest. On push, when the kit has no populated sections, each is uploaded and run through the ingestion + enrichment pipelines. `registry-file` variants must be translated to `url` variants before scai sees the recipe — the Sitecore Documents API has no working bytes-upload path."),
55
184
  sections: zod_1.z
56
185
  .record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), exports.BrandFieldValueSchema))
57
186
  .default({})
58
- .describe("Desired field values, keyed by section name then field name. Sections and fields are created by enrichment; push converges their values."),
187
+ .describe("Desired field values, keyed by section name then field name. Prefer canonical names from BRAND_KIT_CANONICAL_SECTIONS for the outer key. Sections and fields are created by enrichment; push converges their values."),
59
188
  });
@@ -2,6 +2,7 @@ import type { BrandApiClientOptions } from "./api/client";
2
2
  import { type BrandKitSectionSummary } from "./kits/sections";
3
3
  import { type UploadDocumentSource, type UploadedDocument } from "./documents/upload";
4
4
  import type { BrandKitSummary } from "./kits/list";
5
+ import type { BrandDocument } from "./recipe/schema";
5
6
  /**
6
7
  * Stage labels emitted via `onProgress`. Useful for callers that
7
8
  * surface progress (CLI streaming output, MCP progress events).
@@ -30,12 +31,15 @@ export interface SeedBrandKitOptions {
30
31
  /**
31
32
  * Multiple source documents — uploaded before a single ingestion +
32
33
  * enrichment pass. Takes precedence over `source` when non-empty.
34
+ * Accepts the recipe-shaped `BrandDocument` union (URL or
35
+ * registry-file). The seed runner rejects `registry-file` entries
36
+ * with `INPUT_INVALID` and a clear hint — see the
37
+ * `LOCAL_UPLOAD_UNSUPPORTED_MESSAGE` rationale in
38
+ * `documents/upload.ts` for why bytes uploads don't work end-to-end.
39
+ * Callers (orchestrator's `brandkit_deploy` handler) must translate
40
+ * registry-file paths to public URLs before invoking scai.
33
41
  */
34
- documents?: {
35
- url: string;
36
- title?: string;
37
- summary?: string;
38
- }[];
42
+ documents?: BrandDocument[];
39
43
  /** Optional kit metadata. */
40
44
  description?: string;
41
45
  industry?: string;
@@ -49,12 +49,37 @@ const seedBrandKit = async (options) => {
49
49
  const kit = await (0, create_1.createBrandKit)(createInput);
50
50
  emit({ stage: "createKit", message: `Created kit ${kit.id}` });
51
51
  // 2. UPLOAD doc(s) — one or many, before a single ingestion pass.
52
+ //
53
+ // The `documents` list is the recipe-shaped discriminated union.
54
+ // `registry-file` entries arrive unresolved (path relative to the
55
+ // recipe file) and CANNOT be uploaded as bytes — the Sitecore
56
+ // Documents API has no working local-upload path (verified
57
+ // 2026-05-15; see documents/upload.ts). The caller (orchestrator's
58
+ // `brandkit_deploy` handler) must translate them to URL entries
59
+ // first. Failing fast here — with the path in the hint — beats
60
+ // a confusing 400 from `uploadDocument` minutes later.
61
+ if (options.documents) {
62
+ for (const doc of options.documents) {
63
+ if (doc.kind === "registry-file") {
64
+ throw (0, errors_1.createScaiError)(`Brand document "${doc.path}" is a registry-file path; scai cannot upload local bytes.`, "INPUT_INVALID", {
65
+ hint: 'Host the file at an HTTPS URL Sitecore\'s edge can reach (S3, GitHub raw, a CDN) and pass it as a `{ kind: "url", url }` document. The Sitecore Documents API rejects local-file uploads — translation has to happen upstream of scai.',
66
+ });
67
+ }
68
+ }
69
+ }
52
70
  const uploadList = options.documents && options.documents.length > 0
53
- ? options.documents.map((doc) => ({
54
- source: { url: doc.url },
55
- title: doc.title,
56
- summary: doc.summary,
57
- }))
71
+ ? options.documents.map((doc) => {
72
+ // After the registry-file guard above, every doc is a URL
73
+ // variant; the narrow keeps the TS surface clean.
74
+ if (doc.kind !== "url") {
75
+ throw (0, errors_1.createScaiError)("Unreachable: non-URL document survived the registry-file guard.", "INPUT_INVALID");
76
+ }
77
+ return {
78
+ source: { url: doc.url },
79
+ title: doc.title,
80
+ summary: doc.summary,
81
+ };
82
+ })
58
83
  : options.source
59
84
  ? [
60
85
  {
@@ -69,7 +69,10 @@ exports.BudgetFieldSchema = zod_1.z
69
69
  type: zod_1.z.literal("Budget").describe("Discriminator — a budget/currency field."),
70
70
  ...briefFieldBaseShape,
71
71
  currencies: zod_1.z
72
- .array(zod_1.z.string())
72
+ .array(zod_1.z
73
+ .string()
74
+ .length(3)
75
+ .regex(/^[A-Z]{3}$/, "ISO-4217 currency code must be 3 uppercase letters (e.g. `USD`)."))
73
76
  .describe('ISO-4217 currency codes the field accepts, e.g. ["USD", "EUR"].'),
74
77
  })
75
78
  .describe("A budget/currency field.");
@@ -9,6 +9,21 @@
9
9
  * model reads it. See docs/recipe-sync-architecture.md.
10
10
  */
11
11
  import { z } from "zod";
12
+ /**
13
+ * Server enum values **confirmed by observation** in HAR captures of the
14
+ * Sitecore Content Operations UI driving the Orchestrate API. Other
15
+ * values likely exist in the server's enum — `recipe pull` from a tenant
16
+ * with a live `IN_PROGRESS` campaign must still round-trip cleanly — so
17
+ * the schema accepts the known set as a strong hint and falls back to
18
+ * `z.string()`. JSON Schema renders this as `anyOf: [{ enum: [...] }, { type:
19
+ * "string" }]`, which the model reads as "prefer one of these values; new
20
+ * uppercase enums are acceptable too".
21
+ *
22
+ * Update the lists when more values are observed; the trailing
23
+ * `z.string()` keeps the schema permissive in the meantime.
24
+ */
25
+ export declare const KNOWN_CAMPAIGN_STATUSES: readonly ["NOT_STARTED"];
26
+ export declare const KNOWN_CAMPAIGN_FUNNEL_STAGES: readonly ["TOP"];
12
27
  /**
13
28
  * A task — the leaf work item of a campaign. Owned by a deliverable.
14
29
  * Identified within its deliverable by `name`; server ids are dropped
@@ -16,7 +31,9 @@ import { z } from "zod";
16
31
  */
17
32
  export declare const CampaignTaskSchema: z.ZodObject<{
18
33
  name: z.ZodString;
19
- status: z.ZodOptional<z.ZodString>;
34
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
35
+ NOT_STARTED: "NOT_STARTED";
36
+ }>, z.ZodString]>>;
20
37
  dueDate: z.ZodOptional<z.ZodString>;
21
38
  priority: z.ZodOptional<z.ZodString>;
22
39
  description: z.ZodOptional<z.ZodString>;
@@ -29,14 +46,20 @@ export declare const CampaignTaskSchema: z.ZodObject<{
29
46
  */
30
47
  export declare const CampaignDeliverableSchema: z.ZodObject<{
31
48
  name: z.ZodString;
32
- status: z.ZodOptional<z.ZodString>;
49
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
50
+ NOT_STARTED: "NOT_STARTED";
51
+ }>, z.ZodString]>>;
33
52
  dueDate: z.ZodOptional<z.ZodString>;
34
- funnelStage: z.ZodOptional<z.ZodString>;
53
+ funnelStage: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
54
+ TOP: "TOP";
55
+ }>, z.ZodString]>>;
35
56
  funnelTactics: z.ZodDefault<z.ZodArray<z.ZodString>>;
36
57
  labels: z.ZodDefault<z.ZodArray<z.ZodString>>;
37
58
  tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
38
59
  name: z.ZodString;
39
- status: z.ZodOptional<z.ZodString>;
60
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
61
+ NOT_STARTED: "NOT_STARTED";
62
+ }>, z.ZodString]>>;
40
63
  dueDate: z.ZodOptional<z.ZodString>;
41
64
  priority: z.ZodOptional<z.ZodString>;
42
65
  description: z.ZodOptional<z.ZodString>;
@@ -48,21 +71,29 @@ export declare const CampaignDeliverableSchema: z.ZodObject<{
48
71
  export declare const CampaignRecipeSchema: z.ZodObject<{
49
72
  name: z.ZodString;
50
73
  description: z.ZodOptional<z.ZodString>;
51
- status: z.ZodOptional<z.ZodString>;
74
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
75
+ NOT_STARTED: "NOT_STARTED";
76
+ }>, z.ZodString]>>;
52
77
  startDate: z.ZodOptional<z.ZodString>;
53
78
  dueDate: z.ZodOptional<z.ZodString>;
54
79
  brandKitId: z.ZodOptional<z.ZodString>;
55
80
  labels: z.ZodDefault<z.ZodArray<z.ZodString>>;
56
81
  deliverables: z.ZodDefault<z.ZodArray<z.ZodObject<{
57
82
  name: z.ZodString;
58
- status: z.ZodOptional<z.ZodString>;
83
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
84
+ NOT_STARTED: "NOT_STARTED";
85
+ }>, z.ZodString]>>;
59
86
  dueDate: z.ZodOptional<z.ZodString>;
60
- funnelStage: z.ZodOptional<z.ZodString>;
87
+ funnelStage: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
88
+ TOP: "TOP";
89
+ }>, z.ZodString]>>;
61
90
  funnelTactics: z.ZodDefault<z.ZodArray<z.ZodString>>;
62
91
  labels: z.ZodDefault<z.ZodArray<z.ZodString>>;
63
92
  tasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
64
93
  name: z.ZodString;
65
- status: z.ZodOptional<z.ZodString>;
94
+ status: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
95
+ NOT_STARTED: "NOT_STARTED";
96
+ }>, z.ZodString]>>;
66
97
  dueDate: z.ZodOptional<z.ZodString>;
67
98
  priority: z.ZodOptional<z.ZodString>;
68
99
  description: z.ZodOptional<z.ZodString>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CampaignRecipeSchema = exports.CampaignDeliverableSchema = exports.CampaignTaskSchema = void 0;
3
+ exports.CampaignRecipeSchema = exports.CampaignDeliverableSchema = exports.CampaignTaskSchema = exports.KNOWN_CAMPAIGN_FUNNEL_STAGES = exports.KNOWN_CAMPAIGN_STATUSES = void 0;
4
4
  /**
5
5
  * `CampaignRecipe` — the declarative definition of a Sitecore
6
6
  * Orchestrate campaign (a `project`, with its nested deliverables and
@@ -12,6 +12,33 @@ exports.CampaignRecipeSchema = exports.CampaignDeliverableSchema = exports.Campa
12
12
  * model reads it. See docs/recipe-sync-architecture.md.
13
13
  */
14
14
  const zod_1 = require("zod");
15
+ /**
16
+ * ISO-8601 date-or-datetime — accepts `2026-05-26`, `2026-05-26T15:00:00Z`,
17
+ * or `2026-05-26T15:00:00.123+02:00`. Less strict than `z.string().datetime()`
18
+ * because the campaign API returns date-only values for some fields.
19
+ */
20
+ const Iso8601 = zod_1.z
21
+ .string()
22
+ .regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2}))?$/, {
23
+ message: "must be an ISO-8601 date or datetime (e.g. `2026-05-26` or `2026-05-26T15:00:00Z`)",
24
+ });
25
+ /**
26
+ * Server enum values **confirmed by observation** in HAR captures of the
27
+ * Sitecore Content Operations UI driving the Orchestrate API. Other
28
+ * values likely exist in the server's enum — `recipe pull` from a tenant
29
+ * with a live `IN_PROGRESS` campaign must still round-trip cleanly — so
30
+ * the schema accepts the known set as a strong hint and falls back to
31
+ * `z.string()`. JSON Schema renders this as `anyOf: [{ enum: [...] }, { type:
32
+ * "string" }]`, which the model reads as "prefer one of these values; new
33
+ * uppercase enums are acceptable too".
34
+ *
35
+ * Update the lists when more values are observed; the trailing
36
+ * `z.string()` keeps the schema permissive in the meantime.
37
+ */
38
+ exports.KNOWN_CAMPAIGN_STATUSES = ["NOT_STARTED"];
39
+ exports.KNOWN_CAMPAIGN_FUNNEL_STAGES = ["TOP"];
40
+ const CampaignStatusSchema = zod_1.z.union([zod_1.z.enum(exports.KNOWN_CAMPAIGN_STATUSES), zod_1.z.string()]);
41
+ const CampaignFunnelStageSchema = zod_1.z.union([zod_1.z.enum(exports.KNOWN_CAMPAIGN_FUNNEL_STAGES), zod_1.z.string()]);
15
42
  /**
16
43
  * A task — the leaf work item of a campaign. Owned by a deliverable.
17
44
  * Identified within its deliverable by `name`; server ids are dropped
@@ -19,9 +46,12 @@ const zod_1 = require("zod");
19
46
  */
20
47
  exports.CampaignTaskSchema = zod_1.z.object({
21
48
  name: zod_1.z.string().min(1).describe("Task name. Identifies the task within its deliverable."),
22
- status: zod_1.z.string().optional().describe('Task status — a server enum, e.g. "NOT_STARTED".'),
23
- dueDate: zod_1.z.string().optional().describe("Task due date (ISO-8601)."),
24
- priority: zod_1.z.string().optional().describe("Task priority — a server enum."),
49
+ status: CampaignStatusSchema.optional().describe('Task status — a server enum. Confirmed values: "NOT_STARTED". Other UPPER_SNAKE values may exist on the server; the schema accepts them but agents should prefer the confirmed set.'),
50
+ dueDate: Iso8601.optional().describe("Task due date (ISO-8601 date or datetime)."),
51
+ priority: zod_1.z
52
+ .string()
53
+ .optional()
54
+ .describe("Task priority — a server enum. No values have been observed yet; convention follows project-management norms (e.g. UPPERCASE strings). Schema is `string` until the enum set is captured."),
25
55
  description: zod_1.z.string().optional().describe("Task description. HTML."),
26
56
  assignee: zod_1.z.string().optional().describe('Assignee — an Auth0 user subject (e.g. "auth0|...").'),
27
57
  labels: zod_1.z.array(zod_1.z.string()).default([]).describe("Free-form labels on the task."),
@@ -35,9 +65,9 @@ exports.CampaignDeliverableSchema = zod_1.z.object({
35
65
  .string()
36
66
  .min(1)
37
67
  .describe("Deliverable name. Identifies the deliverable within its campaign."),
38
- status: zod_1.z.string().optional().describe('Deliverable status — a server enum, e.g. "NOT_STARTED".'),
39
- dueDate: zod_1.z.string().optional().describe("Deliverable due date (ISO-8601)."),
40
- funnelStage: zod_1.z.string().optional().describe('Funnel stage — a server enum, e.g. "TOP".'),
68
+ status: CampaignStatusSchema.optional().describe('Deliverable status — a server enum. Confirmed values: "NOT_STARTED". Other UPPER_SNAKE values may exist on the server; the schema accepts them but agents should prefer the confirmed set.'),
69
+ dueDate: Iso8601.optional().describe("Deliverable due date (ISO-8601 date or datetime)."),
70
+ funnelStage: CampaignFunnelStageSchema.optional().describe('Funnel stage — a server enum. Confirmed values: "TOP". Other values likely exist (e.g. middle / bottom of funnel); the schema accepts them but agents should prefer the confirmed set.'),
41
71
  funnelTactics: zod_1.z.array(zod_1.z.string()).default([]).describe("Funnel tactics for the deliverable."),
42
72
  labels: zod_1.z.array(zod_1.z.string()).default([]).describe("Free-form labels on the deliverable."),
43
73
  tasks: zod_1.z
@@ -52,9 +82,9 @@ exports.CampaignRecipeSchema = zod_1.z.object({
52
82
  .min(1)
53
83
  .describe("Display name of the campaign (project). Identifies the campaign when pushing."),
54
84
  description: zod_1.z.string().optional().describe("Human description of the campaign."),
55
- status: zod_1.z.string().optional().describe('Campaign status — a server enum, e.g. "NOT_STARTED".'),
56
- startDate: zod_1.z.string().optional().describe("Campaign start date (ISO-8601)."),
57
- dueDate: zod_1.z.string().optional().describe("Campaign due date (ISO-8601)."),
85
+ status: CampaignStatusSchema.optional().describe('Campaign status — a server enum. Confirmed values: "NOT_STARTED". Other UPPER_SNAKE values may exist on the server; the schema accepts them but agents should prefer the confirmed set.'),
86
+ startDate: Iso8601.optional().describe("Campaign start date (ISO-8601 date or datetime)."),
87
+ dueDate: Iso8601.optional().describe("Campaign due date (ISO-8601 date or datetime)."),
58
88
  brandKitId: zod_1.z
59
89
  .string()
60
90
  .optional()
@@ -91,7 +91,7 @@ const createDiffCommand = () => {
91
91
  const logger = (0, cli_tasks_1.toLogger)(options);
92
92
  const kind = resolveKind(options.kind);
93
93
  const ctx = buildContext(options, logger);
94
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", kind.schema);
94
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", kind.schema);
95
95
  const plan = await (0, sync_1.syncDiff)(kind, recipe, { kind: kind.name, id: recipeName(recipe) }, ctx);
96
96
  printPlan(logger, plan);
97
97
  });
@@ -110,7 +110,7 @@ const createPushCommand = () => {
110
110
  const logger = (0, cli_tasks_1.toLogger)(options);
111
111
  const kind = resolveKind(options.kind);
112
112
  const ctx = buildContext(options, logger);
113
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", kind.schema);
113
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", kind.schema);
114
114
  const mode = options.allowWrite ? "apply" : "what-if";
115
115
  const outcome = await (0, sync_1.syncPush)(kind, recipe, { kind: kind.name, id: recipeName(recipe) }, ctx, {
116
116
  mode,
@@ -70,7 +70,7 @@ const seedFromFile = async (options, logger) => {
70
70
  configPath,
71
71
  logger,
72
72
  };
73
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
73
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
74
74
  const outcome = await (0, sync_1.syncPush)(recipe_1.brandKitKind, recipe, { kind: recipe_1.brandKitKind.name, id: recipe.name }, ctx, { mode: "apply", prune: false });
75
75
  if (options.format === "json") {
76
76
  process.stdout.write(JSON.stringify({ recipe: recipe.name, plan: outcome.plan, result: outcome.result }, null, 2) +
@@ -77,7 +77,7 @@ const createDiffCommand = () => {
77
77
  command.action(async (options) => {
78
78
  const logger = (0, cli_tasks_1.toLogger)(options);
79
79
  const ctx = buildContext(options, logger);
80
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
80
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
81
81
  const plan = await (0, sync_1.syncDiff)(recipe_1.brandKitKind, recipe, { kind: recipe_1.brandKitKind.name, id: recipe.name }, ctx);
82
82
  printPlan(logger, plan);
83
83
  if (hasPaidPipeline(plan)) {
@@ -98,7 +98,7 @@ const createPushCommand = () => {
98
98
  command.action(async (options) => {
99
99
  const logger = (0, cli_tasks_1.toLogger)(options);
100
100
  const ctx = buildContext(options, logger);
101
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
101
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.brandKitKind.schema);
102
102
  const mode = options.allowWrite ? "apply" : "what-if";
103
103
  const outcome = await (0, sync_1.syncPush)(recipe_1.brandKitKind, recipe, { kind: recipe_1.brandKitKind.name, id: recipe.name }, ctx, { mode, prune: options.prune });
104
104
  printPlan(logger, outcome.plan);
@@ -76,7 +76,7 @@ const createDiffCommand = () => {
76
76
  command.action(async (options) => {
77
77
  const logger = (0, cli_tasks_1.toLogger)(options);
78
78
  const ctx = buildContext(options, logger);
79
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.briefTypeKind.schema);
79
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.briefTypeKind.schema);
80
80
  const plan = await (0, sync_1.syncDiff)(recipe_1.briefTypeKind, recipe, { kind: recipe_1.briefTypeKind.name, id: recipe.name }, ctx);
81
81
  printPlan(logger, plan);
82
82
  });
@@ -94,7 +94,7 @@ const createPushCommand = () => {
94
94
  command.action(async (options) => {
95
95
  const logger = (0, cli_tasks_1.toLogger)(options);
96
96
  const ctx = buildContext(options, logger);
97
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.briefTypeKind.schema);
97
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.briefTypeKind.schema);
98
98
  const mode = options.allowWrite ? "apply" : "what-if";
99
99
  const outcome = await (0, sync_1.syncPush)(recipe_1.briefTypeKind, recipe, { kind: recipe_1.briefTypeKind.name, id: recipe.name }, ctx, { mode, prune: options.prune });
100
100
  printPlan(logger, outcome.plan);
@@ -76,7 +76,7 @@ const createDiffCommand = () => {
76
76
  command.action(async (options) => {
77
77
  const logger = (0, cli_tasks_1.toLogger)(options);
78
78
  const ctx = buildContext(options, logger);
79
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.campaignKind.schema);
79
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.campaignKind.schema);
80
80
  const plan = await (0, sync_1.syncDiff)(recipe_1.campaignKind, recipe, { kind: recipe_1.campaignKind.name, id: recipe.name }, ctx);
81
81
  printPlan(logger, plan);
82
82
  });
@@ -94,7 +94,7 @@ const createPushCommand = () => {
94
94
  command.action(async (options) => {
95
95
  const logger = (0, cli_tasks_1.toLogger)(options);
96
96
  const ctx = buildContext(options, logger);
97
- const recipe = (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.campaignKind.schema);
97
+ const recipe = await (0, sync_1.loadRecipe)(options.file ?? "", recipe_1.campaignKind.schema);
98
98
  const mode = options.allowWrite ? "apply" : "what-if";
99
99
  const outcome = await (0, sync_1.syncPush)(recipe_1.campaignKind, recipe, { kind: recipe_1.campaignKind.name, id: recipe.name }, ctx, { mode, prune: options.prune });
100
100
  printPlan(logger, outcome.plan);
@@ -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 = "";