@sanity/workflow-cli 0.9.0 → 0.11.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 (71) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/README.md +71 -19
  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 +41 -47
  12. package/dist/commands/definition/show.d.ts +35 -3
  13. package/dist/commands/definition/show.js +44 -24
  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 +60 -56
  18. package/dist/commands/fire-action.d.ts +2 -4
  19. package/dist/commands/fire-action.js +39 -62
  20. package/dist/commands/list.d.ts +18 -10
  21. package/dist/commands/list.js +77 -69
  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 -28
  28. package/dist/commands/start.js +19 -80
  29. package/dist/commands/tail.d.ts +2 -2
  30. package/dist/commands/tail.js +34 -36
  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 +8 -0
  34. package/dist/hooks/prerun/telemetry.js +9 -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.d.ts +10 -0
  42. package/dist/lib/definitions.js +11 -24
  43. package/dist/lib/diff.d.ts +5 -2
  44. package/dist/lib/diff.js +61 -27
  45. package/dist/lib/env.d.ts +4 -0
  46. package/dist/lib/env.js +4 -3
  47. package/dist/lib/fail.d.ts +19 -6
  48. package/dist/lib/fail.js +26 -26
  49. package/dist/lib/flags.d.ts +0 -7
  50. package/dist/lib/flags.js +0 -17
  51. package/dist/lib/load-config.d.ts +11 -7
  52. package/dist/lib/load-config.js +40 -20
  53. package/dist/lib/operation-args.d.ts +14 -23
  54. package/dist/lib/operation-args.js +6 -38
  55. package/dist/lib/ops-report.d.ts +18 -10
  56. package/dist/lib/ops-report.js +20 -16
  57. package/dist/lib/params.js +0 -8
  58. package/dist/lib/read-fanout.d.ts +22 -0
  59. package/dist/lib/read-fanout.js +28 -0
  60. package/dist/lib/select-deployment.js +0 -21
  61. package/dist/lib/share-definitions.d.ts +120 -0
  62. package/dist/lib/share-definitions.js +187 -0
  63. package/dist/lib/stub.js +0 -7
  64. package/dist/lib/telemetry-setup.d.ts +70 -0
  65. package/dist/lib/telemetry-setup.js +99 -0
  66. package/dist/lib/telemetry.d.ts +131 -0
  67. package/dist/lib/telemetry.js +94 -0
  68. package/dist/lib/ui.d.ts +33 -1
  69. package/dist/lib/ui.js +32 -22
  70. package/oclif.manifest.json +21 -52
  71. package/package.json +14 -8
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,14 +1,15 @@
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
10
4
  * (validation, missing input, conflicting flags) read clean.
11
5
  *
6
+ * Exits by THROWING oclif's `ExitError`, never `process.exit`: the error
7
+ * rides the normal command lifecycle, so the `finally` hook still runs —
8
+ * completing the command trace and flushing batched telemetry — before
9
+ * oclif's handler exits 1 without printing anything further. A raw
10
+ * `process.exit` here would silently drop the trace and every batched
11
+ * event (including already-logged events from earlier in the command).
12
+ *
12
13
  * For unexpected failures (network, engine throw) prefer letting the
13
14
  * error propagate so the stack is visible for debugging.
14
15
  */
@@ -20,3 +21,15 @@ export declare function fail(headline: string, detail?: string): never;
20
21
  * helpers pure and unit-testable while giving callers one clean boundary.
21
22
  */
22
23
  export declare function failOnThrow<T>(headline: string, fn: () => T): T;
24
+ /** An API rejection of the auth token itself — as opposed to a 403, where
25
+ * the identity is valid but lacks the role for the operation. Walks the
26
+ * `cause` chain because the 401 often arrives wrapped: the engine's
27
+ * `/users/me` actor resolution rethrows it inside its own `Error`. */
28
+ export declare function isAuthRejection(err: unknown): boolean;
29
+ /**
30
+ * The detail block for a command failure: the error's message, plus the
31
+ * auth recovery hint when the API rejected the token. Every site that
32
+ * renders a caught command error should build its detail here, so the
33
+ * hint reaches the user no matter which call the token died at.
34
+ */
35
+ export declare function failureDetail(err: unknown): string;
package/dist/lib/fail.js CHANGED
@@ -1,38 +1,18 @@
1
1
  import { styleText } from 'node:util';
2
+ import { Errors } from '@oclif/core';
3
+ import { ClientError } from '@sanity/client';
4
+ import { errorMessage } from '@sanity/workflow-engine';
2
5
  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
