@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
@@ -38,7 +38,7 @@ const runAgentGet = async (options) => {
38
38
  exports.runAgentGet = runAgentGet;
39
39
  const runAgentCreate = async (options) => {
40
40
  const { logger, session } = await (0, shared_1.prepare)(options);
41
- const recipe = (0, sync_1.loadRecipe)(options.file, agent_schema_1.AgentRecipeSchema);
41
+ const recipe = await (0, sync_1.loadRecipe)(options.file, agent_schema_1.AgentRecipeSchema);
42
42
  if (findByName(await (0, agents_1.listAgents)(session), recipe.name)) {
43
43
  throw (0, errors_1.createScaiError)(`Agent "${recipe.name}" already exists.`, "INPUT_INVALID", {
44
44
  hint: "Use `scai agents agent update` to change it, or pick a different name.",
@@ -67,7 +67,7 @@ const runAgentCreate = async (options) => {
67
67
  exports.runAgentCreate = runAgentCreate;
68
68
  const runAgentUpdate = async (options) => {
69
69
  const { logger, session } = await (0, shared_1.prepare)(options);
70
- const recipe = (0, sync_1.loadRecipe)(options.file, agent_schema_1.AgentRecipeSchema);
70
+ const recipe = await (0, sync_1.loadRecipe)(options.file, agent_schema_1.AgentRecipeSchema);
71
71
  const existing = await (0, agents_1.getAgent)(session, options.idOrSlug);
72
72
  if (!existing) {
73
73
  throw (0, errors_1.createScaiError)(`Agent "${options.idOrSlug}" not found.`, "INPUT_INVALID", {
@@ -32,7 +32,7 @@ const makeResourceTasks = (config) => {
32
32
  };
33
33
  const runCreate = async (options) => {
34
34
  const { logger, session } = await (0, shared_1.prepare)(options);
35
- const recipe = (0, sync_1.loadRecipe)(options.file, config.schema);
35
+ const recipe = await (0, sync_1.loadRecipe)(options.file, config.schema);
36
36
  if (await config.find(session, recipe.name)) {
37
37
  throw (0, errors_1.createScaiError)(`A ${config.resource} named "${recipe.name}" already exists.`, "INPUT_INVALID", { hint: `Pick a different name, or use \`scai agents ${config.resource} update\`.` });
38
38
  }
@@ -54,7 +54,7 @@ const makeResourceTasks = (config) => {
54
54
  (0, shared_1.requireUnverified)(options.unverified, config.resource, "update");
55
55
  }
56
56
  const { logger, session } = await (0, shared_1.prepare)(options);
57
- const recipe = (0, sync_1.loadRecipe)(options.file, config.schema);
57
+ const recipe = await (0, sync_1.loadRecipe)(options.file, config.schema);
58
58
  let id;
59
59
  if (config.resolveById) {
60
60
  id = options.idOrName;
@@ -35,7 +35,7 @@ const runSpaceArtifacts = async (options) => {
35
35
  exports.runSpaceArtifacts = runSpaceArtifacts;
36
36
  const runSpaceUpdate = async (options) => {
37
37
  const { logger, session } = await (0, shared_1.prepare)(options);
38
- const patch = (0, sync_1.loadRecipe)(options.file, SpaceConfigPatchSchema);
38
+ const patch = await (0, sync_1.loadRecipe)(options.file, SpaceConfigPatchSchema);
39
39
  const current = await (0, spaces_1.getSpaceConfig)(session, options.spaceId);
40
40
  const merged = { ...current, ...patch };
41
41
  if (options.whatIf) {
@@ -32,7 +32,12 @@ import type { BrandCredential } from "../../config/types";
32
32
  export declare const BRAND_REQUIRED_SCOPES: readonly ["ai.org.br:gen"];
33
33
  export interface AcquireBrandTokenOptions {
34
34
  orgId: string;
35
- credential: BrandCredential;
35
+ /**
36
+ * The `brand[orgId]` config block, if present. Optional: serverless
37
+ * callers (showcase orchestrator) may have no config file at all and
38
+ * supply credentials via the `SITECOREAI_BRAND_*` env vars instead.
39
+ */
40
+ credential?: BrandCredential;
36
41
  }
37
42
  export declare const extractScopes: (token: string) => string[];
38
43
  export declare const hasBrandScopes: (token: string) => boolean;
@@ -45,14 +50,16 @@ export declare const hasBrandScopes: (token: string) => boolean;
45
50
  * by a previous mint via this function. Reused while it still
46
51
  * carries the required scopes; cleared on next 401 by callers.
47
52
  * 2. Fresh M2M mint against the `auth.sitecorecloud.io/oauth/token`
48
- * endpoint with `audience=https://api.sitecorecloud.io`, using
49
- * the org-scoped `clientId` from `brand[orgId]` and the
50
- * matching secret from the keychain. Cached on success.
53
+ * endpoint with `audience=https://api.sitecorecloud.io`. The
54
+ * `clientId` + secret + authority + audience are resolved by
55
+ * `resolveBrandSecrets`, which walks: (a) the
56
+ * `SITECOREAI_BRAND_*` env vars (serverless override), then
57
+ * (b) the `brand[orgId]` config block + OS keychain. Successful
58
+ * mints are cached.
51
59
  *
52
- * Refuses with `AUTH_BRAND_REQUIRED` when none of these paths
53
- * produces a token carrying the required scopes. The error message
54
- * decodes the granted-scope set and infers the credential class so
55
- * operators know whether they need to provision a new AI APIs key or
56
- * just re-login.
60
+ * Refuses with `AUTH_BRAND_REQUIRED` when neither tier supplies a
61
+ * credential. The error message points operators at both the OS
62
+ * keychain login flow AND the serverless env-var path so the right
63
+ * fix is one re-read away.
57
64
  */
58
65
  export declare const acquireBrandToken: (options: AcquireBrandTokenOptions) => Promise<string>;
@@ -4,6 +4,7 @@ exports.acquireBrandToken = exports.hasBrandScopes = exports.extractScopes = exp
4
4
  const auth_1 = require("../../serialization/api/auth");
5
5
  const errors_1 = require("../../shared/errors");
6
6
  const keychain_1 = require("../../shared/keychain");
7
+ const credential_1 = require("../credential");
7
8
  /**
8
9
  * OAuth scopes scai's *currently shipped* Brand operations require.
9
10
  *
@@ -35,7 +36,7 @@ const keychain_1 = require("../../shared/keychain");
35
36
  * if their specific scope isn't present.
36
37
  */
37
38
  exports.BRAND_REQUIRED_SCOPES = ["ai.org.br:gen"];
38
- const NO_CREDENTIAL_HINT = "Run `scai setup login brand --env <env>` to provision the credential, or paste an existing AI APIs key into `brand.<orgId>` in sitecoreai.cli.json (clientId only; secret goes through the keychain via the login flow). Create the credential in Cloud Portal → Stream → Admin → AI APIs keys.";
39
+ const NO_CREDENTIAL_HINT = "Run `scai setup login brand --env <env>` to provision the credential, or paste an existing AI APIs key into `brand.<orgId>` in sitecoreai.cli.json (clientId only; secret goes through the keychain via the login flow). Create the credential in Cloud Portal → Stream → Admin → AI APIs keys. In serverless contexts (Vercel functions, CI runners) without an OS keychain, set the SITECOREAI_BRAND_CLIENT_ID + SITECOREAI_BRAND_CLIENT_SECRET env vars instead — they take precedence over the config+keychain pair.";
39
40
  const decodeJwtPayload = (token) => {
40
41
  const parts = token.split(".");
41
42
  if (parts.length !== 3) {
@@ -67,7 +68,6 @@ const hasBrandScopes = (token) => {
67
68
  return exports.BRAND_REQUIRED_SCOPES.every((s) => granted.has(s));
68
69
  };
69
70
  exports.hasBrandScopes = hasBrandScopes;
70
- const DEFAULT_AUTHORITY = "https://auth.sitecorecloud.io";
71
71
  /**
72
72
  * Returns a Bearer JWT for the Sitecore Brand APIs.
73
73
  *
@@ -77,15 +77,17 @@ const DEFAULT_AUTHORITY = "https://auth.sitecorecloud.io";
77
77
  * by a previous mint via this function. Reused while it still
78
78
  * carries the required scopes; cleared on next 401 by callers.
79
79
  * 2. Fresh M2M mint against the `auth.sitecorecloud.io/oauth/token`
80
- * endpoint with `audience=https://api.sitecorecloud.io`, using
81
- * the org-scoped `clientId` from `brand[orgId]` and the
82
- * matching secret from the keychain. Cached on success.
80
+ * endpoint with `audience=https://api.sitecorecloud.io`. The
81
+ * `clientId` + secret + authority + audience are resolved by
82
+ * `resolveBrandSecrets`, which walks: (a) the
83
+ * `SITECOREAI_BRAND_*` env vars (serverless override), then
84
+ * (b) the `brand[orgId]` config block + OS keychain. Successful
85
+ * mints are cached.
83
86
  *
84
- * Refuses with `AUTH_BRAND_REQUIRED` when none of these paths
85
- * produces a token carrying the required scopes. The error message
86
- * decodes the granted-scope set and infers the credential class so
87
- * operators know whether they need to provision a new AI APIs key or
88
- * just re-login.
87
+ * Refuses with `AUTH_BRAND_REQUIRED` when neither tier supplies a
88
+ * credential. The error message points operators at both the OS
89
+ * keychain login flow AND the serverless env-var path so the right
90
+ * fix is one re-read away.
89
91
  */
90
92
  const acquireBrandToken = async (options) => {
91
93
  const { orgId, credential } = options;
@@ -93,25 +95,33 @@ const acquireBrandToken = async (options) => {
93
95
  // server-side scope enforcement happens on the API call itself,
94
96
  // and gating here would skip a perfectly usable token whenever
95
97
  // the operation it's used for doesn't need the validated scope.
98
+ //
99
+ // NB: in a serverless context (no keychain) the cache lookup
100
+ // silently returns undefined via the keychain wrapper's
101
+ // fail-closed behaviour, so we drop straight to the mint path —
102
+ // every invocation re-mints, which is correct: there is no
103
+ // persistent process to hold a cached token across invocations.
96
104
  const cached = await (0, keychain_1.getBrandToken)(orgId);
97
105
  if (cached) {
98
106
  return cached;
99
107
  }
100
- // 2. Fresh M2M mint via the AI APIs key.
101
- const clientSecret = await (0, keychain_1.getBrandClientSecret)(orgId);
102
- if (!credential.clientId || !clientSecret) {
108
+ // 2. Fresh M2M mint via the AI APIs key. `resolveBrandSecrets`
109
+ // picks env-var vs config+keychain; partial-env states throw
110
+ // inside the resolver (so the operator sees the malformed-env
111
+ // hint, not a generic missing-credential one), an empty result
112
+ // here means neither tier had anything to offer.
113
+ const secrets = await (0, credential_1.resolveBrandSecrets)({ orgId, credential });
114
+ if (!secrets) {
103
115
  throw (0, errors_1.createScaiError)(`No Brand credential is configured for org '${orgId}'.`, "AUTH_BRAND_REQUIRED", { hint: NO_CREDENTIAL_HINT });
104
116
  }
105
- const authority = credential.authority ?? DEFAULT_AUTHORITY;
106
- const audience = credential.audience ?? auth_1.DEFAULT_SITECORE_API_AUDIENCE;
107
117
  // Reuse the shared client-credentials helper. It accepts a
108
118
  // `SitecoreApiClientOptions`-shaped argument; we only need the
109
119
  // auth-relevant fields.
110
120
  const mintEnv = {
111
- authority,
112
- clientId: credential.clientId,
113
- clientSecret,
114
- audience,
121
+ authority: secrets.authority,
122
+ clientId: secrets.clientId,
123
+ clientSecret: secrets.clientSecret,
124
+ audience: secrets.audience,
115
125
  };
116
126
  let result;
117
127
  try {
@@ -2,11 +2,16 @@
2
2
  * Brand credential resolution shared by every `scai brand` surface and
3
3
  * by `scai setup login brand`.
4
4
  *
5
- * Two layers:
5
+ * Three layers:
6
6
  * - `resolveBrandOrgId` — the pure orgId-resolution rule.
7
7
  * - `resolveBrandClient` — reads the config, resolves the orgId, looks
8
8
  * up the `brand[orgId]` credential, and returns the API client
9
9
  * options a brand operation needs.
10
+ * - `resolveBrandSecrets` — pulls the `{ clientId, clientSecret,
11
+ * authority, audience }` quartet a token mint needs. Env-var
12
+ * overrides take precedence over the config-and-keychain pair so
13
+ * serverless callers (the showcase orchestrator) can drive brand
14
+ * ops without an OS keychain.
10
15
  *
11
16
  * (`src/brand/recipe/client.ts` has a separate `resolveBrandClient` that
12
17
  * resolves from a sync `SyncContext` rather than CLI options — the sync
@@ -14,6 +19,7 @@
14
19
  * the fallbacks here.)
15
20
  */
16
21
  import type { BrandApiClientOptions } from "./api/client";
22
+ import type { BrandCredential } from "../config/types";
17
23
  /**
18
24
  * Resolve the Sitecore `organizationId` for a Brand credential.
19
25
  * Resolution order:
@@ -53,3 +59,67 @@ export interface BrandClientResolveOptions {
53
59
  * first.
54
60
  */
55
61
  export declare const resolveBrandClient: (options: BrandClientResolveOptions) => BrandApiClientOptions;
62
+ /**
63
+ * The `{ clientId, clientSecret, authority, audience }` a Brand-API
64
+ * client-credentials mint needs, plus the tier that supplied each
65
+ * field for diagnostics.
66
+ */
67
+ export interface ResolvedBrandSecrets {
68
+ clientId: string;
69
+ clientSecret: string;
70
+ authority: string;
71
+ audience: string;
72
+ /**
73
+ * Which tier the **secret** came from. `"env"` means the
74
+ * orchestrator-style override; `"keychain"` is the developer-laptop
75
+ * default. The `clientId` / `authority` / `audience` may still come
76
+ * from the config when the secret is from the env (one-shot override
77
+ * of just the secret is supported and useful for short-lived CI
78
+ * secrets paired with a config-pinned client).
79
+ */
80
+ source: "env" | "keychain";
81
+ }
82
+ export interface ResolveBrandSecretsOptions {
83
+ /** Sitecore organization id — keys the `getBrandClientSecret` slot. */
84
+ orgId: string;
85
+ /** `brand[orgId]` block from the root config — may be absent in serverless. */
86
+ credential?: BrandCredential;
87
+ }
88
+ /**
89
+ * Resolve the secrets a Brand-API M2M mint needs, walking two tiers:
90
+ *
91
+ * 1. **Environment variables** (`SITECOREAI_BRAND_CLIENT_ID` +
92
+ * `SITECOREAI_BRAND_CLIENT_SECRET`). Both must be present for this
93
+ * tier to apply. `SITECOREAI_BRAND_AUTHORITY` and
94
+ * `SITECOREAI_BRAND_AUDIENCE` are optional overrides that compose
95
+ * with either tier.
96
+ * 2. **Config + OS keychain** — the developer-laptop default: client
97
+ * id from the `brand[orgId]` config block, secret from the keychain
98
+ * (`getBrandClientSecret`).
99
+ *
100
+ * **Precedence:** env wins. Rationale — the env-var path is the
101
+ * explicit, per-invocation override a serverless host (Vercel function,
102
+ * CI runner) sets right before calling scai; treating it as the
103
+ * higher-priority lookup means a host can swap credentials between
104
+ * invocations without touching disk or the keychain. The keychain path
105
+ * is the long-lived default for human developers.
106
+ *
107
+ * Error contract — `AUTH_BRAND_REQUIRED` in three flavors so operators
108
+ * know which knob to turn:
109
+ *
110
+ * - **Partial env**: one of `SITECOREAI_BRAND_CLIENT_ID` /
111
+ * `SITECOREAI_BRAND_CLIENT_SECRET` is set but the other isn't.
112
+ * We treat that as malformed (operator intended env-tier but
113
+ * missed a var) rather than silently falling through to the
114
+ * keychain — silent fallthrough would mask a typo and hand the
115
+ * wrong credential to the API.
116
+ * - **No credential anywhere**: neither env nor keychain has the
117
+ * secret, and the config has no `clientId` for the org.
118
+ * - **Keychain only, no secret**: the config has a `clientId` but
119
+ * the keychain lookup came back empty (re-login needed).
120
+ *
121
+ * Returns `undefined` only as a sentinel for the "no credential
122
+ * anywhere" case so the caller can build the right hint with the
123
+ * orgId in scope. Other malformed states throw immediately.
124
+ */
125
+ export declare const resolveBrandSecrets: (options: ResolveBrandSecretsOptions) => Promise<ResolvedBrandSecrets | undefined>;
@@ -3,11 +3,16 @@
3
3
  * Brand credential resolution shared by every `scai brand` surface and
4
4
  * by `scai setup login brand`.
5
5
  *
6
- * Two layers:
6
+ * Three layers:
7
7
  * - `resolveBrandOrgId` — the pure orgId-resolution rule.
8
8
  * - `resolveBrandClient` — reads the config, resolves the orgId, looks
9
9
  * up the `brand[orgId]` credential, and returns the API client
10
10
  * options a brand operation needs.
11
+ * - `resolveBrandSecrets` — pulls the `{ clientId, clientSecret,
12
+ * authority, audience }` quartet a token mint needs. Env-var
13
+ * overrides take precedence over the config-and-keychain pair so
14
+ * serverless callers (the showcase orchestrator) can drive brand
15
+ * ops without an OS keychain.
11
16
  *
12
17
  * (`src/brand/recipe/client.ts` has a separate `resolveBrandClient` that
13
18
  * resolves from a sync `SyncContext` rather than CLI options — the sync
@@ -15,10 +20,11 @@
15
20
  * the fallbacks here.)
16
21
  */
17
22
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.resolveBrandClient = exports.resolveBrandOrgId = void 0;
23
+ exports.resolveBrandSecrets = exports.resolveBrandClient = exports.resolveBrandOrgId = void 0;
19
24
  const root_config_1 = require("../config/root-config");
20
25
  const errors_1 = require("../shared/errors");
21
26
  const cli_tasks_1 = require("../shared/cli-tasks");
27
+ const keychain_1 = require("../shared/keychain");
22
28
  /**
23
29
  * Resolve the Sitecore `organizationId` for a Brand credential.
24
30
  * Resolution order:
@@ -85,3 +91,114 @@ const resolveBrandClient = (options) => {
85
91
  return { orgId, credential };
86
92
  };
87
93
  exports.resolveBrandClient = resolveBrandClient;
94
+ /**
95
+ * Default Auth0 authority for Brand credentials. Kept aligned with the
96
+ * one `acquireBrandToken` previously hard-coded so callers that drop in
97
+ * `resolveBrandSecrets` see no behavioural change.
98
+ */
99
+ const DEFAULT_BRAND_AUTHORITY = "https://auth.sitecorecloud.io";
100
+ const DEFAULT_BRAND_AUDIENCE = "https://api.sitecorecloud.io";
101
+ /**
102
+ * Env-var names the serverless fallback honors. The serverless caller —
103
+ * today the showcase orchestrator's `brandkit_deploy` handler — sets
104
+ * these per invocation: there is no shared OS keychain in a Vercel
105
+ * function, so the credential MUST come from the environment.
106
+ *
107
+ * Generic names (no per-org / per-env normalization) on purpose: a
108
+ * single function invocation is always scoped to one org, and the host
109
+ * sets the env vars right before calling scai. If a future caller needs
110
+ * multi-org concurrency in one process, this will need a key-normalized
111
+ * variant — but that day hasn't come.
112
+ */
113
+ const BRAND_ENV_CLIENT_ID = "SITECOREAI_BRAND_CLIENT_ID";
114
+ const BRAND_ENV_CLIENT_SECRET = "SITECOREAI_BRAND_CLIENT_SECRET";
115
+ const BRAND_ENV_AUTHORITY = "SITECOREAI_BRAND_AUTHORITY";
116
+ const BRAND_ENV_AUDIENCE = "SITECOREAI_BRAND_AUDIENCE";
117
+ const trimmedEnv = (key) => {
118
+ const raw = process.env[key];
119
+ if (typeof raw !== "string")
120
+ return undefined;
121
+ const trimmed = raw.trim();
122
+ return trimmed.length > 0 ? trimmed : undefined;
123
+ };
124
+ /**
125
+ * Resolve the secrets a Brand-API M2M mint needs, walking two tiers:
126
+ *
127
+ * 1. **Environment variables** (`SITECOREAI_BRAND_CLIENT_ID` +
128
+ * `SITECOREAI_BRAND_CLIENT_SECRET`). Both must be present for this
129
+ * tier to apply. `SITECOREAI_BRAND_AUTHORITY` and
130
+ * `SITECOREAI_BRAND_AUDIENCE` are optional overrides that compose
131
+ * with either tier.
132
+ * 2. **Config + OS keychain** — the developer-laptop default: client
133
+ * id from the `brand[orgId]` config block, secret from the keychain
134
+ * (`getBrandClientSecret`).
135
+ *
136
+ * **Precedence:** env wins. Rationale — the env-var path is the
137
+ * explicit, per-invocation override a serverless host (Vercel function,
138
+ * CI runner) sets right before calling scai; treating it as the
139
+ * higher-priority lookup means a host can swap credentials between
140
+ * invocations without touching disk or the keychain. The keychain path
141
+ * is the long-lived default for human developers.
142
+ *
143
+ * Error contract — `AUTH_BRAND_REQUIRED` in three flavors so operators
144
+ * know which knob to turn:
145
+ *
146
+ * - **Partial env**: one of `SITECOREAI_BRAND_CLIENT_ID` /
147
+ * `SITECOREAI_BRAND_CLIENT_SECRET` is set but the other isn't.
148
+ * We treat that as malformed (operator intended env-tier but
149
+ * missed a var) rather than silently falling through to the
150
+ * keychain — silent fallthrough would mask a typo and hand the
151
+ * wrong credential to the API.
152
+ * - **No credential anywhere**: neither env nor keychain has the
153
+ * secret, and the config has no `clientId` for the org.
154
+ * - **Keychain only, no secret**: the config has a `clientId` but
155
+ * the keychain lookup came back empty (re-login needed).
156
+ *
157
+ * Returns `undefined` only as a sentinel for the "no credential
158
+ * anywhere" case so the caller can build the right hint with the
159
+ * orgId in scope. Other malformed states throw immediately.
160
+ */
161
+ const resolveBrandSecrets = async (options) => {
162
+ const envClientId = trimmedEnv(BRAND_ENV_CLIENT_ID);
163
+ const envClientSecret = trimmedEnv(BRAND_ENV_CLIENT_SECRET);
164
+ const envAuthority = trimmedEnv(BRAND_ENV_AUTHORITY);
165
+ const envAudience = trimmedEnv(BRAND_ENV_AUDIENCE);
166
+ // Tier 1: env vars. Require BOTH the id and the secret — a partial
167
+ // pair is almost always a misconfiguration (forgot to set the other
168
+ // half), and silently falling through to a different credential
169
+ // tier would surface as a confusing 401 from Sitecore rather than
170
+ // the real "your env is malformed" message.
171
+ if (envClientId && envClientSecret) {
172
+ return {
173
+ clientId: envClientId,
174
+ clientSecret: envClientSecret,
175
+ authority: envAuthority ?? options.credential?.authority ?? DEFAULT_BRAND_AUTHORITY,
176
+ audience: envAudience ?? options.credential?.audience ?? DEFAULT_BRAND_AUDIENCE,
177
+ source: "env",
178
+ };
179
+ }
180
+ if (envClientId || envClientSecret) {
181
+ const missing = envClientId ? BRAND_ENV_CLIENT_SECRET : BRAND_ENV_CLIENT_ID;
182
+ throw (0, errors_1.createScaiError)(`Brand credential env vars are partially set: ${missing} is missing.`, "AUTH_BRAND_REQUIRED", {
183
+ hint: `Set both ${BRAND_ENV_CLIENT_ID} and ${BRAND_ENV_CLIENT_SECRET}, or unset both to fall back to the OS keychain.`,
184
+ });
185
+ }
186
+ // Tier 2: config + OS keychain. The config supplies the non-secret
187
+ // metadata (clientId / authority / audience); the keychain supplies
188
+ // the secret.
189
+ if (!options.credential?.clientId) {
190
+ return undefined;
191
+ }
192
+ const keychainSecret = await (0, keychain_1.getBrandClientSecret)(options.orgId);
193
+ if (!keychainSecret) {
194
+ return undefined;
195
+ }
196
+ return {
197
+ clientId: options.credential.clientId,
198
+ clientSecret: keychainSecret,
199
+ authority: envAuthority ?? options.credential.authority ?? DEFAULT_BRAND_AUTHORITY,
200
+ audience: envAudience ?? options.credential.audience ?? DEFAULT_BRAND_AUDIENCE,
201
+ source: "keychain",
202
+ };
203
+ };
204
+ exports.resolveBrandSecrets = resolveBrandSecrets;
@@ -31,11 +31,16 @@ const diffBrandKit = (desired, current) => {
31
31
  meta: { stage: "kit", description: desired.description, industry: desired.industry },
32
32
  });
33
33
  desired.documents.forEach((document, index) => {
34
+ // The discriminated union has two shapes — `url` carries a URL,
35
+ // `registry-file` carries a `path`. The diff describes the
36
+ // change in human terms only; the apply step is where the
37
+ // registry-file → URL contract is enforced.
38
+ const label = document.kind === "url" ? document.url : `registry-file:${document.path}`;
34
39
  changes.push({
35
40
  kind: "create",
36
41
  path: `documents[${index}]`,
37
- summary: `Upload + ingest ${document.url}`,
38
- after: document.url,
42
+ summary: `Upload + ingest ${label}`,
43
+ after: label,
39
44
  meta: { stage: "document", document },
40
45
  });
41
46
  });
Binary file
@@ -5,7 +5,23 @@
5
5
  * This schema is the single source of truth for the `brand-kit` recipe
6
6
  * kind: it validates recipe files, drives the `sync` CLI, and becomes
7
7
  * the MCP tool input schema. Keep the `.describe()` text accurate — the
8
- * model reads it. See docs/recipe-sync-architecture.md.
8
+ * model reads it.
9
+ *
10
+ * Two authoring shapes share this schema:
11
+ *
12
+ * - **scai-native** — flat object with `name`, `documents`, `sections`.
13
+ * No `kind` / `schemaVersion` / `handle`. Used by hand-authored
14
+ * `.brandkit.yaml` files and the `scai brand sync pull` capture
15
+ * path. Stays valid: the discriminator fields are optional here.
16
+ * - **registry-superset** — the richer shape the `@registry`
17
+ * `sitecore-recipes.ts` exports use: adds `kind: "brandkit"`,
18
+ * `schemaVersion: "1"`, `handle`, `displayName`, and a
19
+ * discriminated `documents[]` shape (`url` | `registry-file`)
20
+ * with `tags` / `sections` hints per document. The orchestrator
21
+ * passes recipes through unchanged from the registry to scai;
22
+ * scai's parser accepts the extra fields without stripping them.
23
+ *
24
+ * See docs/recipe-sync-architecture.md.
9
25
  */
10
26
  import { z } from "zod";
11
27
  /** A `richArray`-field entry: text plus optional tags and a constraint. */
@@ -24,31 +40,121 @@ export declare const BrandFieldValueSchema: z.ZodUnion<readonly [z.ZodString, z.
24
40
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
41
  restrictions: z.ZodOptional<z.ZodString>;
26
42
  }, z.core.$strip>>]>;
27
- /** A brand document to ingest. Sitecore fetches the URL server-side. */
28
- export declare const BrandDocumentSchema: z.ZodObject<{
43
+ /**
44
+ * Canonical Sitecore AI brand-kit section names — the seven buckets
45
+ * the EnrichSections pipeline produces. Mirrors
46
+ * `BRAND_KIT_CANONICAL_SECTIONS` in the registry's recipe definitions.
47
+ * Exported so callers building recipes have a stable list to bias
48
+ * `documents[].sections` against.
49
+ */
50
+ export declare const BRAND_KIT_CANONICAL_SECTIONS: readonly ["Brand Context", "Global Goals", "Tone of Voice", "Glossary and Localization", "Do's and Don'ts", "Grammar Checklists", "Visual Guidelines"];
51
+ export type BrandKitCanonicalSection = (typeof BRAND_KIT_CANONICAL_SECTIONS)[number];
52
+ /**
53
+ * A brand document referenced by URL. Sitecore's Documents API fetches
54
+ * the URL server-side and copies the bytes into MMS. The default
55
+ * variant — what `scai brand sync pull` emits when capturing a live
56
+ * kit.
57
+ */
58
+ export declare const BrandUrlDocumentSchema: z.ZodObject<{
59
+ title: z.ZodOptional<z.ZodString>;
60
+ summary: z.ZodOptional<z.ZodString>;
61
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
62
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ kind: z.ZodLiteral<"url">;
29
64
  url: z.ZodString;
65
+ }, z.core.$strip>;
66
+ /**
67
+ * A brand document stored alongside the recipe in the registry repo.
68
+ * The `path` is relative to the recipe file's directory. scai itself
69
+ * does **not** upload these — the Sitecore Documents API has no
70
+ * working bytes-upload path (see
71
+ * `src/brand/documents/upload.ts::LOCAL_UPLOAD_UNSUPPORTED_MESSAGE`),
72
+ * so the orchestrator (or whoever owns the recipe before scai sees
73
+ * it) MUST translate `registry-file` entries to `url` entries
74
+ * pointing at an HTTP-reachable host before invoking `scai brand
75
+ * sync push`. The seed runner rejects unresolved `registry-file`
76
+ * documents with a clear hint; this stays in the schema as an
77
+ * authoring-time shape so registry-side recipes round-trip cleanly.
78
+ */
79
+ export declare const BrandRegistryFileDocumentSchema: z.ZodObject<{
30
80
  title: z.ZodOptional<z.ZodString>;
31
81
  summary: z.ZodOptional<z.ZodString>;
82
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
83
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
84
+ kind: z.ZodLiteral<"registry-file">;
85
+ path: z.ZodString;
32
86
  }, z.core.$strip>;
87
+ /**
88
+ * A brand document to ingest. Two variants — `{ kind: "url", url }` and
89
+ * `{ kind: "registry-file", path }` — sharing optional `title` /
90
+ * `summary` / `tags` / `sections` ingestion hints.
91
+ *
92
+ * Back-compat: scai-native recipes that pre-date the discriminator wrote
93
+ * documents as the flat `{ url, title?, summary? }` shape. The
94
+ * `z.preprocess` step defaults a missing `kind` to `"url"` whenever
95
+ * `url` is present, so legacy recipes keep parsing without a migration
96
+ * step. Zod 4's discriminated unions require the discriminator to be
97
+ * present BEFORE routing, so `.default("url")` inline on the literal
98
+ * doesn't work — preprocess is the correct seam.
99
+ */
100
+ export declare const BrandDocumentSchema: z.ZodPreprocess<z.ZodDiscriminatedUnion<[z.ZodObject<{
101
+ title: z.ZodOptional<z.ZodString>;
102
+ summary: z.ZodOptional<z.ZodString>;
103
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
104
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
105
+ kind: z.ZodLiteral<"url">;
106
+ url: z.ZodString;
107
+ }, z.core.$strip>, z.ZodObject<{
108
+ title: z.ZodOptional<z.ZodString>;
109
+ summary: z.ZodOptional<z.ZodString>;
110
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
+ kind: z.ZodLiteral<"registry-file">;
113
+ path: z.ZodString;
114
+ }, z.core.$strip>], "kind">>;
33
115
  /** The full brand-kit recipe. */
34
116
  export declare const BrandKitRecipeSchema: z.ZodObject<{
117
+ kind: z.ZodOptional<z.ZodLiteral<"brandkit">>;
118
+ schemaVersion: z.ZodOptional<z.ZodLiteral<"1">>;
119
+ handle: z.ZodOptional<z.ZodString>;
35
120
  name: z.ZodString;
121
+ displayName: z.ZodOptional<z.ZodString>;
36
122
  description: z.ZodOptional<z.ZodString>;
37
123
  industry: z.ZodOptional<z.ZodString>;
38
- documents: z.ZodDefault<z.ZodArray<z.ZodObject<{
124
+ documents: z.ZodDefault<z.ZodArray<z.ZodPreprocess<z.ZodDiscriminatedUnion<[z.ZodObject<{
125
+ title: z.ZodOptional<z.ZodString>;
126
+ summary: z.ZodOptional<z.ZodString>;
127
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
128
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
129
+ kind: z.ZodLiteral<"url">;
39
130
  url: z.ZodString;
131
+ }, z.core.$strip>, z.ZodObject<{
40
132
  title: z.ZodOptional<z.ZodString>;
41
133
  summary: z.ZodOptional<z.ZodString>;
42
- }, z.core.$strip>>>;
134
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
135
+ sections: z.ZodOptional<z.ZodArray<z.ZodString>>;
136
+ kind: z.ZodLiteral<"registry-file">;
137
+ path: z.ZodString;
138
+ }, z.core.$strip>], "kind">>>>;
43
139
  sections: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodArray<z.ZodObject<{
44
140
  name: z.ZodString;
45
141
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
142
  restrictions: z.ZodOptional<z.ZodString>;
47
143
  }, z.core.$strip>>]>>>>;
48
144
  }, z.core.$strip>;
49
- /** A validated brand-kit recipe. */
145
+ /** A validated brand-kit recipe (parsed form — defaults materialised). */
50
146
  export type BrandKitRecipe = z.infer<typeof BrandKitRecipeSchema>;
147
+ /**
148
+ * Input alias — the shape an *author* writes. Same as
149
+ * `BrandKitRecipe` minus the `.default(...)` clauses; useful for
150
+ * loaders and TypeScript-authored recipe modules.
151
+ */
152
+ export type BrandKitRecipeInput = z.input<typeof BrandKitRecipeSchema>;
51
153
  /** A brand-kit field value (text / name list / rich entries). */
52
154
  export type BrandFieldValue = z.infer<typeof BrandFieldValueSchema>;
53
- /** A brand document reference within a recipe. */
155
+ /** A brand document reference within a recipe (URL or registry-file). */
54
156
  export type BrandDocument = z.infer<typeof BrandDocumentSchema>;
157
+ /** A URL-form brand document (post-parse — `kind` is the literal). */
158
+ export type BrandUrlDocument = z.infer<typeof BrandUrlDocumentSchema>;
159
+ /** A registry-file-form brand document (post-parse — `kind` is the literal). */
160
+ export type BrandRegistryFileDocument = z.infer<typeof BrandRegistryFileDocumentSchema>;