@sanity/workflow-cli 0.9.0 → 0.10.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 (70) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/README.md +62 -18
  3. package/bin/run.js +0 -4
  4. package/dist/commands/abort.d.ts +4 -6
  5. package/dist/commands/abort.js +13 -24
  6. package/dist/commands/definition/delete.d.ts +2 -2
  7. package/dist/commands/definition/delete.js +14 -27
  8. package/dist/commands/definition/diff.d.ts +2 -2
  9. package/dist/commands/definition/diff.js +3 -12
  10. package/dist/commands/definition/list.d.ts +3 -4
  11. package/dist/commands/definition/list.js +40 -46
  12. package/dist/commands/definition/show.d.ts +22 -3
  13. package/dist/commands/definition/show.js +23 -21
  14. package/dist/commands/deploy.d.ts +11 -5
  15. package/dist/commands/deploy.js +54 -57
  16. package/dist/commands/diagnose.d.ts +22 -4
  17. package/dist/commands/diagnose.js +39 -45
  18. package/dist/commands/fire-action.d.ts +2 -4
  19. package/dist/commands/fire-action.js +27 -61
  20. package/dist/commands/list.d.ts +7 -8
  21. package/dist/commands/list.js +43 -56
  22. package/dist/commands/reset-activity.js +0 -1
  23. package/dist/commands/set-stage.d.ts +2 -2
  24. package/dist/commands/set-stage.js +14 -30
  25. package/dist/commands/show.d.ts +2 -2
  26. package/dist/commands/show.js +12 -24
  27. package/dist/commands/start.d.ts +4 -7
  28. package/dist/commands/start.js +18 -54
  29. package/dist/commands/tail.d.ts +2 -2
  30. package/dist/commands/tail.js +20 -25
  31. package/dist/hooks/finally/telemetry.d.ts +8 -0
  32. package/dist/hooks/finally/telemetry.js +13 -0
  33. package/dist/hooks/prerun/telemetry.d.ts +5 -0
  34. package/dist/hooks/prerun/telemetry.js +5 -0
  35. package/dist/index.js +0 -3
  36. package/dist/lib/base-command.d.ts +14 -0
  37. package/dist/lib/base-command.js +10 -0
  38. package/dist/lib/client.js +13 -30
  39. package/dist/lib/context.d.ts +49 -13
  40. package/dist/lib/context.js +49 -48
  41. package/dist/lib/definitions.js +5 -24
  42. package/dist/lib/diff.d.ts +5 -2
  43. package/dist/lib/diff.js +61 -27
  44. package/dist/lib/env.d.ts +4 -0
  45. package/dist/lib/env.js +4 -3
  46. package/dist/lib/fail.d.ts +12 -6
  47. package/dist/lib/fail.js +24 -25
  48. package/dist/lib/flags.d.ts +0 -7
  49. package/dist/lib/flags.js +0 -17
  50. package/dist/lib/load-config.d.ts +11 -7
  51. package/dist/lib/load-config.js +40 -20
  52. package/dist/lib/operation-args.d.ts +14 -23
  53. package/dist/lib/operation-args.js +6 -38
  54. package/dist/lib/ops-report.d.ts +18 -10
  55. package/dist/lib/ops-report.js +20 -16
  56. package/dist/lib/params.js +0 -8
  57. package/dist/lib/read-fanout.d.ts +22 -0
  58. package/dist/lib/read-fanout.js +28 -0
  59. package/dist/lib/select-deployment.js +0 -21
  60. package/dist/lib/share-definitions.d.ts +86 -0
  61. package/dist/lib/share-definitions.js +106 -0
  62. package/dist/lib/stub.js +0 -7
  63. package/dist/lib/telemetry-setup.d.ts +66 -0
  64. package/dist/lib/telemetry-setup.js +92 -0
  65. package/dist/lib/telemetry.d.ts +100 -0
  66. package/dist/lib/telemetry.js +89 -0
  67. package/dist/lib/ui.d.ts +33 -1
  68. package/dist/lib/ui.js +32 -21
  69. package/oclif.manifest.json +8 -47
  70. package/package.json +14 -8