- */
6
+ import { envAuthToken } from "./env.js";
21
7
  export function fail(headline, detail) {
22
8
  process.stderr.write(`${styleText('red', `${logSymbols.error} ${headline}`)}\n`);
23
9
  if (detail !== undefined && detail !== '') {
24
10
  for (const line of detail.split('\n')) {
25
- process.stderr.write(` ${line}\n`);
11
+ process.stderr.write(` ${styleText(['dim', 'red'], line)}\n`);
26
12
  }
27
13
  }
28
- process.exit(1);
14
+ return Errors.exit(1);
29
15
  }
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
16
  export function failOnThrow(headline, fn) {
37
17
  try {
38
18
  return fn();
@@ -41,3 +21,23 @@ export function failOnThrow(headline, fn) {
41
21
  fail(headline, errorMessage(err));
42
22
  }
43
23
  }
24
+ export function isAuthRejection(err) {
25
+ let current = err;
26
+ for (let depth = 0; depth < 5 && current !== undefined; depth++) {
27
+ if (current instanceof ClientError && current.statusCode === 401) {
28
+ return true;
29
+ }
30
+ current = current instanceof Error ? current.cause : undefined;
31
+ }
32
+ return false;
33
+ }
34
+ function authRecoveryHint() {
35
+ if (envAuthToken() !== undefined) {
36
+ return 'The SANITY_AUTH_TOKEN token was rejected — check it is valid and has access to this project.';
37
+ }
38
+ return 'Your `sanity login` session may have expired — run `sanity login` and retry.';
39
+ }
40
+ export function failureDetail(err) {
41
+ const message = errorMessage(err);
42
+ return isAuthRejection(err) ? `${message}\n${authRecoveryHint()}` : message;
43
+ }
@@ -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>;
@@ -1,10 +1,7 @@
1
1
  import { styleText } from 'node:util';
2
2
  import logSymbols from 'log-symbols';
3
- /**
4
- * The "ops applied" block shared by write-verb reports (set-stage,
5
- * fire-action): a blank separator, a header, and one line per primitive
6
- * that ran. Empty when nothing ran, so callers append it unconditionally.
7
- */
3
+ import ora from 'ora';
4
+ import { fail, failureDetail } from "./fail.js";
8
5
  export function opsAppliedLines(ranOps) {
9
6
  const details = (ranOps ?? []).map((op) => {
10
7
  const target = op.target ? styleText('dim', ` → ${op.target.scope}.${op.target.field}`) : '';
@@ -12,16 +9,23 @@ export function opsAppliedLines(ranOps) {
12
9
  });
13
10
  return details.length > 0 ? ['', styleText('dim', 'ops applied:'), ...details] : [];
14
11
  }
15
- /**
16
- * Emit a write-verb report through its spinner: warn and bail on a no-op
17
- * (`changed: false`), otherwise succeed and print the ops block. Shared by
18
- * every write command so the changed / no-op branches stay consistent.
19
- */
20
- export function emitWriteReport({ spinner, report, log, }) {
21
- if (!report.changed) {
22
- spinner.warn(report.message);
23
- return;
12
+ export function cascadeTail(cascaded) {
13
+ return cascaded > 0 ? `, then cascaded ${cascaded} auto-transition(s)` : '';
14
+ }
15
+ export async function runWriteVerb({ startLabel, failLabel, failHeadline, run, report, log, }) {
16
+ const spinner = ora(startLabel).start();
17
+ try {
18
+ const result = await run();
19
+ const emitted = report(result);
20
+ if (!emitted.changed) {
21
+ spinner.warn(emitted.message);
22
+ return;
23
+ }
24
+ spinner.succeed(emitted.message);
25
+ emitted.opsLines?.forEach(log);
26
+ }
27
+ catch (error) {
28
+ spinner.fail(failLabel);
29
+ fail(failHeadline, failureDetail(error));
24
30
  }
25
- spinner.succeed(report.message);
26
- report.opsLines.forEach(log);
27
31
  }
@@ -3,8 +3,6 @@ function coerceValue(raw) {
3
3
  return JSON.parse(raw);
4
4
  }
5
5
  catch {
6
- // Not valid JSON — treat it as a plain string so `--param note=hi`
7
- // needs no quoting. The engine validates the typed value either way.
8
6
  return raw;
9
7
  }
10
8
  }
@@ -15,12 +13,6 @@ function parsePair(pair) {
15
13
  }
16
14
  return [pair.slice(0, eq), coerceValue(pair.slice(eq + 1))];
17
15
  }
18
- /**
19
- * Parse repeated `key=value` flag pairs (`--param`, `--field`) into an
20
- * object. Each value is JSON-parsed so numbers/booleans/objects type
21
- * correctly, falling back to the raw string. The engine validates the
22
- * result against the declared types the consuming flag targets.
23
- */
24
16
  export function parseParams(pairs) {
25
17
  return Object.fromEntries(pairs.map(parsePair));
26
18
  }
@@ -0,0 +1,22 @@
1
+ import type { WorkflowResource } from '@sanity/workflow-engine';
2
+ import type { ReadTarget } from './context.ts';
3
+ /** A read target whose query threw — noted in place and reflected in the
4
+ * command's exit code. */
5
+ export interface ReadFailure {
6
+ resource: WorkflowResource;
7
+ message: string;
8
+ }
9
+ /**
10
+ * Run a read over every target, grouping output per resource: a banner when
11
+ * the read spans several, then whatever `run` logs for that target. A throw
12
+ * from one target is noted under its banner and the rest still render — a
13
+ * token that can't read one dataset must not sink the whole listing. A
14
+ * single-target run rethrows instead, keeping the plain unprefixed output
15
+ * and clean error rendering at the command boundary. Callers exit non-zero
16
+ * when failures come back.
17
+ */
18
+ export declare function runReadAcrossTargets({ targets, log, run, }: {
19
+ targets: ReadTarget[];
20
+ log: (line: string) => void;
21
+ run: (target: ReadTarget) => Promise<void>;
22
+ }): Promise<ReadFailure[]>;