@sanity/workflow-cli 0.11.0 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @sanity/workflow-cli
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e3a7ba2: `start` gains `--instance-id` — for retries: the id is the start's idempotency key, so passing the id of a start that failed partway resumes it instead of creating a duplicate (an already-settled start replays as a no-op). A start whose first auto-advance fails after the instance was created and primed (`StartNotSettledError`) now reports as STARTED with the cause and the retry id — a warn, not the "Start rejected" failure path — and the `--json` payload carries `{instanceId, notSettled: true, detail}`. A start that fails before priming (`StartNotPrimedError`) still fails, with the `--instance-id` retry hint appended so the operator can resume instead of duplicating.
8
+ - a8ace4d: New `nuke --tag <tag>` command — the dev-period big red button that deletes every engine-owned document for a deployment tag: instances, definitions, and guard docs (matched by instance-id prefix, so guards orphaned by earlier partial deletions are swept too) across the deployment's workflow resource and every alias-bound resource. Content documents are never touched. The command always prints a per-dataset dry-run plan, then requires typing back every involved `project.dataset` target; `--force` skips the prompt for scripts/CI (the plan still prints). Unlike `definition delete` (the governed lifecycle path), `nuke` deletes raw docs without reading them, so it works on pre-amendment shapes a newer engine can no longer read — the sanctioned reset while the versioned upgrade framework doesn't exist.
9
+ - 5a1a9fe: Direct reads of engine-owned documents adopt the data-model gate (`assertReadableModel`): the CLI's instance and definition reads (`show`, `tail`, `list`, `definition show`, `definition list`, deploy lookups, the share read-back) and the MCP tools that interpret rows from the ungated `engine.query` escape hatch (`list_workflow_definitions`, `list_workflow_instances`, the definition lookup behind `start_workflow` / `get_workflow_definition`). A document written by a newer engine data model now fails with the governed "upgrade `@sanity/workflow-engine`" error instead of rendering unchecked; list projections carry the stamp pair so projected rows gate too.
10
+
11
+ ### Patch Changes
12
+
13
+ - 092a0d4: **BREAKING:** runtime-supplied refs written into field state are gated on the deployment's declared resource surface.
14
+ - A `doc.ref` / `doc.refs` / `release.ref` value a caller or effect handler supplies — start `initialFields` input, an action's param-sourced op values, `editField` values, effect-completion ops — must target a declared resource: the `workflowResource` itself or a resource the `resourceClients` resolver serves. An off-surface ref throws the new `RefResourceUndeclaredError` and the commit aborts, instead of writing cleanly and failing later when something dereferences it. The check is advisory like every engine check; stored documents are unchanged.
15
+ - Definition-authored refs are exempt: literal `initialValue` seeds, `type: 'query'` texts, spawn `with` projections, and op values that bind no action param were validated (and alias-expanded) at deploy, so they materialize into field state without re-checking.
16
+ - Resource aliases stay a deploy-time abstraction: `deployDefinitions({definitions, resourceAliases?})` expands every `@<alias>:` reference to its physical GDR before fingerprinting, and nothing alias-shaped survives past deploy — the runtime holds no alias map, and an alias-shaped ref supplied to a runtime verb is rejected as malformed like any other non-GDR string.
17
+ - `createBench` takes `serveResources` (dataset siblings served — and thereby declared — straight from the bench's own store) plus a raw `resourceClients` resolver for anything beyond same-store siblings. The engine also exports `sameResource` alongside the existing GDR helpers.
18
+ - The CLI wires no `resourceClients`, so refs supplied at runtime through it (`start --field`, `fire-action` ref params) admit the workflow resource only.
19
+
20
+ - Updated dependencies [f9389e5]
21
+ - Updated dependencies [d0c62ea]
22
+ - Updated dependencies [c3eed2e]
23
+ - Updated dependencies [df4bd80]
24
+ - Updated dependencies [092a0d4]
25
+ - Updated dependencies [5a1a9fe]
26
+ - Updated dependencies [e3a7ba2]
27
+ - Updated dependencies [30fed9e]
28
+ - Updated dependencies [1321ba5]
29
+ - Updated dependencies [e683875]
30
+ - Updated dependencies [a8ace4d]
31
+ - @sanity/workflow-engine@0.17.0
32
+
3
33
  ## 0.11.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -74,11 +74,20 @@ config fails with a clean, path-prefixed error before the command runs.
74
74
  `resourceAliases` is only for content that lives in a _different_ resource from
75
75
  `workflowResource`. A definition can reference a document in the **same**
76
76
  resource by bare id, no alias needed — bare ids root at `workflowResource` at
77
- runtime. Rule of thumb: **different resource bind an alias and reference it as
78
- `@<handle>:<id>`; same resource use a bare id and omit `resourceAliases`
77
+ runtime. Bindings exist at deploy time only: `deploy` expands each `@<handle>:`
78
+ reference to its bound physical GDR, and nothing alias-shaped survives into the
79
+ deployed definition or the runtime. Rule of thumb: **different resource →
80
+ bind an alias; same resource → use a bare id and omit `resourceAliases`
79
81
  entirely.** So the simplest single-dataset setup is just a `workflowResource`
80
82
  and `definitions`, with no `resourceAliases` at all.
81
83
 
84
+ Refs supplied at runtime (`start --field`, `fire-action` ref params) are gated
85
+ separately: they may target the `workflowResource` itself, and anything else is
86
+ rejected at the write with `RefResourceUndeclaredError`. The CLI wires no
87
+ `resourceClients` resolver, so it has no way to widen that surface — a runtime
88
+ foreign-resource ref goes through an engine consumer that serves the target
89
+ resource, not through the CLI.
90
+
82
91
  | Var | Purpose |
83
92
  | ------------------- | ---------------------------------------------------------------------------------------------------- |
84
93
  | `SANITY_AUTH_TOKEN` | Explicit token for CI / scripted use — wins over the `sanity login` session (editor role for writes) |
@@ -118,25 +127,26 @@ stale compiled output.
118
127
 
119
128
  ## Command status
120
129
 
121
- | Command | Status |
122
- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
123
- | `deploy` | wired — calls `workflow.deployDefinitions` over the selected deployment's definitions |
124
- | `deploy --all-tags` | wired — deploys every deployment in the config, continuing past per-deployment failures |
125
- | `deploy --check` | wired — runs `validateDefinition` over the local batch + a duplicate-name check |
126
- | `deploy --dry-run` | wired — fetches existing docs and renders a coloured JSON diff per change |
127
- | `deploy --only <name>` | wired — filters deploy/check/dry-run to one definition by `name` |
128
- | `start <name>` | wired — calls `workflow.startInstance` (`--field` for input fields) |
129
- | `list` | wired — `client.fetch` over `sanity.workflow.instance` documents (`--definition <name>` to filter) |
130
- | `show <instance-id>` | wired — `client.getDocument` |
131
- | `diagnose <instance-id>` | wired — calls `workflow.diagnose`, classifies why the instance is/isn't progressing |
132
- | `tail <instance-id>` | wired — `client.listen()` over the instance, prints new history entries |
133
- | `abort <instance-id>` | wired — calls `workflow.abortInstance` (hard stop: cancels pending effects, lifts guards) |
134
- | `set-stage <instance-id> --to <stage>` | wired — calls `workflow.setStage` (admin override: skips declared transitions/filters; enter lifecycle + cascade still run) |
135
- | `fire-action <instance-id>` | wired — `workflow.availableActions` lists actions; `workflow.fireAction` fires one |
136
- | `definition list` | wired — `client.fetch` over `sanity.workflow.definition` documents |
137
- | `definition show <name>` | wired — `client.fetch`, latest version unless `--version` |
138
- | `definition diff <name>` | wired — diffs the in-code definition against the deployed latest (`--version` to pin) |
139
- | `definition delete <name>` | wired — calls `workflow.deleteDefinition` (refuses on live instances unless `--cascade`) |
130
+ | Command | Status |
131
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
132
+ | `deploy` | wired — calls `workflow.deployDefinitions` over the selected deployment's definitions |
133
+ | `deploy --all-tags` | wired — deploys every deployment in the config, continuing past per-deployment failures |
134
+ | `deploy --check` | wired — runs `validateDefinition` over the local batch + a duplicate-name check |
135
+ | `deploy --dry-run` | wired — fetches existing docs and renders a coloured JSON diff per change |
136
+ | `deploy --only <name>` | wired — filters deploy/check/dry-run to one definition by `name` |
137
+ | `start <name>` | wired — calls `workflow.startInstance` (`--field` for input fields) |
138
+ | `list` | wired — `client.fetch` over `sanity.workflow.instance` documents (`--definition <name>` to filter) |
139
+ | `show <instance-id>` | wired — `client.getDocument` |
140
+ | `diagnose <instance-id>` | wired — calls `workflow.diagnose`, classifies why the instance is/isn't progressing |
141
+ | `tail <instance-id>` | wired — `client.listen()` over the instance, prints new history entries |
142
+ | `abort <instance-id>` | wired — calls `workflow.abortInstance` (hard stop: cancels pending effects, lifts guards) |
143
+ | `set-stage <instance-id> --to <stage>` | wired — calls `workflow.setStage` (admin override: skips declared transitions/filters; enter lifecycle + cascade still run) |
144
+ | `fire-action <instance-id>` | wired — `workflow.availableActions` lists actions; `workflow.fireAction` fires one |
145
+ | `definition list` | wired — `client.fetch` over `sanity.workflow.definition` documents |
146
+ | `definition show <name>` | wired — `client.fetch`, latest version unless `--version` |
147
+ | `definition diff <name>` | wired — diffs the in-code definition against the deployed latest (`--version` to pin) |
148
+ | `definition delete <name>` | wired — calls `workflow.deleteDefinition` (refuses on live instances unless `--cascade`) |
149
+ | `nuke --tag <tag>` | wired — dev-period reset (exists only until the versioned upgrade framework): deletes every engine-owned doc for the tag (instances, definitions, guards across resources); plan + typed confirm |
140
150
 
141
151
  ## Telemetry
142
152
 
@@ -5,6 +5,7 @@ interface DefinitionListFlags {
5
5
  limit: number;
6
6
  }
7
7
  export interface DefinitionListRow {
8
+ _id: string;
8
9
  name: string;
9
10
  version: number;
10
11
  title: string;
@@ -13,6 +14,8 @@ export interface DefinitionListRow {
13
14
  inFlightCount: number;
14
15
  totalInstances: number;
15
16
  _createdAt: string;
17
+ modelVersion?: number | null;
18
+ minReaderModel?: number | null;
16
19
  }
17
20
  export declare function buildDefinitionListQuery(flags: DefinitionListFlags): {
18
21
  groq: string;
@@ -1,5 +1,5 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, inFlightFilter, tagScopeFilter, } from '@sanity/workflow-engine';
2
+ import { WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, assertReadableModel, inFlightFilter, tagScopeFilter, } from '@sanity/workflow-engine';
3
3
  import logSymbols from 'log-symbols';
4
4
  import { WorkflowCommand } from "../../lib/base-command.js";
5
5
  import { resolveReadTargets } from "../../lib/context.js";
@@ -25,11 +25,14 @@ export function buildDefinitionListQuery(flags) {
25
25
  }
26
26
  const instanceMatch = instanceFilters.join(' && ');
27
27
  const groq = `*[${filters.join(' && ')}] | order(name asc, version desc) [0...$limit]{
28
+ _id,
28
29
  name,
29
30
  version,
30
31
  title,
31
32
  tag,
32
33
  _createdAt,
34
+ modelVersion,
35
+ minReaderModel,
33
36
  "stageCount": count(stages),
34
37
  "inFlightCount": count(*[${instanceMatch} && ${inFlightFilter()}]),
35
38
  "totalInstances": count(*[${instanceMatch}])
@@ -60,9 +63,9 @@ export default class DefinitionList extends WorkflowCommand {
60
63
  targets,
61
64
  log: (line) => this.log(line),
62
65
  run: async ({ client }) => {
63
- const fetched = await client.fetch(groq, params, {
66
+ const fetched = (await client.fetch(groq, params, {
64
67
  tag: 'definition.list',
65
- });
68
+ })).map(assertReadableModel);
66
69
  if (!fetched.length) {
67
70
  this.log(`${logSymbols.info} no definitions found`);
68
71
  return;
@@ -1,6 +1,6 @@
1
1
  import { styleText } from 'node:util';
2
2
  import { Args, Flags } from '@oclif/core';
3
- import { isTerminalStage, } from '@sanity/workflow-engine';
3
+ import { assertReadableModel, isTerminalStage, } from '@sanity/workflow-engine';
4
4
  import logSymbols from 'log-symbols';
5
5
  import { WorkflowCommand } from "../../lib/base-command.js";
6
6
  import { resolveReadTargets, soleHitOrFail } from "../../lib/context.js";
@@ -64,7 +64,7 @@ export async function fetchDefinitionHits({ targets, groq, params, }) {
64
64
  const def = await target.client.fetch(groq, params, {
65
65
  tag: 'definition.show',
66
66
  });
67
- return def === null ? undefined : { resource: target.resource, def };
67
+ return def === null ? undefined : { resource: target.resource, def: assertReadableModel(def) };
68
68
  }));
69
69
  return probes.filter((hit) => hit !== undefined);
70
70
  }
@@ -8,6 +8,8 @@ interface InstanceRow {
8
8
  completedAt?: string | null;
9
9
  abortedAt?: string | null;
10
10
  lastChangedAt: string;
11
+ modelVersion?: number | null;
12
+ minReaderModel?: number | null;
11
13
  }
12
14
  interface ListFlags {
13
15
  'include-completed': boolean;
@@ -1,5 +1,5 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { WORKFLOW_INSTANCE_TYPE, documentPrefilter, inFlightFilter, instanceWatchesDocument, tagScopeFilter, terminalState, } from '@sanity/workflow-engine';
2
+ import { WORKFLOW_INSTANCE_TYPE, assertReadableModel, documentPrefilter, inFlightFilter, instanceWatchesDocument, tagScopeFilter, terminalState, } from '@sanity/workflow-engine';
3
3
  import logSymbols from 'log-symbols';
4
4
  import { WorkflowCommand } from "../lib/base-command.js";
5
5
  import { resolveReadTargets } from "../lib/context.js";
@@ -36,7 +36,9 @@ export function buildListQuery(flags) {
36
36
  currentStage,
37
37
  completedAt,
38
38
  abortedAt,
39
- lastChangedAt
39
+ lastChangedAt,
40
+ modelVersion,
41
+ minReaderModel
40
42
  }`;
41
43
  return { groq, params };
42
44
  }
@@ -125,8 +127,11 @@ export default class List extends WorkflowCommand {
125
127
  }
126
128
  async function fetchRows({ client, groq, params, document, }) {
127
129
  if (document === undefined) {
128
- return client.fetch(groq, params, { tag: 'list' });
130
+ const rows = await client.fetch(groq, params, { tag: 'list' });
131
+ return rows.map(assertReadableModel);
129
132
  }
130
133
  const candidates = await client.fetch(groq, params, { tag: 'list' });
131
- return candidates.filter((instance) => instanceWatchesDocument(instance, document));
134
+ return candidates
135
+ .map(assertReadableModel)
136
+ .filter((instance) => instanceWatchesDocument(instance, document));
132
137
  }
@@ -0,0 +1,11 @@
1
+ import { WorkflowCommand } from '../lib/base-command.ts';
2
+ export default class Nuke extends WorkflowCommand {
3
+ static summary: string;
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ tag: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,76 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { input } from '@sanity/cli-core/ux';
3
+ import { resourceGdr } from '@sanity/workflow-engine';
4
+ import logSymbols from 'log-symbols';
5
+ import { WorkflowCommand } from "../lib/base-command.js";
6
+ import { clientFor, resolveApiHost, resolveTokenOrFail } from "../lib/client.js";
7
+ import { dedupeResources } from "../lib/context.js";
8
+ import { fail } from "../lib/fail.js";
9
+ import { loadWorkflowConfig } from "../lib/load-config.js";
10
+ import { confirmationMatches, executeNuke, formatNukeSummary, involvedTargets, planCounts, renderNukePlan, resolveNukePlan, } from "../lib/nuke.js";
11
+ import { runWriteVerb } from "../lib/ops-report.js";
12
+ import { canPromptOnStderr } from "../lib/prompt.js";
13
+ import { selectDeployment } from "../lib/select-deployment.js";
14
+ export default class Nuke extends WorkflowCommand {
15
+ static summary = 'Delete every engine-owned document for a deployment tag — the dev-period big red button.';
16
+ static description = 'The reset for a dataset holding engine documents the versioned upgrade framework cannot yet ' +
17
+ "migrate: deletes the tag's instances, definitions, and guards (across every alias-bound " +
18
+ 'resource). Content documents are never touched. Prints a dry-run plan, then requires you to ' +
19
+ 'type back every involved dataset (--force skips the prompt; the plan still prints).';
20
+ static examples = [
21
+ '<%= config.bin %> nuke --tag plugin-dev',
22
+ '<%= config.bin %> nuke --tag plugin-dev --force',
23
+ ];
24
+ static flags = {
25
+ tag: Flags.string({
26
+ description: 'The deployment tag to reset. Required — a destructive reset never guesses the environment.',
27
+ required: true,
28
+ }),
29
+ force: Flags.boolean({
30
+ description: 'Skip the confirmation prompt (for scripts/CI). The plan still prints.',
31
+ default: false,
32
+ }),
33
+ };
34
+ async run() {
35
+ const { flags } = await this.parse(Nuke);
36
+ const config = await loadWorkflowConfig();
37
+ const deployment = selectDeployment(config, { tag: flags.tag });
38
+ const targets = buildTargets(deployment, await resolveTokenOrFail());
39
+ const plan = await resolveNukePlan({ tag: deployment.tag, targets });
40
+ const apiHost = resolveApiHost() ?? 'https://api.sanity.io (production default)';
41
+ renderNukePlan(plan, apiHost).forEach((line) => this.log(line));
42
+ const counts = planCounts(plan);
43
+ if (counts.total === 0) {
44
+ this.log(`${logSymbols.info} Nothing to nuke for tag "${deployment.tag}".`);
45
+ return;
46
+ }
47
+ if (!flags.force)
48
+ await confirmOrFail(involvedTargets(plan));
49
+ await runWriteVerb({
50
+ startLabel: 'Nuking…',
51
+ failLabel: 'Nuke failed',
52
+ failHeadline: 'workflow nuke error:',
53
+ run: () => executeNuke(plan),
54
+ report: () => ({ changed: true, message: formatNukeSummary(deployment.tag, counts) }),
55
+ log: (line) => this.log(line),
56
+ });
57
+ }
58
+ }
59
+ function buildTargets(deployment, token) {
60
+ const engineResource = deployment.workflowResource;
61
+ const aliasResources = (deployment.resourceAliases ?? []).map((binding) => binding.resource);
62
+ return dedupeResources([engineResource, ...aliasResources]).map((resource) => ({
63
+ resource,
64
+ client: clientFor(resource, token),
65
+ holdsEngineDocs: resourceGdr(resource) === resourceGdr(engineResource),
66
+ }));
67
+ }
68
+ async function confirmOrFail(targets) {
69
+ if (!canPromptOnStderr()) {
70
+ fail('Refusing to nuke without confirmation in a non-interactive shell.', 'Re-run with --force to skip the prompt.');
71
+ }
72
+ const answer = await input({ message: `Type every target to confirm deletion (space-separated):\n ${targets.join(' ')}\n` }, { output: process.stderr });
73
+ if (!confirmationMatches(answer, targets)) {
74
+ fail('Confirmation did not match — nothing was deleted.', `Expected: ${targets.join(' ')}`);
75
+ }
76
+ }
@@ -1,4 +1,4 @@
1
- import { type InitialFieldValue, type StartInstanceArgs, type WorkflowInstance } from '@sanity/workflow-engine';
1
+ import { StartNotSettledError, type InitialFieldValue, type StartInstanceArgs, type WorkflowInstance } from '@sanity/workflow-engine';
2
2
  import { WorkflowCommand } from '../lib/base-command.ts';
3
3
  import { type EngineScope } from '../lib/operation-args.ts';
4
4
  export default class Start extends WorkflowCommand {
@@ -11,20 +11,35 @@ export default class Start extends WorkflowCommand {
11
11
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
12
  version: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
13
  field: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
14
+ 'instance-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  };
16
17
  run(): Promise<void>;
17
18
  }
19
+ /** A created-but-unprimed failure is resumable — the engine message names
20
+ * the id and the generic remediation; operators need the CLI's own retry
21
+ * spelling appended. Every other error passes through untouched. */
22
+ export declare function withRetryHint(error: unknown): unknown;
23
+ /** The spinner message for a start whose first cascade failed after the
24
+ * instance was created and primed: the run exists — say so, name the cause,
25
+ * and point at the retry rail instead of reporting a rejection. */
26
+ export declare function startNotSettledReport(error: StartNotSettledError): string;
27
+ /** The `--json` payload for a not-settled start — same `instanceId` key as
28
+ * the success payload so scripts can pick up the retry id either way. */
29
+ export declare function startNotSettledJson(error: StartNotSettledError): Record<string, unknown>;
18
30
  /**
19
31
  * The shared engine args ({@link baseEngineArgs}) plus the start-specific
20
32
  * fields. Optionals spread conditionally because the engine's optional args
21
33
  * reject an explicit `undefined` under `exactOptionalPropertyTypes`.
22
34
  */
23
- export declare function buildStartArgs({ scope, name, version, initialFields, }: {
35
+ export declare function buildStartArgs({ scope, name, version, initialFields, instanceId, }: {
24
36
  scope: EngineScope;
25
37
  name: string;
26
38
  version: number | undefined;
27
39
  initialFields: InitialFieldValue[];
40
+ /** The start's idempotency key (`--instance-id`) — a retry carrying it
41
+ * resumes the earlier attempt instead of creating a duplicate. */
42
+ instanceId?: string | undefined;
28
43
  }): StartInstanceArgs & EngineScope;
29
44
  /** The success spinner message. Pure so the in-flight / immediately-terminal
30
45
  * permutations can be asserted without driving a real start. */
@@ -1,6 +1,6 @@
1
1
  import { styleText } from 'node:util';
2
2
  import { Args, Flags } from '@oclif/core';
3
- import { buildInitialFields, startRefusal, workflow, } from '@sanity/workflow-engine';
3
+ import { buildInitialFields, errorMessage, StartNotPrimedError, StartNotSettledError, startRefusal, workflow, } from '@sanity/workflow-engine';
4
4
  import { WorkflowCommand } from "../lib/base-command.js";
5
5
  import { resolveContext } from "../lib/context.js";
6
6
  import { fetchDeployedDefinition } from "../lib/definitions.js";
@@ -16,6 +16,7 @@ export default class Start extends WorkflowCommand {
16
16
  '<%= config.bin %> start productLaunch',
17
17
  '<%= config.bin %> start article-review --field subject=\'{"id":"dataset:proj:ds:article-1","type":"article"}\'',
18
18
  '<%= config.bin %> start productLaunch --version 2 --tag prod',
19
+ '<%= config.bin %> start productLaunch --instance-id prod.wf-instance.a1b2c3d4e5f6',
19
20
  ];
20
21
  static args = {
21
22
  name: Args.string({ required: true, description: 'Workflow definition name.' }),
@@ -32,6 +33,12 @@ export default class Start extends WorkflowCommand {
32
33
  multiple: true,
33
34
  default: [],
34
35
  }),
36
+ 'instance-id': Flags.string({
37
+ description: "Start under this instance id — for retries. The id is the start's idempotency key: " +
38
+ 'pass the id of a start that failed partway and the engine resumes it instead of ' +
39
+ 'creating a duplicate (an already-settled start replays as a no-op). Omit to mint ' +
40
+ 'a fresh id.',
41
+ }),
35
42
  ...jsonFlags,
36
43
  };
37
44
  async run() {
@@ -52,25 +59,58 @@ export default class Start extends WorkflowCommand {
52
59
  name: args.name,
53
60
  version: flags.version,
54
61
  initialFields,
62
+ instanceId: flags['instance-id'],
55
63
  }),
56
64
  };
57
65
  if (flags.json) {
58
- const result = await workflow
59
- .startInstance(startArgs)
60
- .catch((error) => fail('start error:', failureDetail(error)));
61
- this.log(JSON.stringify(startResultJson(result.instance), null, 2));
66
+ const result = await workflow.startInstance(startArgs).catch((error) => {
67
+ if (error instanceof StartNotSettledError) {
68
+ this.log(JSON.stringify(startNotSettledJson(error), null, 2));
69
+ return undefined;
70
+ }
71
+ fail('start error:', failureDetail(withRetryHint(error)));
72
+ });
73
+ if (result !== undefined) {
74
+ this.log(JSON.stringify(startResultJson(result.instance), null, 2));
75
+ }
62
76
  return;
63
77
  }
64
78
  await runWriteVerb({
65
79
  startLabel: `Starting ${args.name}…`,
66
80
  failLabel: 'Start rejected',
67
81
  failHeadline: 'start error:',
68
- run: () => workflow.startInstance(startArgs),
69
- report: (result) => ({ changed: true, message: startReport(result.instance) }),
82
+ run: () => workflow.startInstance(startArgs).catch((error) => {
83
+ if (error instanceof StartNotSettledError)
84
+ return error;
85
+ throw withRetryHint(error);
86
+ }),
87
+ report: (result) => result instanceof StartNotSettledError
88
+ ? { changed: false, message: startNotSettledReport(result) }
89
+ : { changed: true, message: startReport(result.instance) },
70
90
  log: (line) => this.log(line),
71
91
  });
72
92
  }
73
93
  }
94
+ export function withRetryHint(error) {
95
+ return error instanceof StartNotPrimedError
96
+ ? new Error(`${errorMessage(error)} (--instance-id ${error.instanceId})`)
97
+ : error;
98
+ }
99
+ export function startNotSettledReport(error) {
100
+ const id = styleText('bold', error.instanceId);
101
+ return (`Started ${id} — but its first auto-advance failed: ${errorMessage(error.cause)}. ` +
102
+ `It settles on the next tick, or retry with --instance-id ${error.instanceId}.`);
103
+ }
104
+ export function startNotSettledJson(error) {
105
+ return {
106
+ instanceId: error.instanceId,
107
+ notSettled: true,
108
+ detail: errorMessage(error.cause),
109
+ ...(error.instance !== undefined
110
+ ? { currentStage: error.instance.currentStage, definition: error.instance.definition }
111
+ : {}),
112
+ };
113
+ }
74
114
  async function resolveStartInputs({ client, name, tag, version, values, }) {
75
115
  const deployed = await fetchDeployedDefinition({ client, name, tag, version });
76
116
  if (deployed === undefined) {
@@ -82,12 +122,13 @@ async function resolveStartInputs({ client, name, tag, version, values, }) {
82
122
  }
83
123
  return failOnThrow('start usage error:', () => buildInitialFields({ declared: deployed.fields ?? [], values }));
84
124
  }
85
- export function buildStartArgs({ scope, name, version, initialFields, }) {
125
+ export function buildStartArgs({ scope, name, version, initialFields, instanceId, }) {
86
126
  return {
87
127
  ...baseEngineArgs(scope),
88
128
  definition: name,
89
129
  ...(version !== undefined ? { version } : {}),
90
130
  ...(initialFields.length > 0 ? { initialFields } : {}),
131
+ ...(instanceId !== undefined ? { instanceId } : {}),
91
132
  };
92
133
  }
93
134
  export function startReport(instance) {
@@ -1,6 +1,6 @@
1
1
  import { styleText } from 'node:util';
2
2
  import { Args } from '@oclif/core';
3
- import { displayTitle, errorMessage } from '@sanity/workflow-engine';
3
+ import { assertReadableModel, displayTitle, errorMessage, } from '@sanity/workflow-engine';
4
4
  import logSymbols from 'log-symbols';
5
5
  import { WorkflowCommand } from "../lib/base-command.js";
6
6
  import { findInstance, resolveReadTargets } from "../lib/context.js";
@@ -64,9 +64,10 @@ export default class Tail extends WorkflowCommand {
64
64
  });
65
65
  }
66
66
  async printNewEntries({ client, instanceId, seen, }) {
67
- const next = await client.getDocument(instanceId, { tag: TAIL_TAG });
68
- if (!next)
67
+ const raw = await client.getDocument(instanceId, { tag: TAIL_TAG });
68
+ if (!raw)
69
69
  return seen;
70
+ const next = assertReadableModel(raw);
70
71
  const newEntries = next.history.slice(seen);
71
72
  for (const entry of newEntries) {
72
73
  this.log(`${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${styleText('cyan', displayTitle(entry._type))}`);
@@ -1,5 +1,13 @@
1
1
  import { type SanityClient } from '@sanity/client';
2
2
  import { type WorkflowResource } from '@sanity/workflow-engine';
3
+ /**
4
+ * The API host to hit: an explicit `SANITY_API_HOST` wins; otherwise mirror
5
+ * the Sanity CLI — staging routes to the staging API, prod falls through to
6
+ * `@sanity/client`'s default (`undefined` here). Pairs with
7
+ * {@link resolveToken}, which reads the matching session token. Exported so a
8
+ * destructive command can display the environment its clients will hit.
9
+ */
10
+ export declare function resolveApiHost(): string | undefined;
3
11
  /**
4
12
  * Resolve the Sanity auth token. An explicit `SANITY_AUTH_TOKEN` wins (CI /
5
13
  * scripted use); otherwise fall back to the `sanity login` session token.
@@ -5,7 +5,7 @@ import { ENV, envAuthToken } from "./env.js";
5
5
  import { fail } from "./fail.js";
6
6
  const API_VERSION = ENGINE_API_VERSION;
7
7
  const STAGING_API_HOST = 'https://api.sanity.work';
8
- function resolveApiHost() {
8
+ export function resolveApiHost() {
9
9
  return process.env[ENV.SANITY_API_HOST] ?? (isStaging() ? STAGING_API_HOST : undefined);
10
10
  }
11
11
  export async function resolveToken() {
@@ -1,5 +1,5 @@
1
1
  import type { SanityClient } from '@sanity/client';
2
- import type { WorkflowConfig, WorkflowDeployment, WorkflowInstance, WorkflowResource } from '@sanity/workflow-engine';
2
+ import { type WorkflowConfig, type WorkflowDeployment, type WorkflowInstance, type WorkflowResource } from '@sanity/workflow-engine';
3
3
  import type { EngineScope } from './operation-args.ts';
4
4
  export interface DeploymentContext {
5
5
  deployment: WorkflowDeployment;
@@ -40,9 +40,13 @@ export interface ReadTarget {
40
40
  export declare function resolveReadTargets(flags: {
41
41
  tag?: string | undefined;
42
42
  }): Promise<ReadTarget[]>;
43
+ /** Distinct resources, keyed by their resource-shaped GDR (`<type>:<id>`).
44
+ * The first occurrence wins its position, so a caller-ordered list (e.g. the
45
+ * engine resource first) keeps its lead entry. */
46
+ export declare function dedupeResources(resources: WorkflowResource[]): WorkflowResource[];
43
47
  /** The distinct resources a read targets: the tagged deployment's sole
44
48
  * resource, or — untagged — every distinct one the config's deployments
45
- * mention (deduped by `type:id`). */
49
+ * mention ({@link dedupeResources}). */
46
50
  export declare function resolveReadResources(config: WorkflowConfig, tag: string | undefined): WorkflowResource[];
47
51
  /** The single resource a read should target, or fail asking which when the
48
52
  * config spans more than one. For the paths that need one definite dataset
@@ -1,3 +1,4 @@
1
+ import { assertReadableModel, resourceGdr, } from '@sanity/workflow-engine';
1
2
  import { clientFor, resolveTokenOrFail } from "./client.js";
2
3
  import { fail } from "./fail.js";
3
4
  import { loadWorkflowConfig } from "./load-config.js";
@@ -14,15 +15,14 @@ export async function resolveReadTargets(flags) {
14
15
  const token = await resolveTokenOrFail();
15
16
  return resources.map((resource) => ({ resource, client: clientFor(resource, token) }));
16
17
  }
18
+ export function dedupeResources(resources) {
19
+ return [...new Map(resources.map((resource) => [resourceGdr(resource), resource])).values()];
20
+ }
17
21
  export function resolveReadResources(config, tag) {
18
22
  if (tag !== undefined) {
19
23
  return [selectDeployment(config, { tag }).workflowResource];
20
24
  }
21
- const byKey = new Map(config.deployments.map((d) => [
22
- `${d.workflowResource.type}:${d.workflowResource.id}`,
23
- d.workflowResource,
24
- ]));
25
- const resources = [...byKey.values()];
25
+ const resources = dedupeResources(config.deployments.map((d) => d.workflowResource));
26
26
  if (resources.length === 0) {
27
27
  fail('No deployments configured.');
28
28
  }
@@ -51,7 +51,7 @@ export async function loadInstanceOrFail(client, instanceId) {
51
51
  if (!instance) {
52
52
  fail(`Workflow instance ${instanceId} not found`);
53
53
  }
54
- return instance;
54
+ return assertReadableModel(instance);
55
55
  }
56
56
  export function soleHitOrFail(hits, subject) {
57
57
  const [sole, ...rest] = hits;
@@ -65,7 +65,9 @@ export async function findInstance({ targets, instanceId, requestTag, }) {
65
65
  const instance = await target.client.getDocument(instanceId, {
66
66
  tag: requestTag,
67
67
  });
68
- return instance === undefined ? undefined : { ...target, instance };
68
+ return instance === undefined
69
+ ? undefined
70
+ : { ...target, instance: assertReadableModel(instance) };
69
71
  }));
70
72
  const hits = probes
71
73
  .filter((probe) => probe.status === 'fulfilled')
@@ -1,4 +1,4 @@
1
- import { WORKFLOW_DEFINITION_TYPE, errorMessage, tagScopeFilter, validateDefinition, } from '@sanity/workflow-engine';
1
+ import { WORKFLOW_DEFINITION_TYPE, assertReadableModel, errorMessage, tagScopeFilter, validateDefinition, } from '@sanity/workflow-engine';
2
2
  import { fail } from "./fail.js";
3
3
  export function buildDefinitionShowQuery(flags) {
4
4
  const params = { name: flags.name };
@@ -28,7 +28,7 @@ export async function fetchDeployedDefinition({ client, name, tag, version, }) {
28
28
  if (deployed === null && version !== undefined) {
29
29
  fail(`No deployed definition "${name}" v${version}.`);
30
30
  }
31
- return deployed ?? undefined;
31
+ return deployed === null ? undefined : assertReadableModel(deployed);
32
32
  }
33
33
  export function selectDefinitions(allDefinitions, { only, context = '' }) {
34
34
  if (!only) {
@@ -0,0 +1,89 @@
1
+ import { type WorkflowClient, type WorkflowResource } from '@sanity/workflow-engine';
2
+ /** A resource a nuke may delete from, with the client that reads/writes it and
3
+ * whether it is the engine's own resource — the only one that holds instances
4
+ * and definitions (guards can co-locate with subjects in any resource). */
5
+ export interface NukeTarget {
6
+ resource: WorkflowResource;
7
+ client: WorkflowClient;
8
+ holdsEngineDocs: boolean;
9
+ }
10
+ /** One resolved resource of a {@link NukePlan}: the ids to delete, by doc type. */
11
+ export interface NukeResourcePlan {
12
+ resource: WorkflowResource;
13
+ /** The `project.dataset` (or `<type>:<id>`) label the operator confirms by. */
14
+ label: string;
15
+ client: WorkflowClient;
16
+ holdsEngineDocs: boolean;
17
+ instanceIds: string[];
18
+ definitionIds: string[];
19
+ guardIds: string[];
20
+ }
21
+ /** The dry-run plan: what a `nuke --tag <tag>` would delete, per resource. */
22
+ export interface NukePlan {
23
+ tag: string;
24
+ resources: NukeResourcePlan[];
25
+ }
26
+ /** Per-doc-type totals across the whole plan, plus the count of resources that
27
+ * actually hold something to delete ({@link involvedTargets}). */
28
+ export interface PlanCounts {
29
+ instances: number;
30
+ definitions: number;
31
+ guards: number;
32
+ datasets: number;
33
+ total: number;
34
+ }
35
+ /**
36
+ * Resolve what a tag-scoped nuke would delete, per resource — the dry-run plan
37
+ * printed before any deletion. Instances and definitions are read only from the
38
+ * engine's own resource; guards are read from every target, since a guard
39
+ * co-locates with the subject it locks and may live in a foreign dataset.
40
+ *
41
+ * Every read uses the raw perspective, so a draft-form or otherwise
42
+ * perspective-hidden copy is still surfaced (and swept).
43
+ */
44
+ export declare function resolveNukePlan(args: {
45
+ tag: string;
46
+ targets: NukeTarget[];
47
+ }): Promise<NukePlan>;
48
+ /**
49
+ * Does a guard doc belong to `tag`? A guard's `sourceInstanceId` is the id of
50
+ * the instance that registered it (`<tag>.wf-instance.<random>`), and its own
51
+ * `_id` embeds that same instance id
52
+ * (`temp.system.guard.<tag>.wf-instance.<random>.<name>`). Matching on either —
53
+ * with the trailing `.` so `prod` never matches `prod-eu` — catches guards
54
+ * whose `sourceInstanceId` was never written as well as guards whose instance
55
+ * is already gone.
56
+ */
57
+ export declare function guardMatchesTag(guard: {
58
+ _id: string;
59
+ sourceInstanceId?: string;
60
+ }, tag: string): boolean;
61
+ /** The `project.dataset` labels of resources the plan actually deletes from —
62
+ * the exact set the operator must type back to confirm. A resource with
63
+ * nothing to delete is not "involved" and is omitted. */
64
+ export declare function involvedTargets(plan: NukePlan): string[];
65
+ /** Roll the plan up into per-doc-type totals for the summary and the
66
+ * nothing-to-do short-circuit. */
67
+ export declare function planCounts(plan: NukePlan): PlanCounts;
68
+ /**
69
+ * Does the operator's typed confirmation name every involved target, and only
70
+ * those? Order-independent, whitespace-tolerant set equality: the operator
71
+ * pastes back all `project.dataset` targets the plan listed.
72
+ */
73
+ export declare function confirmationMatches(input: string, targets: string[]): boolean;
74
+ /** The dry-run plan as printable lines: a heading, the content-safety
75
+ * invariant, the API host the deletes will hit (plan rows alone can't tell
76
+ * same-named projects on different environments apart), and a per-resource,
77
+ * per-doc-type count table. A guard-only foreign resource shows `—` for the
78
+ * engine-only columns, since those docs can't live there. */
79
+ export declare function renderNukePlan(plan: NukePlan, apiHost: string): string[];
80
+ /** The success line: what was deleted, and across how many datasets. */
81
+ export declare function formatNukeSummary(tag: string, counts: PlanCounts): string;
82
+ /**
83
+ * Delete exactly what the plan printed, in uniform waves over every resource.
84
+ * Wave order is referrers-first — instances, then definitions, then guards —
85
+ * a nicety for an interrupted run (engine docs link by plain strings, so the
86
+ * lake enforces no order); non-engine resources simply carry empty
87
+ * instance/definition lists. Idempotent: an empty plan deletes nothing.
88
+ */
89
+ export declare function executeNuke(plan: NukePlan): Promise<void>;
@@ -0,0 +1,111 @@
1
+ import { styleText } from 'node:util';
2
+ import { GUARD_DOC_TYPE, WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, tagScopeFilter, } from '@sanity/workflow-engine';
3
+ import { formatTable, resourceLabel, sectionHeader } from "./ui.js";
4
+ const CHUNK = 200;
5
+ const REQUEST_TAG = 'nuke';
6
+ export async function resolveNukePlan(args) {
7
+ const { tag, targets } = args;
8
+ const resources = await Promise.all(targets.map((target) => resolveResourcePlan({ tag, target })));
9
+ return { tag, resources };
10
+ }
11
+ async function resolveResourcePlan(args) {
12
+ const { tag, target } = args;
13
+ const { resource, client, holdsEngineDocs } = target;
14
+ const noIds = [];
15
+ const [instanceIds, definitionIds, guardIds] = await Promise.all([
16
+ holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_INSTANCE_TYPE, tag }) : noIds,
17
+ holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_DEFINITION_TYPE, tag }) : noIds,
18
+ tagGuardIds({ client, tag }),
19
+ ]);
20
+ return {
21
+ resource,
22
+ label: resourceLabel(resource),
23
+ client,
24
+ holdsEngineDocs,
25
+ instanceIds,
26
+ definitionIds,
27
+ guardIds,
28
+ };
29
+ }
30
+ function engineDocIds(args) {
31
+ return args.client.fetch(`*[_type == $type && ${tagScopeFilter()}]._id`, { type: args.type, tag: args.tag }, { perspective: 'raw', tag: REQUEST_TAG });
32
+ }
33
+ async function tagGuardIds(args) {
34
+ const guards = await args.client.fetch(`*[_type == $type]{_id, sourceInstanceId}`, { type: GUARD_DOC_TYPE }, { perspective: 'raw', tag: REQUEST_TAG });
35
+ return guards.filter((guard) => guardMatchesTag(guard, args.tag)).map((guard) => guard._id);
36
+ }
37
+ export function guardMatchesTag(guard, tag) {
38
+ const instancePrefix = `${tag}.wf-instance.`;
39
+ const guardIdPrefix = `${GUARD_DOC_TYPE}.${instancePrefix}`;
40
+ return ((guard.sourceInstanceId?.startsWith(instancePrefix) ?? false) ||
41
+ guard._id.startsWith(guardIdPrefix));
42
+ }
43
+ export function involvedTargets(plan) {
44
+ return plan.resources
45
+ .filter((resource) => resourceTotal(resource) > 0)
46
+ .map((resource) => resource.label);
47
+ }
48
+ function resourceTotal(resource) {
49
+ return resource.instanceIds.length + resource.definitionIds.length + resource.guardIds.length;
50
+ }
51
+ export function planCounts(plan) {
52
+ const totals = plan.resources.reduce((acc, resource) => ({
53
+ instances: acc.instances + resource.instanceIds.length,
54
+ definitions: acc.definitions + resource.definitionIds.length,
55
+ guards: acc.guards + resource.guardIds.length,
56
+ }), { instances: 0, definitions: 0, guards: 0 });
57
+ return {
58
+ ...totals,
59
+ datasets: involvedTargets(plan).length,
60
+ total: totals.instances + totals.definitions + totals.guards,
61
+ };
62
+ }
63
+ export function confirmationMatches(input, targets) {
64
+ const typed = new Set(input.trim().split(/\s+/).filter(Boolean));
65
+ const expected = new Set(targets);
66
+ return typed.size === expected.size && [...expected].every((target) => typed.has(target));
67
+ }
68
+ export function renderNukePlan(plan, apiHost) {
69
+ const rows = plan.resources.map((resource) => [
70
+ resource.label,
71
+ resource.holdsEngineDocs ? String(resource.instanceIds.length) : styleText('dim', '—'),
72
+ resource.holdsEngineDocs ? String(resource.definitionIds.length) : styleText('dim', '—'),
73
+ String(resource.guardIds.length),
74
+ ]);
75
+ return [
76
+ sectionHeader(`nuke — tag "${plan.tag}"`),
77
+ styleText('dim', 'engine-owned documents only — content is never touched'),
78
+ styleText('dim', `API host: ${apiHost}`),
79
+ ...formatTable(['target', 'instances', 'definitions', 'guards'], rows),
80
+ ];
81
+ }
82
+ export function formatNukeSummary(tag, counts) {
83
+ const parts = [
84
+ pluralize(counts.instances, 'instance'),
85
+ pluralize(counts.definitions, 'definition'),
86
+ pluralize(counts.guards, 'guard'),
87
+ ].join(', ');
88
+ return `Nuked tag "${tag}": ${parts} across ${pluralize(counts.datasets, 'dataset')}.`;
89
+ }
90
+ function pluralize(count, noun) {
91
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
92
+ }
93
+ export async function executeNuke(plan) {
94
+ for (const resource of plan.resources) {
95
+ await deleteIds(resource.client, resource.instanceIds);
96
+ }
97
+ for (const resource of plan.resources) {
98
+ await deleteIds(resource.client, resource.definitionIds);
99
+ }
100
+ for (const resource of plan.resources) {
101
+ await deleteIds(resource.client, resource.guardIds);
102
+ }
103
+ }
104
+ async function deleteIds(client, ids) {
105
+ for (let start = 0; start < ids.length; start += CHUNK) {
106
+ const tx = client.transaction();
107
+ for (const id of ids.slice(start, start + CHUNK))
108
+ tx.delete(id);
109
+ await tx.commit({ tag: REQUEST_TAG });
110
+ }
111
+ }
@@ -3,7 +3,9 @@ import { type EngineScopeArgs, type OperationArgs, type WorkflowTelemetryLogger
3
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
- * so commands pass their resolved deployment straight through.
6
+ * so commands pass their resolved deployment straight through. The CLI passes
7
+ * no `resourceClients`, so its writes admit runtime-supplied refs to the
8
+ * workflow resource only.
7
9
  */
8
10
  export type EngineScope = Pick<EngineScopeArgs, 'tag' | 'workflowResource'>;
9
11
  /**
@@ -38,7 +38,8 @@ export declare function cascadeTail(cascaded: number): string;
38
38
  * emit the report — warn and bail on a no-op (`changed: false`), otherwise
39
39
  * succeed and print the ops block. A throw fails the spinner and exits
40
40
  * through {@link fail}. The one spinner + report + catch shape every write
41
- * command (abort, set-stage, fire-action, start, definition delete) shares.
41
+ * command (abort, set-stage, fire-action, start, definition delete, nuke)
42
+ * shares.
42
43
  */
43
44
  export declare function runWriteVerb<T>({ startLabel, failLabel, failHeadline, run, report, log, }: {
44
45
  startLabel: string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Can this process ask a question on stderr? A strict superset of the
3
+ * {@link isInteractive} gate cli-core's own prompts enforce (stdin TTY,
4
+ * `TERM` not dumb, no `CI` — so its `NonInteractiveError` can never escape a
5
+ * caller that checked here first), adding stderr-TTY because this package's
6
+ * prompts render on stderr: a redirected stderr must refuse rather than block
7
+ * on stdin with the question swallowed. The one predicate every prompt site
8
+ * (the deploy consent prompt, nuke's confirmation) shares.
9
+ */
10
+ export declare function canPromptOnStderr(): boolean;
@@ -0,0 +1,4 @@
1
+ import { isInteractive } from '@sanity/cli-core';
2
+ export function canPromptOnStderr() {
3
+ return isInteractive() && process.stderr.isTTY === true;
4
+ }
@@ -1,8 +1,9 @@
1
1
  import { styleText } from 'node:util';
2
- import { getUserConfig, isInteractive } from '@sanity/cli-core';
2
+ import { getUserConfig } from '@sanity/cli-core';
3
3
  import { confirm } from '@sanity/cli-core/ux';
4
- import { definitionDeployedData, errorMessage, isTelemetryEnvDenied, } from '@sanity/workflow-engine';
4
+ import { assertReadableModel, definitionDeployedData, errorMessage, isTelemetryEnvDenied, } from '@sanity/workflow-engine';
5
5
  import { buildDefinitionShowQuery } from "./definitions.js";
6
+ import { canPromptOnStderr } from "./prompt.js";
6
7
  import { cliTelemetry, WorkflowDefinitionShared, WorkflowDefinitionSharingDecided, } from "./telemetry.js";
7
8
  export const SHARE_ENDPOINT_URI = '/workflow/definition-feedback';
8
9
  const SHARE_TAG = 'definition.share';
@@ -70,6 +71,7 @@ async function loadDeployedForShare(args) {
70
71
  warn(`Could not read back ${result.name} v${result.version} — not shared.`);
71
72
  return undefined;
72
73
  }
74
+ assertReadableModel(deployed);
73
75
  return deployed;
74
76
  }
75
77
  catch (error) {
@@ -171,7 +173,7 @@ export async function shareDefinitionsAfterDeploy(args) {
171
173
  try {
172
174
  const { share, decision } = await resolveShareIntent({
173
175
  flag: args.flag,
174
- interactive: args.interactive ?? (isInteractive() && process.stderr.isTTY === true),
176
+ interactive: args.interactive ?? canPromptOnStderr(),
175
177
  envDenied: args.envDenied ?? isTelemetryEnvDenied(process.env),
176
178
  userConfig: args.userConfig ?? getUserConfig(),
177
179
  writeStderr: args.writeStderr ?? ((message) => void process.stderr.write(message)),
@@ -290,6 +290,45 @@
290
290
  "list.js"
291
291
  ]
292
292
  },
293
+ "nuke": {
294
+ "aliases": [],
295
+ "args": {},
296
+ "description": "The reset for a dataset holding engine documents the versioned upgrade framework cannot yet migrate: deletes the tag's instances, definitions, and guards (across every alias-bound resource). Content documents are never touched. Prints a dry-run plan, then requires you to type back every involved dataset (--force skips the prompt; the plan still prints).",
297
+ "examples": [
298
+ "<%= config.bin %> nuke --tag plugin-dev",
299
+ "<%= config.bin %> nuke --tag plugin-dev --force"
300
+ ],
301
+ "flags": {
302
+ "tag": {
303
+ "description": "The deployment tag to reset. Required — a destructive reset never guesses the environment.",
304
+ "name": "tag",
305
+ "required": true,
306
+ "hasDynamicHelp": false,
307
+ "multiple": false,
308
+ "type": "option"
309
+ },
310
+ "force": {
311
+ "description": "Skip the confirmation prompt (for scripts/CI). The plan still prints.",
312
+ "name": "force",
313
+ "allowNo": false,
314
+ "type": "boolean"
315
+ }
316
+ },
317
+ "hasDynamicHelp": false,
318
+ "hiddenAliases": [],
319
+ "id": "nuke",
320
+ "pluginAlias": "@sanity/workflow-cli",
321
+ "pluginName": "@sanity/workflow-cli",
322
+ "pluginType": "core",
323
+ "strict": true,
324
+ "summary": "Delete every engine-owned document for a deployment tag — the dev-period big red button.",
325
+ "isESM": true,
326
+ "relativePath": [
327
+ "dist",
328
+ "commands",
329
+ "nuke.js"
330
+ ]
331
+ },
293
332
  "reset-activity": {
294
333
  "aliases": [],
295
334
  "args": {
@@ -434,7 +473,8 @@
434
473
  "examples": [
435
474
  "<%= config.bin %> start productLaunch",
436
475
  "<%= config.bin %> start article-review --field subject='{\"id\":\"dataset:proj:ds:article-1\",\"type\":\"article\"}'",
437
- "<%= config.bin %> start productLaunch --version 2 --tag prod"
476
+ "<%= config.bin %> start productLaunch --version 2 --tag prod",
477
+ "<%= config.bin %> start productLaunch --instance-id prod.wf-instance.a1b2c3d4e5f6"
438
478
  ],
439
479
  "flags": {
440
480
  "tag": {
@@ -459,6 +499,13 @@
459
499
  "multiple": true,
460
500
  "type": "option"
461
501
  },
502
+ "instance-id": {
503
+ "description": "Start under this instance id — for retries. The id is the start's idempotency key: pass the id of a start that failed partway and the engine resumes it instead of creating a duplicate (an already-settled start replays as a no-op). Omit to mint a fresh id.",
504
+ "name": "instance-id",
505
+ "hasDynamicHelp": false,
506
+ "multiple": false,
507
+ "type": "option"
508
+ },
462
509
  "json": {
463
510
  "description": "Emit structured JSON instead of rendered output.",
464
511
  "name": "json",
@@ -709,5 +756,5 @@
709
756
  ]
710
757
  }
711
758
  },
712
- "version": "0.11.0"
759
+ "version": "0.12.0"
713
760
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/workflow-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.",
5
5
  "keywords": [
6
6
  "cli",
@@ -56,15 +56,15 @@
56
56
  "jiti": "^2.7.0",
57
57
  "log-symbols": "^7.0.1",
58
58
  "ora": "^9.4.0",
59
- "@sanity/workflow-engine": "0.16.0"
59
+ "@sanity/workflow-engine": "0.17.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/diff": "^8.0.0",
63
63
  "@types/node": "^24.12.4",
64
64
  "oclif": "^4.23.16",
65
65
  "vitest": "^4.1.8",
66
- "@sanity/workflow-engine-test": "0.11.0",
67
- "@sanity/workflow-examples": "0.6.0"
66
+ "@sanity/workflow-engine-test": "0.12.0",
67
+ "@sanity/workflow-examples": "0.7.0"
68
68
  },
69
69
  "oclif": {
70
70
  "bin": "sanity-workflows",