@@ -1,5 +1,5 @@
1
- import { WORKFLOW_DEFINITION_TYPE, tagScopeFilter, validateDefinition, } from '@sanity/workflow-engine';
2
- import { errorMessage, fail } from "./fail.js";
1
+ import { WORKFLOW_DEFINITION_TYPE, errorMessage, tagScopeFilter, validateDefinition, } from '@sanity/workflow-engine';
2
+ import { fail } from "./fail.js";
3
3
  export function buildDefinitionShowQuery(flags) {
4
4
  const params = { name: flags.name };
5
5
  const filters = [`_type == "${WORKFLOW_DEFINITION_TYPE}"`, 'name == $name'];
@@ -14,26 +14,16 @@ export function buildDefinitionShowQuery(flags) {
14
14
  const groq = `*[${filters.join(' && ')}] | order(version desc) [0]`;
15
15
  return { groq, params };
16
16
  }
17
- /**
18
- * Fetch the deployed definition a command targets — latest unless pinned via
19
- * `version`. An absent pinned version always fails (the caller asked for
20
- * something specific); an absent latest returns `undefined` so the caller
21
- * decides whether that's fatal.
22
- */
23
17
  export async function fetchDeployedDefinition({ client, name, tag, version, }) {
24
18
  const { groq, params } = buildDefinitionShowQuery({ name, tag, version });
25
- const deployed = await client.fetch(groq, params);
19
+ const deployed = await client.fetch(groq, params, {
20
+ tag: 'definition.load',
21
+ });
26
22
  if (deployed === null && version !== undefined) {
27
23
  fail(`No deployed definition "${name}" v${version}.`);
28
24
  }
29
25
  return deployed ?? undefined;
30
26
  }
31
- /**
32
- * Narrow a batch to one definition name (`only`), failing with the available
33
- * names when there's no match. With `only` unset the whole batch passes through
34
- * (a fresh array, so callers can mutate without aliasing). `context` prefixes
35
- * the failure so callers whose run spans several deployments can attribute it.
36
- */
37
27
  export function selectDefinitions(allDefinitions, { only, context = '' }) {
38
28
  if (!only) {
39
29
  return [...allDefinitions];
@@ -45,11 +35,6 @@ export function selectDefinitions(allDefinitions, { only, context = '' }) {
45
35
  }
46
36
  return filtered;
47
37
  }
48
- /**
49
- * Per-definition structural/GROQ checks plus one batch-level check the engine
50
- * doesn't do for us: duplicate `name` within the set. Returns the
51
- * human-readable error lines; empty means the batch is valid.
52
- */
53
38
  export function collectValidationErrors(defs) {
54
39
  const errors = [];
55
40
  const seen = new Map();
@@ -70,10 +55,6 @@ export function collectValidationErrors(defs) {
70
55
  }
71
56
  return errors;
72
57
  }
73
- /** Validate a set of definitions, exiting with the collected errors on any
74
- * failure. The single validate-then-fail gate `deploy` and `definition diff`
75
- * share, so the two can't drift into divergent failure messages. `context`
76
- * prefixes the failure with the deployment it belongs to in multi-target runs. */
77
58
  export function validateOrFail(defs, context = '') {
78
59
  const errors = collectValidationErrors(defs);
79
60
  if (errors.length > 0) {
@@ -2,7 +2,10 @@ import type { DiffEntry } from '@sanity/workflow-engine';
2
2
  /** One summary line per diff entry, tagged create / unchanged. Definitions are
3
3
  * immutable, so a content change is a new version (`create`), never an update. */
4
4
  export declare function summaryLines(entries: DiffEntry[]): string[];
5
- /** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines. */
5
+ /** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines, long
6
+ * unchanged runs collapsed to a dim `… N unchanged lines` marker. */
6
7
  export declare function diffLines(oldObj: object, newObj: object): string[];
7
- /** The full diff report: the summary block plus a per-change diff. */
8
+ /** The full diff report: the summary block plus a per-change diff of the
9
+ * authored content — the deploy envelope is stripped from both sides (the
10
+ * version and docId already render on the summary line). */
8
11
  export declare function diffReport(entries: DiffEntry[]): string[];
package/dist/lib/diff.js CHANGED
@@ -2,8 +2,6 @@ import { styleText } from 'node:util';
2
2
  import { diffJson } from 'diff';
3
3
  import logSymbols from 'log-symbols';
4
4
  import { sectionHeader } from "./ui.js";
5
- /** One summary line per diff entry, tagged create / unchanged. Definitions are
6
- * immutable, so a content change is a new version (`create`), never an update. */
7
5
  export function summaryLines(entries) {
8
6
  const tags = {
9
7
  create: styleText('green', '+ create '),
@@ -13,34 +11,73 @@ export function summaryLines(entries) {
13
11
  return ` ${tags[e.status]} ${e.name} v${e.version} ${styleText('dim', `(${e.docId})`)}`;
14
12
  });
15
13
  }
16
- /** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines. */
17
- export function diffLines(oldObj, newObj) {
18
- const out = [];
19
- for (const chunk of diffJson(oldObj, newObj)) {
14
+ const CONTEXT_LINES = 3;
15
+ const DEPLOY_STAMPED_KEYS = new Set(['tag', 'version', 'contentHash']);
16
+ function authoredContent(doc) {
17
+ return Object.fromEntries(Object.entries(doc).filter(([key]) => !key.startsWith('_') && !DEPLOY_STAMPED_KEYS.has(key)));
18
+ }
19
+ function chunkKind(chunk) {
20
+ if (chunk.added) {
21
+ return 'added';
22
+ }
23
+ if (chunk.removed) {
24
+ return 'removed';
25
+ }
26
+ return 'context';
27
+ }
28
+ function taggedLines(oldObj, newObj) {
29
+ return diffJson(oldObj, newObj).flatMap((chunk) => {
20
30
  const chunkLines = chunk.value.split('\n');
21
- // diffJson appends a trailing empty string when the chunk ends in "\n";
22
- // drop it so we don't emit a blank line per chunk boundary.
23
- if (chunkLines.at(-1) === '')
31
+ if (chunkLines.at(-1) === '') {
24
32
  chunkLines.pop();
25
- for (const line of chunkLines) {
26
- if (chunk.added)
27
- out.push(styleText('green', `+ ${line}`));
28
- else if (chunk.removed)
29
- out.push(styleText('red', `- ${line}`));
30
- else
31
- out.push(styleText('dim', ` ${line}`));
32
33
  }
34
+ return chunkLines.map((text) => ({ kind: chunkKind(chunk), text }));
35
+ });
36
+ }
37
+ function collapseUnchanged(lines, context) {
38
+ const changed = lines.flatMap((line, i) => (line.kind === 'context' ? [] : [i]));
39
+ if (changed.length === 0) {
40
+ return lines;
33
41
  }
42
+ const keep = lines.map((_, i) => changed.some((c) => Math.abs(c - i) <= context));
43
+ const out = [];
44
+ let run = [];
45
+ const flushRun = () => {
46
+ if (run.length === 1) {
47
+ out.push(...run);
48
+ }
49
+ else if (run.length > 1) {
50
+ out.push({ kind: 'elision', count: run.length });
51
+ }
52
+ run = [];
53
+ };
54
+ lines.forEach((line, i) => {
55
+ if (keep[i]) {
56
+ flushRun();
57
+ out.push(line);
58
+ }
59
+ else {
60
+ run.push(line);
61
+ }
62
+ });
63
+ flushRun();
34
64
  return out;
35
65
  }
36
- /** Footnote for the detail diff: `_id`, `version`, and `contentHash` are
37
- * stamped by deploy as a function of the content — never authored. Without
38
- * this an operator can read their `+`/`-` lines as fields they edited (e.g. a
39
- * `version` they never set), when they only reflect the content change. */
40
- function derivedFieldsNote() {
41
- return `${logSymbols.info} ${styleText('dim', '_id, version and contentHash are deploy-generated — they change automatically with the content, not fields you edit.')}`;
66
+ function renderLine(line) {
67
+ if (line.kind === 'elision') {
68
+ return styleText('dim', ` ${line.count} unchanged lines`);
69
+ }
70
+ if (line.kind === 'added') {
71
+ return styleText('green', `+ ${line.text}`);
72
+ }
73
+ if (line.kind === 'removed') {
74
+ return styleText('red', `- ${line.text}`);
75
+ }
76
+ return styleText('dim', ` ${line.text}`);
77
+ }
78
+ export function diffLines(oldObj, newObj) {
79
+ return collapseUnchanged(taggedLines(oldObj, newObj), CONTEXT_LINES).map(renderLine);
42
80
  }
43
- /** The full diff report: the summary block plus a per-change diff. */
44
81
  export function diffReport(entries) {
45
82
  const lines = ['', sectionHeader('Summary'), ...summaryLines(entries)];
46
83
  const changes = entries.filter((e) => e.status !== 'unchanged');
@@ -49,10 +86,7 @@ export function diffReport(entries) {
49
86
  return lines;
50
87
  }
51
88
  for (const e of changes) {
52
- // For net-new docs, existing is undefined render the whole expected
53
- // doc as added lines so the operator sees what would land.
54
- lines.push('', styleText('bold', `── ${e.name} v${e.version} ──`), ...diffLines(e.existing ?? {}, e.expected));
89
+ lines.push('', styleText('bold', `── ${e.name} v${e.version} ──`), ...diffLines(authoredContent(e.existing ?? {}), authoredContent(e.expected)));
55
90
  }
56
- lines.push('', derivedFieldsNote());
57
91
  return lines;
58
92
  }
package/dist/lib/env.d.ts CHANGED
@@ -2,3 +2,7 @@ export declare const ENV: {
2
2
  readonly SANITY_AUTH_TOKEN: "SANITY_AUTH_TOKEN";
3
3
  readonly SANITY_API_HOST: "SANITY_API_HOST";
4
4
  };
5
+ /** The explicit env token, or undefined when unset/blank. The single owner
6
+ * of "did the token come from the env?" — token resolution and the auth
7
+ * recovery hint must never disagree on it. */
8
+ export declare function envAuthToken(): string | undefined;
package/dist/lib/env.js CHANGED
@@ -1,7 +1,8 @@
1
- // Typed env var definitions. Client config (projectId/dataset) is derived from
2
- // the deployment's resource, and the tag comes from `--tag`/the config — so the
3
- // only env vars left are auth + host overrides.
4
1
  export const ENV = {
5
2
  SANITY_AUTH_TOKEN: 'SANITY_AUTH_TOKEN',
6
3
  SANITY_API_HOST: 'SANITY_API_HOST',
7
4
  };
5
+ export function envAuthToken() {
6
+ const token = process.env[ENV.SANITY_AUTH_TOKEN]?.trim();
7
+ return token ? token : undefined;
8
+ }
@@ -1,9 +1,3 @@
1
- /**
2
- * The human-readable message of a caught `unknown`. Strips control characters
3
- * (keeping newlines and tabs) so server-derived error text can't smuggle
4
- * terminal escape sequences into CLI output.
5
- */
6
- export declare function errorMessage(error: unknown): string;
7
1
  /**
8
2
  * Render a user-facing error and exit non-zero. Writes straight to
9
3
  * stderr — no oclif framing, no Node stack — so expected failures
@@ -20,3 +14,15 @@ export declare function fail(headline: string, detail?: string): never;
20
14
  * helpers pure and unit-testable while giving callers one clean boundary.
21
15
  */
22
16
  export declare function failOnThrow<T>(headline: string, fn: () => T): T;
17
+ /** An API rejection of the auth token itself — as opposed to a 403, where
18
+ * the identity is valid but lacks the role for the operation. Walks the
19
+ * `cause` chain because the 401 often arrives wrapped: the engine's
20
+ * `/users/me` actor resolution rethrows it inside its own `Error`. */
21
+ export declare function isAuthRejection(err: unknown): boolean;
22
+ /**
23
+ * The detail block for a command failure: the error's message, plus the
24
+ * auth recovery hint when the API rejected the token. Every site that
25
+ * renders a caught command error should build its detail here, so the
26
+ * hint reaches the user no matter which call the token died at.
27
+ */
28
+ export declare function failureDetail(err: unknown): string;
package/dist/lib/fail.js CHANGED
@@ -1,38 +1,17 @@
1
1
  import { styleText } from 'node:util';
2
+ import { ClientError } from '@sanity/client';
3
+ import { errorMessage } from '@sanity/workflow-engine';
2
4
  import logSymbols from 'log-symbols';
3
- /**
4
- * The human-readable message of a caught `unknown`. Strips control characters
5
- * (keeping newlines and tabs) so server-derived error text can't smuggle
6
- * terminal escape sequences into CLI output.
7
- */
8
- export function errorMessage(error) {
9
- const message = error instanceof Error ? error.message : String(error);
10
- // [^\P{Cc}\n\t] = every control character except \n and \t.
11
- return message.replace(/[^\P{Cc}\n\t]/gu, '');
12
- }
13
- /**
14
- * Render a user-facing error and exit non-zero. Writes straight to
15
- * stderr — no oclif framing, no Node stack — so expected failures
16
- * (validation, missing input, conflicting flags) read clean.
17
- *
18
- * For unexpected failures (network, engine throw) prefer letting the
19
- * error propagate so the stack is visible for debugging.
20
- */
5
+ import { envAuthToken } from "./env.js";
21
6
  export function fail(headline, detail) {
22
7
  process.stderr.write(`${styleText('red', `${logSymbols.error} ${headline}`)}\n`);
23
8
  if (detail !== undefined && detail !== '') {
24
9
  for (const line of detail.split('\n')) {
25
- process.stderr.write(` ${line}\n`);
10
+ process.stderr.write(` ${styleText(['dim', 'red'], line)}\n`);
26
11
  }
27
12
  }
28
13
  process.exit(1);
29
14
  }
30
- /**
31
- * Run `fn`, funnelling a thrown env/validation error through {@link fail}
32
- * so an expected, user-fixable failure (missing input, bad value) reads
33
- * clean instead of as an oclif stack trace. Keeps the throwing `read*`
34
- * helpers pure and unit-testable while giving callers one clean boundary.
35
- */
36
15
  export function failOnThrow(headline, fn) {
37
16
  try {
38
17
  return fn();
@@ -41,3 +20,23 @@ export function failOnThrow(headline, fn) {
41
20
  fail(headline, errorMessage(err));
42
21
  }
43
22
  }
23
+ export function isAuthRejection(err) {
24
+ let current = err;
25
+ for (let depth = 0; depth < 5 && current !== undefined; depth++) {
26
+ if (current instanceof ClientError && current.statusCode === 401) {
27
+ return true;
28
+ }
29
+ current = current instanceof Error ? current.cause : undefined;
30
+ }
31
+ return false;
32
+ }
33
+ function authRecoveryHint() {
34
+ if (envAuthToken() !== undefined) {
35
+ return 'The SANITY_AUTH_TOKEN token was rejected — check it is valid and has access to this project.';
36
+ }
37
+ return 'Your `sanity login` session may have expired — run `sanity login` and retry.';
38
+ }
39
+ export function failureDetail(err) {
40
+ const message = errorMessage(err);
41
+ return isAuthRejection(err) ? `${message}\n${authRecoveryHint()}` : message;
42
+ }
@@ -5,13 +5,6 @@
5
5
  export declare const tagFlags: {
6
6
  tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  };
8
- /** Actor attribution for write verbs — name a real user (plus roles) instead
9
- * of the pinned workflow-cli system actor, so assignee- and role-gated
10
- * filters pass. Commands resolve the pair with `resolveActor`. */
11
- export declare const actorFlags: {
12
- as: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
- 'as-role': import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
14
- };
15
8
  export declare const jsonFlags: {
16
9
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
10
  };
package/dist/lib/flags.js CHANGED
@@ -1,26 +1,9 @@
1
1
  import { Flags } from '@oclif/core';
2
- /** The environment tag (e.g. prod, test). For write commands it selects which
3
- * deployment in the discovered `sanity.workflow` config to act on (omittable
4
- * when only one is configured); for read commands it's an optional query
5
- * filter — and the resource disambiguator when a config spans more than one. */
6
2
  export const tagFlags = {
7
3
  tag: Flags.string({
8
4
  description: 'Workflow environment tag (e.g. prod, test) — the deployment to target for writes; an optional filter for reads.',
9
5
  }),
10
6
  };
11
- /** Actor attribution for write verbs — name a real user (plus roles) instead
12
- * of the pinned workflow-cli system actor, so assignee- and role-gated
13
- * filters pass. Commands resolve the pair with `resolveActor`. */
14
- export const actorFlags = {
15
- as: Flags.string({
16
- description: 'Attribute the write to this Sanity user id (default: a workflow-cli system actor).',
17
- }),
18
- 'as-role': Flags.string({
19
- description: 'Role to attribute to the --as user (repeatable), so role-gated filters pass.',
20
- multiple: true,
21
- default: [],
22
- }),
23
- };
24
7
  export const jsonFlags = {
25
8
  json: Flags.boolean({
26
9
  description: 'Emit structured JSON instead of rendered output.',
@@ -1,11 +1,15 @@
1
- import type { WorkflowConfig } from '@sanity/workflow-engine';
1
+ import { type WorkflowConfig } from '@sanity/workflow-engine';
2
2
  /**
3
3
  * Discover the `sanity.workflow.{ts,js,mjs}` in `cwd`, import its default
4
- * export, and validate it through {@link defineWorkflowConfig}.
5
- *
6
- * Re-validating here (rather than trusting the export) means a config authored
7
- * as a raw object — `export default {deployments: [...]}` without the helper —
8
- * is still checked, and a malformed one fails with the engine's path-prefixed
9
- * message at the CLI boundary instead of mid-deploy.
4
+ * export, and validate it through {@link defineWorkflowConfig}. Any problem —
5
+ * no file, unloadable module, invalid config — exits through the clean `fail`
6
+ * path.
10
7
  */
11
8
  export declare function loadWorkflowConfig(cwd?: string): Promise<WorkflowConfig>;
9
+ /**
10
+ * {@link loadWorkflowConfig} for telemetry setup, which must never break (or
11
+ * exit) a command: `undefined` when the file is absent or unusable. A broken
12
+ * config is not swallowed for real — the command's own load reports it through
13
+ * the clean `fail` path; this caller just runs with telemetry off.
14
+ */
15
+ export declare function loadWorkflowConfigIfPresent(cwd?: string): Promise<WorkflowConfig | undefined>;
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { basename, dirname, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
+ import { errorMessage } from '@sanity/workflow-engine';
12
13
  import { defineWorkflowConfig } from '@sanity/workflow-engine/define';
13
14
  import { createJiti } from 'jiti';
14
15
  import { fail } from "./fail.js";
@@ -17,7 +18,6 @@ const CONFIG_FILE_NAMES = [
17
18
  'sanity.workflow.js',
18
19
  'sanity.workflow.mjs',
19
20
  ];
20
- /** First `sanity.workflow.{ts,js,mjs}` in `cwd`, or undefined. */
21
21
  function findConfigFile(cwd) {
22
22
  for (const name of CONFIG_FILE_NAMES) {
23
23
  const candidate = join(cwd, name);
@@ -27,12 +27,15 @@ function findConfigFile(cwd) {
27
27
  }
28
28
  return undefined;
29
29
  }
30
- /**
31
- * Import the config module's default export. `.ts` goes through jiti (no build
32
- * step); `.js`/`.mjs` use native dynamic import. A throw inside the module — a
33
- * syntax error, a `defineWorkflowConfig` that rejected — surfaces as a clean
34
- * CLI error rather than an oclif stack trace.
35
- */
30
+ class ConfigLoadError extends Error {
31
+ headline;
32
+ detail;
33
+ constructor(headline, detail) {
34
+ super(`${headline} ${detail}`);
35
+ this.headline = headline;
36
+ this.detail = detail;
37
+ }
38
+ }
36
39
  async function importDefault(filePath) {
37
40
  const url = pathToFileURL(filePath).href;
38
41
  try {
@@ -42,28 +45,45 @@ async function importDefault(filePath) {
42
45
  return (await import(__rewriteRelativeImportExtension(url, true))).default;
43
46
  }
44
47
  catch (err) {
45
- fail(`Failed to load ${basename(filePath)}:`, err instanceof Error ? err.message : String(err));
48
+ throw new ConfigLoadError(`Failed to load ${basename(filePath)}:`, errorMessage(err));
49
+ }
50
+ }
51
+ const parsedConfigs = new Map();
52
+ function parseConfigFile(filePath) {
53
+ const cached = parsedConfigs.get(filePath);
54
+ if (cached !== undefined) {
55
+ return cached;
46
56
  }
57
+ const parsed = importDefault(filePath).then((exported) => {
58
+ try {
59
+ return defineWorkflowConfig(exported);
60
+ }
61
+ catch (err) {
62
+ throw new ConfigLoadError(`Invalid config in ${basename(filePath)}:`, errorMessage(err));
63
+ }
64
+ });
65
+ parsedConfigs.set(filePath, parsed);
66
+ return parsed;
47
67
  }
48
- /**
49
- * Discover the `sanity.workflow.{ts,js,mjs}` in `cwd`, import its default
50
- * export, and validate it through {@link defineWorkflowConfig}.
51
- *
52
- * Re-validating here (rather than trusting the export) means a config authored
53
- * as a raw object — `export default {deployments: [...]}` without the helper —
54
- * is still checked, and a malformed one fails with the engine's path-prefixed
55
- * message at the CLI boundary instead of mid-deploy.
56
- */
57
68
  export async function loadWorkflowConfig(cwd = process.cwd()) {
58
69
  const filePath = findConfigFile(cwd);
59
70
  if (filePath === undefined) {
60
71
  fail(`No ${CONFIG_FILE_NAMES[0]} found in ${cwd}.`, 'Create one that `export default defineWorkflowConfig({deployments: [...]})`.');
61
72
  }
62
- const exported = await importDefault(filePath);
63
73
  try {
64
- return defineWorkflowConfig(exported);
74
+ return await parseConfigFile(filePath);
65
75
  }
66
76
  catch (err) {
67
- fail(`Invalid config in ${basename(filePath)}:`, err instanceof Error ? err.message : String(err));
77
+ if (err instanceof ConfigLoadError) {
78
+ fail(err.headline, err.detail);
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+ export async function loadWorkflowConfigIfPresent(cwd = process.cwd()) {
84
+ const filePath = findConfigFile(cwd);
85
+ if (filePath === undefined) {
86
+ return undefined;
68
87
  }
88
+ return parseConfigFile(filePath).catch(() => undefined);
69
89
  }
@@ -1,33 +1,24 @@
1
- import type { Actor, EngineScopeArgs, OperationArgs } from '@sanity/workflow-engine';
1
+ import { type EngineScopeArgs, type OperationArgs, type WorkflowTelemetryLogger } from '@sanity/workflow-engine';
2
2
  /**
3
- * The engine-scope a CLI write needs: which tag partition and which resource
3
+ * The engine-scope a CLI verb needs: which tag partition and which resource
4
4
  * the engine's own data lives in — {@link EngineScopeArgs} minus the client.
5
5
  * A {@link import('@sanity/workflow-engine').WorkflowDeployment} satisfies it,
6
6
  * so commands pass their resolved deployment straight through.
7
7
  */
8
8
  export type EngineScope = Pick<EngineScopeArgs, 'tag' | 'workflowResource'>;
9
9
  /**
10
- * The synthetic actor a CLI write is attributed to when the operator
11
- * doesn't name a real user. The engine resolves a real actor from
12
- * `/users/me`, which a token-only CLI can't reach; until the CLI hooks
13
- * into Sanity auth proper, writes act as this system identity.
10
+ * The engine args every CLI verb shares: tag scope, workflow resource,
11
+ * the CLI's declared execution context the advisory "via what" stamped
12
+ * on history; identity itself is always the token behind the client
13
+ * (`/users/me`), never injected and the invocation's telemetry logger,
14
+ * so the engine's adoption vocabulary flows from CLI-driven operations
15
+ * (the raw-verb equivalent of `createEngine({telemetry})`). Spread by the
16
+ * instance verbs ({@link buildOperationArgs}), the definition verbs
17
+ * (e.g. `definition delete`), and reads like `diagnose`.
14
18
  */
15
- export declare const SYSTEM_ACTOR: Actor;
16
- /**
17
- * The write-verb arguments every engine operation shares: tag scope,
18
- * workflow resource, and the actor the write is attributed to — commands
19
- * with `--as` resolve one via {@link resolveActor}; the rest pass
20
- * {@link SYSTEM_ACTOR} explicitly. Spread by both the instance verbs
21
- * ({@link buildOperationArgs}) and the definition verbs (e.g. `definition delete`).
22
- */
23
- export declare function baseEngineArgs(scope: EngineScope, actor: Actor): EngineScope & Pick<OperationArgs, 'access'>;
24
- /**
25
- * Resolve the actor a write is attributed to. With a named user the fire
26
- * is pinned to it (plus any roles, so role-gated action filters pass);
27
- * without one the {@link SYSTEM_ACTOR} fallback applies. Roles without a
28
- * named user throw — they'd silently attach to nothing otherwise.
29
- */
30
- export declare function resolveActor(as: string | undefined, roles: string[]): Actor;
19
+ export declare function baseEngineArgs(scope: EngineScope): EngineScope & Pick<EngineScopeArgs, 'executionContext'> & {
20
+ telemetry: WorkflowTelemetryLogger;
21
+ };
31
22
  /**
32
23
  * {@link baseEngineArgs} plus the instance-operation fields (everything
33
24
  * but the client and the verb-specific fields). The `reason` is spread
@@ -38,6 +29,6 @@ export declare function buildOperationArgs({ scope, instanceId, reason, }: {
38
29
  scope: EngineScope;
39
30
  instanceId: string;
40
31
  reason: string | undefined;
41
- }): OperationArgs & EngineScope & {
32
+ }): OperationArgs & ReturnType<typeof baseEngineArgs> & {
42
33
  reason?: string;
43
34
  };
@@ -1,48 +1,16 @@
1
- /**
2
- * The synthetic actor a CLI write is attributed to when the operator
3
- * doesn't name a real user. The engine resolves a real actor from
4
- * `/users/me`, which a token-only CLI can't reach; until the CLI hooks
5
- * into Sanity auth proper, writes act as this system identity.
6
- */
7
- export const SYSTEM_ACTOR = { kind: 'system', id: 'workflow-cli' };
8
- /**
9
- * The write-verb arguments every engine operation shares: tag scope,
10
- * workflow resource, and the actor the write is attributed to — commands
11
- * with `--as` resolve one via {@link resolveActor}; the rest pass
12
- * {@link SYSTEM_ACTOR} explicitly. Spread by both the instance verbs
13
- * ({@link buildOperationArgs}) and the definition verbs (e.g. `definition delete`).
14
- */
15
- export function baseEngineArgs(scope, actor) {
1
+ import { EXECUTION_KINDS, } from '@sanity/workflow-engine';
2
+ import { cliTelemetry } from "./telemetry.js";
3
+ export function baseEngineArgs(scope) {
16
4
  return {
17
5
  tag: scope.tag,
18
6
  workflowResource: scope.workflowResource,
19
- access: { actor },
7
+ executionContext: { kind: EXECUTION_KINDS.cli, id: 'workflow-cli' },
8
+ telemetry: cliTelemetry().logger,
20
9
  };
21
10
  }
22
- /**
23
- * Resolve the actor a write is attributed to. With a named user the fire
24
- * is pinned to it (plus any roles, so role-gated action filters pass);
25
- * without one the {@link SYSTEM_ACTOR} fallback applies. Roles without a
26
- * named user throw — they'd silently attach to nothing otherwise.
27
- */
28
- export function resolveActor(as, roles) {
29
- if (as === undefined) {
30
- if (roles.length > 0) {
31
- throw new Error('--as-role requires --as <userId>');
32
- }
33
- return SYSTEM_ACTOR;
34
- }
35
- return { kind: 'person', id: as, ...(roles.length > 0 ? { roles } : {}) };
36
- }
37
- /**
38
- * {@link baseEngineArgs} plus the instance-operation fields (everything
39
- * but the client and the verb-specific fields). The `reason` is spread
40
- * conditionally because the engine's optional field rejects an explicit
41
- * `undefined` under `exactOptionalPropertyTypes`.
42
- */
43
11
  export function buildOperationArgs({ scope, instanceId, reason, }) {
44
12
  return {
45
- ...baseEngineArgs(scope, SYSTEM_ACTOR),
13
+ ...baseEngineArgs(scope),
46
14
  instanceId,
47
15
  ...(reason !== undefined ? { reason } : {}),
48
16
  };
@@ -1,4 +1,3 @@
1
- import type { Ora } from 'ora';
2
1
  /** A single engine primitive that ran during a write commit — the
3
2
  * reportable subset of the engine's `OpAppliedSummary`. */
4
3
  export interface RanOp {
@@ -24,19 +23,28 @@ export interface WriteOutcome {
24
23
  ranOps?: RanOp[];
25
24
  }
26
25
  /** The spinner message + ops block a write verb prints once its engine
27
- * call returns. {@link opsAppliedLines} builds `opsLines`. */
26
+ * call returns. {@link opsAppliedLines} builds `opsLines`; verbs whose
27
+ * commits never run ops omit it. */
28
28
  export interface WriteReport {
29
29
  changed: boolean;
30
30
  message: string;
31
- opsLines: string[];
31
+ opsLines?: string[];
32
32
  }
33
+ /** The ", then cascaded N auto-transition(s)" suffix a write report appends
34
+ * when the commit cascaded the instance onward; empty when it didn't. */
35
+ export declare function cascadeTail(cascaded: number): string;
33
36
  /**
34
- * Emit a write-verb report through its spinner: warn and bail on a no-op
35
- * (`changed: false`), otherwise succeed and print the ops block. Shared by
36
- * every write command so the changed / no-op branches stay consistent.
37
+ * Run a write verb behind its spinner: start it, run the engine call, and
38
+ * emit the report — warn and bail on a no-op (`changed: false`), otherwise
39
+ * succeed and print the ops block. A throw fails the spinner and exits
40
+ * through {@link fail}. The one spinner + report + catch shape every write
41
+ * command (abort, set-stage, fire-action, start, definition delete) shares.
37
42
  */
38
- export declare function emitWriteReport({ spinner, report, log, }: {
39
- spinner: Ora;
40
- report: WriteReport;
43
+ export declare function runWriteVerb<T>({ startLabel, failLabel, failHeadline, run, report, log, }: {
44
+ startLabel: string;
45
+ failLabel: string;
46
+ failHeadline: string;
47
+ run: () => Promise<T>;
48
+ report: (result: T) => WriteReport;
41
49
  log: (line: string) => void;
42
- }): void;
50
+ }): Promise<void>;