@sanity/workflow-cli 0.23.0 → 0.25.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,63 @@
1
1
  # @sanity/workflow-cli
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 177d600: Deployed workflow environment tags are now discoverable.
8
+
9
+ The engine gains two cross-partition GROQ builders: `deployedTagsGroq()` lists
10
+ every tag holding a deployed definition in a resource, and
11
+ `definitionTagsGroq()` narrows that to one definition name via `$definition`.
12
+ Both deliberately span tag partitions — every other read is tag-scoped, and
13
+ `workflow.query` refuses GROQ that isn't — so they answer "which environments
14
+ exist here" for a caller holding a resource but no tag. They report what is
15
+ _observed_: a tag with nothing deployed does not appear.
16
+
17
+ The stdio MCP server adds a `list_workflow_tags` tool taking a
18
+ `workflow_resource` and no tag. It is registered by the host rather than exported
19
+ as a tool def, because a def is only ever handed an engine and an engine is
20
+ pinned to one tag. `LIST_WORKFLOW_TAGS_TOOL_NAME` and
21
+ `LIST_WORKFLOW_TAGS_DESCRIPTION` are exported so an embedding host registers the
22
+ same capability under the same name without re-authoring the model-facing
23
+ wording. The `tag` parameter's description now points an agent at that tool where
24
+ a server offers it and at the user otherwise, and requires confirmation either
25
+ way — having the list does not license picking from it.
26
+
27
+ Both registration paths share one outcome path, `withToolTelemetry`, so the
28
+ result envelope, error rendering, and the `Editorial Workflows MCP Tool Called`
29
+ event cannot diverge between a def-backed tool and a host-registered one. Tag
30
+ discovery reports its adoption event like every other tool, and because it names
31
+ a resource it can also initialize the stdio server's deferred telemetry shell —
32
+ which matters when discovery is an agent's first call.
33
+
34
+ The CLI's definition-tags probe runs the engine's builder instead of its own
35
+ copy of the query, so its `params` key is `definition` rather than `name`, and
36
+ the tag partitions it names in an ambiguity error arrive sorted from the lake.
37
+
38
+ ### Patch Changes
39
+
40
+ - Updated dependencies [177d600]
41
+ - Updated dependencies [fc12989]
42
+ - @sanity/workflow-engine@0.25.0
43
+
44
+ ## 0.24.0
45
+
46
+ ### Minor Changes
47
+
48
+ - e359f4b: Add `nuke --instance <id>`: delete a single terminal workflow instance and its guard documents (swept across every alias-bound resource), instead of wiping a whole deployment tag. In-flight instances are refused — abort them first, and so is any document that is not an engine instance, so a bare id can never reach co-located content. Prints a dry-run plan, then a `y/N` confirmation (`--force` skips it).
49
+
50
+ Instance mode locates its target by lake-side projection rather than a gated engine read, so it can delete an instance whose reader floor is ahead of the running engine — the state the reset exists to clear, and which tag mode could already handle.
51
+
52
+ Instance mode deletes the instance document in its LAST wave, after the guards. A nuke is never a single transaction, so an interrupted run always leaves something behind — and that document is the handle a re-run locates by, so deleting it first would strand any guard the run had not yet reached with nothing left to name them. Tag mode keeps deleting instance documents first, unchanged: it re-plans from a query, so a re-run sweeps whatever survived either way.
53
+
54
+ ### Patch Changes
55
+
56
+ - fc02505: `deploy --only` now fails fast: the definition name is checked against the config before any deployment is selected, so a bogus value errors immediately instead of after the interactive deployment picker has been answered. When the value names a deployment rather than a definition, the failure points at the flag it belongs to — `"review-prod" is a deployment — did you mean --deployment review-prod?`.
57
+ - Updated dependencies [0bc4328]
58
+ - Updated dependencies [41a3f02]
59
+ - @sanity/workflow-engine@0.24.0
60
+
3
61
  ## 0.23.0
4
62
 
5
63
  ### Minor Changes
package/README.md CHANGED
@@ -156,27 +156,28 @@ stale compiled output.
156
156
 
157
157
  ## Command status
158
158
 
159
- | Command | Status |
160
- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
- | `deploy` | wired — calls `workflow.deployDefinitions` over the selected deployment's definitions |
162
- | `deploy --all-tags` | wired — deploys every deployment in the config, continuing past per-deployment failures |
163
- | `deploy --check` | wired — runs `validateDefinition` over the local batch + a duplicate-name check |
164
- | `deploy --dry-run` | wired — fetches existing docs and renders a coloured JSON diff per change |
165
- | `deploy --only <name>` | wired — filters deploy/check/dry-run to one definition by `name` |
166
- | `start <name>` | wired — calls `workflow.startInstance` (`--field` for input fields) |
167
- | `list` | wired — `client.fetch` over `sanity.workflow.instance` documents (`--definition <name>` to filter) |
168
- | `show <instance-id>` | wired — `client.getDocument` |
169
- | `diagnose <instance-id>` | wired — calls `workflow.diagnose`, classifies why the instance is/isn't progressing |
170
- | `tail <instance-id>` | wired — `client.listen()` over the instance, prints new history entries |
171
- | `abort <instance-id>` | wired — calls `workflow.abortInstance` (hard stop: cancels pending effects, removes guards) |
172
- | `set-stage <instance-id> --to <stage>` | wired — calls `workflow.setStage` (admin override: skips declared transitions/filters; enter lifecycle + cascade still run) |
173
- | `reset-activity <instance-id> <activity>` | wired — calls `workflow.resetActivity` (recovery override: reset a failed activity to `active`, or `--skip` to bypass it; then cascade) |
174
- | `fire-action <instance-id>` | wired — `workflow.availableActions` lists actions; `workflow.fireAction` fires one |
175
- | `definition list` | wired — `client.fetch` over `sanity.workflow.definition` documents |
176
- | `definition show <name>` | wired — `client.fetch`, latest version unless `--version` |
177
- | `definition diff <name>` | wired — diffs the in-code definition against the deployed latest (`--version` to pin) |
178
- | `definition delete <name>` | wired — calls `workflow.deleteDefinition` (refuses on live instances unless `--cascade`) |
179
- | `nuke --deployment <name>` / `nuke --tag <tag>` | wired — dev-period reset (exists only until the versioned upgrade framework): deletes every engine-owned doc for the deployment's tag (instances, definitions, guards across resources); exactly one selector required; refuses while another same-tag deployment sweeps an overlapping resource (guard ids embed only the tag); plan + typed confirm |
159
+ | Command | Status |
160
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
161
+ | `deploy` | wired — calls `workflow.deployDefinitions` over the selected deployment's definitions |
162
+ | `deploy --all-tags` | wired — deploys every deployment in the config, continuing past per-deployment failures |
163
+ | `deploy --check` | wired — runs `validateDefinition` over the local batch + a duplicate-name check |
164
+ | `deploy --dry-run` | wired — fetches existing docs and renders a coloured JSON diff per change |
165
+ | `deploy --only <name>` | wired — filters deploy/check/dry-run to one definition by `name` |
166
+ | `start <name>` | wired — calls `workflow.startInstance` (`--field` for input fields) |
167
+ | `list` | wired — `client.fetch` over `sanity.workflow.instance` documents (`--definition <name>` to filter) |
168
+ | `show <instance-id>` | wired — `client.getDocument` |
169
+ | `diagnose <instance-id>` | wired — calls `workflow.diagnose`, classifies why the instance is/isn't progressing |
170
+ | `tail <instance-id>` | wired — `client.listen()` over the instance, prints new history entries |
171
+ | `abort <instance-id>` | wired — calls `workflow.abortInstance` (hard stop: cancels pending effects, removes guards) |
172
+ | `set-stage <instance-id> --to <stage>` | wired — calls `workflow.setStage` (admin override: skips declared transitions/filters; enter lifecycle + cascade still run) |
173
+ | `reset-activity <instance-id> <activity>` | wired — calls `workflow.resetActivity` (recovery override: reset a failed activity to `active`, or `--skip` to bypass it; then cascade) |
174
+ | `fire-action <instance-id>` | wired — `workflow.availableActions` lists actions; `workflow.fireAction` fires one |
175
+ | `definition list` | wired — `client.fetch` over `sanity.workflow.definition` documents |
176
+ | `definition show <name>` | wired — `client.fetch`, latest version unless `--version` |
177
+ | `definition diff <name>` | wired — diffs the in-code definition against the deployed latest (`--version` to pin) |
178
+ | `definition delete <name>` | wired — calls `workflow.deleteDefinition` (refuses on live instances unless `--cascade`) |
179
+ | `nuke --deployment <name>` / `nuke --tag <tag>` | wired — dev-period reset (exists only until the versioned upgrade framework): deletes every engine-owned doc for the deployment's tag (instances, definitions, guards across resources); exactly one of these two selectors required; refuses while another same-tag deployment sweeps an overlapping resource (guard ids embed only the tag); plan + typed confirm |
180
+ | `nuke --instance <id>` | wired — dev-period reset, same fence as the tag mode above: deletes ONE terminal instance plus the guards it owns, swept across every alias-bound resource (matched by `sourceInstanceId`, with an id-prefix leg as redundant cover; matching provenance rather than the live instance set is what catches a partial-delete orphan); definitions are never in scope; refuses an in-flight instance (abort it first) and anything that is not an engine instance document; takes no `--tag`, and `--deployment` only to disambiguate a tag spanning deployments; plan + `y/N` confirm |
180
181
 
181
182
  ## Telemetry
182
183
 
@@ -61,7 +61,7 @@ async function assertSingleTagPartition({ targets, name, }) {
61
61
  const tags = await target.client.fetch(groq, params, { tag: 'definition.show' });
62
62
  if (tags.length > 1) {
63
63
  fail(`Definition "${name}" exists in several tag partitions in ` +
64
- `${resourceLabel(target.resource)} — pass --tag to choose one.`, tags.toSorted().join('\n'));
64
+ `${resourceLabel(target.resource)} — pass --tag to choose one.`, tags.join('\n'));
65
65
  }
66
66
  }));
67
67
  }
@@ -1,5 +1,6 @@
1
- import { type DeployDefinitionResult, type WorkflowDefinition, type WorkflowDeployment } from '@sanity/workflow-engine';
1
+ import { type DeployDefinitionResult, type WorkflowConfig, type WorkflowDefinition, type WorkflowDeployment } from '@sanity/workflow-engine';
2
2
  import { WorkflowCommand } from '../../lib/base-command.ts';
3
+ import { type DeploymentSelectionOptions } from '../../lib/select-deployment.ts';
3
4
  /** A deployment paired with the definitions selected + validated for it. */
4
5
  interface DeployBatch {
5
6
  deployment: WorkflowDeployment;
@@ -33,6 +34,15 @@ export default class Deploy extends WorkflowCommand {
33
34
  }
34
35
  /** Reject the contradictory `--check --dry-run` combination. */
35
36
  export declare function validateModeFlags(check: boolean, dryRun: boolean): void;
37
+ /**
38
+ * The batches a run acts on. The order is load-bearing: gating `--only` before
39
+ * selection is what spares the user an interactive deployment picker they were
40
+ * always going to fail behind.
41
+ */
42
+ export declare function resolveBatches(config: WorkflowConfig, { only, selection }: {
43
+ only: string | undefined;
44
+ selection: DeploymentSelectionOptions;
45
+ }): Promise<DeployBatch[]>;
36
46
  /**
37
47
  * Select and validate every deployment's definitions up front. Validation runs
38
48
  * before any network write ({@link validateOrFail} exits on a bad definition),
@@ -5,7 +5,7 @@ import logSymbols from 'log-symbols';
5
5
  import ora from 'ora';
6
6
  import { WorkflowCommand } from "../../lib/base-command.js";
7
7
  import { clientFor, resolveTokenOrFail } from "../../lib/client.js";
8
- import { selectDefinitions, validateOrFail } from "../../lib/definitions.js";
8
+ import { assertKnownDefinition, selectDefinitions, validateOrFail } from "../../lib/definitions.js";
9
9
  import { diffReport } from "../../lib/diff.js";
10
10
  import { fail, isAuthRejection } from "../../lib/fail.js";
11
11
  import { deploymentFlags } from "../../lib/flags.js";
@@ -45,7 +45,7 @@ export default class Deploy extends WorkflowCommand {
45
45
  default: false,
46
46
  }),
47
47
  only: Flags.string({
48
- description: 'Limit deploy/check/diff to a single workflow definition by name. Every targeted deployment must contain it.',
48
+ description: 'Limit deploy/check/diff to a single workflow definition by name (deployments are selected with --deployment). Every targeted deployment must contain it.',
49
49
  }),
50
50
  'share-defs': Flags.boolean({
51
51
  allowNo: true,
@@ -56,11 +56,10 @@ export default class Deploy extends WorkflowCommand {
56
56
  const { flags } = await this.parse(Deploy);
57
57
  validateModeFlags(flags.check, flags['dry-run']);
58
58
  const config = await loadWorkflowConfig();
59
- const batches = buildBatches(await selectDeployments(config, {
60
- name: flags.deployment,
61
- tag: flags.tag,
62
- allTags: flags['all-tags'],
63
- }), flags.only);
59
+ const batches = await resolveBatches(config, {
60
+ only: flags.only,
61
+ selection: { name: flags.deployment, tag: flags.tag, allTags: flags['all-tags'] },
62
+ });
64
63
  const log = (line) => this.log(line);
65
64
  if (flags.check) {
66
65
  await reconcileBatches({
@@ -156,6 +155,10 @@ export function validateModeFlags(check, dryRun) {
156
155
  fail('Pass either --check or --dry-run, not both.');
157
156
  }
158
157
  }
158
+ export async function resolveBatches(config, { only, selection }) {
159
+ assertKnownDefinition(config, only);
160
+ return buildBatches(await selectDeployments(config, selection), only);
161
+ }
159
162
  export function buildBatches(deployments, only) {
160
163
  return deployments.map((deployment) => {
161
164
  const context = deployments.length > 1 ? `${deploymentLabel(deployment)} — ` : '';
@@ -1,3 +1,4 @@
1
+ import { type WorkflowConfig, type WorkflowDeployment, type WorkflowInstance, type WorkflowResource } from '@sanity/workflow-engine';
1
2
  import { WorkflowCommand } from '../../lib/base-command.ts';
2
3
  export default class Nuke extends WorkflowCommand {
3
4
  static aliases: string[];
@@ -7,7 +8,70 @@ export default class Nuke extends WorkflowCommand {
7
8
  static flags: {
8
9
  deployment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ instance: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
13
  };
12
14
  run(): Promise<void>;
15
+ /** Tag mode: delete every engine-owned document for the selected deployment. */
16
+ private nukePartition;
17
+ /**
18
+ * Instance mode: delete ONE terminal instance plus its guard docs. Refuses an
19
+ * in-flight instance — abort it first, so pending effects are cancelled and
20
+ * children condemned; this command only removes what is already settled. The
21
+ * guard sweep runs regardless of how it terminated: a normally completed
22
+ * instance still holds its terminal-stage guards.
23
+ *
24
+ * Deliberately does NOT run {@link refuseOverlappingNuke}, which tag mode must.
25
+ * That refusal exists because guard ids embed only the tag, so a tag sweep
26
+ * cannot tell two same-tag deployments' guards apart in a shared resource. The
27
+ * instance sweep matches `sourceInstanceId` (and a `<type>.<instanceId>.` id
28
+ * prefix), both of which name one instance — so same-tag siblings cannot
29
+ * collide and adding the gate back would refuse valid deletes.
30
+ */
31
+ private deleteInstance;
13
32
  }
33
+ /**
34
+ * Guard the two mutually exclusive modes. Instance mode reads its tag from the
35
+ * id, so `--tag` is meaningless there (`--deployment` still disambiguates a tag
36
+ * that spans deployments); tag mode requires exactly one selector, never
37
+ * guessing the environment.
38
+ */
39
+ export declare function assertNukeFlags(flags: {
40
+ instance?: string | undefined;
41
+ deployment?: string | undefined;
42
+ tag?: string | undefined;
43
+ }): void;
44
+ /**
45
+ * The configured deployment that owns an instance — matched by the instance's
46
+ * own tag AND the resource that held it. Config forbids two deployments sharing
47
+ * tag + workflow resource, so this is unambiguous. Needed for the alias-bound
48
+ * resources a single-instance guard sweep must also cover.
49
+ *
50
+ * Both halves of the match are named on failure: a tag that IS configured but on
51
+ * another resource is the likelier miss of the two, and naming only the tag
52
+ * makes that read as though the tag were unknown.
53
+ */
54
+ export declare function deploymentForInstanceScope(config: WorkflowConfig, located: {
55
+ tag: string | undefined;
56
+ resource: WorkflowResource;
57
+ }): WorkflowDeployment;
58
+ /**
59
+ * Refuse anything that is not an engine instance document. The locate reads a
60
+ * projection by raw id, so nothing upstream has proved the id names an instance
61
+ * — and engine documents can share a dataset with the content they govern, where
62
+ * a bare id would otherwise let a delete reach a content document. The plan
63
+ * promises "content is never touched"; this is what makes that true.
64
+ */
65
+ export declare function assertEngineInstanceOrFail(document: {
66
+ _type: string;
67
+ }, instanceId: string): void;
68
+ /**
69
+ * Refuse to delete an in-flight instance — abort it first, so its pending
70
+ * effects are cancelled and children condemned. This command only removes what
71
+ * is already settled ({@link terminalState} is not `'in-flight'`); it performs
72
+ * no engine mutation of its own.
73
+ */
74
+ export declare function assertInstanceTerminalOrFail(instance: Pick<WorkflowInstance, 'completedAt' | 'abortedAt'>, { instanceId, bin }: {
75
+ instanceId: string;
76
+ bin: string;
77
+ }): void;
@@ -1,35 +1,44 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { input } from '@sanity/cli-core/ux';
3
- import { resourceGdr } from '@sanity/workflow-engine';
3
+ import { WORKFLOW_INSTANCE_TYPE, resourceGdr, terminalState, } from '@sanity/workflow-engine';
4
4
  import logSymbols from 'log-symbols';
5
5
  import { WorkflowCommand } from "../../lib/base-command.js";
6
6
  import { clientFor, resolveApiHost, resolveTokenOrFail } from "../../lib/client.js";
7
- import { dedupeResources } from "../../lib/context.js";
7
+ import { dedupeResources, resolveInstanceStamps } from "../../lib/context.js";
8
8
  import { fail } from "../../lib/fail.js";
9
9
  import { loadWorkflowConfig } from "../../lib/load-config.js";
10
- import { confirmationMatches, executeNuke, formatNukeSummary, involvedTargets, planCounts, refuseOverlappingNuke, renderNukePlan, resolveNukePlan, sweptResources, } from "../../lib/nuke.js";
10
+ import { confirmationMatches, executeNuke, formatInstanceNukeSummary, formatNukeSummary, involvedTargets, planCounts, refuseOverlappingNuke, renderNukePlan, resolveInstanceNukePlan, resolveNukePlan, sweptResources, } from "../../lib/nuke.js";
11
11
  import { runWriteVerb } from "../../lib/ops-report.js";
12
- import { canPromptOnStderr } from "../../lib/prompt.js";
12
+ import { canPromptOnStderr, confirmOnStderr } from "../../lib/prompt.js";
13
13
  import { selectDeployment } from "../../lib/select-deployment.js";
14
+ import { resourceLabel } from "../../lib/ui.js";
15
+ function planApiHost() {
16
+ return resolveApiHost() ?? 'https://api.sanity.io (production default)';
17
+ }
14
18
  export default class Nuke extends WorkflowCommand {
15
19
  static aliases = ['nuke'];
16
- static summary = 'Delete every engine-owned document for a deployment tag the dev-period big red button.';
20
+ static summary = 'Delete engine-owned documents a whole deployment tag, or a single instance.';
17
21
  static description = 'The reset for a dataset holding engine documents the versioned upgrade framework cannot yet ' +
18
22
  "migrate: deletes the tag's instances, definitions, and guards (across every alias-bound " +
19
- 'resource). Content documents are never touched. Prints a dry-run plan, then requires you to ' +
20
- 'type back every involved dataset (--force skips the prompt; the plan still prints).';
23
+ 'resource). --instance <id> instead deletes one terminal instance plus its guards. Content ' +
24
+ 'documents are never touched. Prints a dry-run plan, then confirms (--force skips the prompt; ' +
25
+ 'the plan still prints).';
21
26
  static examples = [
22
27
  '<%= config.bin %> nuke --deployment plugin-dev',
23
28
  '<%= config.bin %> nuke --tag plugin-dev --force',
29
+ '<%= config.bin %> nuke --instance plugin-dev.wf-instance.abc123',
24
30
  ];
25
31
  static flags = {
26
32
  deployment: Flags.string({
27
- description: 'The deployment name to reset.',
28
- exactlyOne: ['deployment', 'tag'],
33
+ description: 'The deployment name to reset. With --instance, narrows which deployment the ' +
34
+ 'instance is looked up in when its tag spans several.',
29
35
  }),
30
36
  tag: Flags.string({
31
- description: 'The deployment tag to reset (while it names exactly one deployment).',
32
- exactlyOne: ['deployment', 'tag'],
37
+ description: 'The deployment tag to reset (while it names exactly one deployment). Not valid ' +
38
+ 'with --instance, which reads its tag from the instance id.',
39
+ }),
40
+ instance: Flags.string({
41
+ description: 'Delete a single terminal instance by id, plus its guard docs, instead of a tag.',
33
42
  }),
34
43
  force: Flags.boolean({
35
44
  description: 'Skip the confirmation prompt (for scripts/CI). The plan still prints.',
@@ -38,13 +47,20 @@ export default class Nuke extends WorkflowCommand {
38
47
  };
39
48
  async run() {
40
49
  const { flags } = await this.parse(Nuke);
50
+ assertNukeFlags(flags);
51
+ if (flags.instance !== undefined) {
52
+ await this.deleteInstance(flags.instance, flags);
53
+ return;
54
+ }
55
+ await this.nukePartition(flags);
56
+ }
57
+ async nukePartition(flags) {
41
58
  const config = await loadWorkflowConfig();
42
59
  const deployment = await selectDeployment(config, { name: flags.deployment, tag: flags.tag });
43
60
  refuseOverlappingNuke(config.deployments, deployment);
44
61
  const targets = buildTargets(deployment, await resolveTokenOrFail());
45
62
  const plan = await resolveNukePlan({ tag: deployment.tag, targets });
46
- const apiHost = resolveApiHost() ?? 'https://api.sanity.io (production default)';
47
- renderNukePlan(plan, apiHost).forEach((line) => this.log(line));
63
+ renderNukePlan(plan, { apiHost: planApiHost() }).forEach((line) => this.log(line));
48
64
  const counts = planCounts(plan);
49
65
  if (counts.total === 0) {
50
66
  this.log(`${logSymbols.info} Nothing to nuke for tag "${deployment.tag}".`);
@@ -61,6 +77,67 @@ export default class Nuke extends WorkflowCommand {
61
77
  log: (line) => this.log(line),
62
78
  });
63
79
  }
80
+ async deleteInstance(instanceId, flags) {
81
+ const config = await loadWorkflowConfig();
82
+ const { resource, stamps } = await resolveInstanceStamps(flags, instanceId);
83
+ assertEngineInstanceOrFail(stamps, instanceId);
84
+ assertInstanceTerminalOrFail(stamps, { instanceId, bin: this.config.bin });
85
+ const deployment = deploymentForInstanceScope(config, { tag: stamps.tag, resource });
86
+ const targets = buildTargets(deployment, await resolveTokenOrFail());
87
+ const plan = await resolveInstanceNukePlan({
88
+ instance: { id: instanceId, resource },
89
+ tag: deployment.tag,
90
+ targets,
91
+ });
92
+ renderNukePlan(plan, {
93
+ apiHost: planApiHost(),
94
+ heading: `nuke — instance "${instanceId}"`,
95
+ }).forEach((line) => this.log(line));
96
+ const counts = planCounts(plan);
97
+ if (!flags.force)
98
+ await confirmInstanceDeleteOrFail(instanceId);
99
+ await runWriteVerb({
100
+ startLabel: `Deleting ${instanceId}…`,
101
+ failLabel: 'Delete failed',
102
+ failHeadline: 'workflow nuke error:',
103
+ run: () => executeNuke(plan),
104
+ report: () => ({ changed: true, message: formatInstanceNukeSummary(instanceId, counts) }),
105
+ log: (line) => this.log(line),
106
+ });
107
+ }
108
+ }
109
+ export function assertNukeFlags(flags) {
110
+ if (flags.instance !== undefined) {
111
+ if (flags.tag !== undefined) {
112
+ fail('--instance cannot be combined with --tag.', 'The tag is read from the instance id; pass --deployment to disambiguate if needed.');
113
+ }
114
+ return;
115
+ }
116
+ const selectors = [flags.deployment, flags.tag].filter((value) => value !== undefined);
117
+ if (selectors.length !== 1) {
118
+ fail('Pass exactly one of --deployment or --tag (or --instance <id> to delete one instance).');
119
+ }
120
+ }
121
+ export function deploymentForInstanceScope(config, located) {
122
+ const gdr = resourceGdr(located.resource);
123
+ const deployment = config.deployments.find((candidate) => candidate.tag === located.tag && resourceGdr(candidate.workflowResource) === gdr);
124
+ if (deployment === undefined) {
125
+ fail(`No deployment in this workflow config has tag "${located.tag}" on ` +
126
+ `${resourceLabel(located.resource)}.`, 'A single-instance delete resolves the deployment to find the alias-bound resources its ' +
127
+ 'guards can live in. Add that deployment to the config, or pass --deployment to name ' +
128
+ 'the one that should own it.');
129
+ }
130
+ return deployment;
131
+ }
132
+ export function assertEngineInstanceOrFail(document, instanceId) {
133
+ if (document._type !== WORKFLOW_INSTANCE_TYPE) {
134
+ fail(`${instanceId} is not a workflow instance (its _type is "${document._type}").`, `Only ${WORKFLOW_INSTANCE_TYPE} documents can be deleted this way.`);
135
+ }
136
+ }
137
+ export function assertInstanceTerminalOrFail(instance, { instanceId, bin }) {
138
+ if (terminalState(instance) === 'in-flight') {
139
+ fail('Instance is in-flight — abort it first, then delete.', `Run: ${bin} abort ${instanceId}`);
140
+ }
64
141
  }
65
142
  function buildTargets(deployment, token) {
66
143
  const engineGdr = resourceGdr(deployment.workflowResource);
@@ -70,12 +147,21 @@ function buildTargets(deployment, token) {
70
147
  holdsEngineDocs: resourceGdr(resource) === engineGdr,
71
148
  }));
72
149
  }
73
- async function confirmOrFail(targets) {
150
+ function assertPromptableOrFail(action) {
74
151
  if (!canPromptOnStderr()) {
75
- fail('Refusing to nuke without confirmation in a non-interactive shell.', 'Re-run with --force to skip the prompt.');
152
+ fail(`Refusing to ${action} without confirmation in a non-interactive shell.`, 'Re-run with --force to skip the prompt.');
76
153
  }
154
+ }
155
+ async function confirmOrFail(targets) {
156
+ assertPromptableOrFail('nuke');
77
157
  const answer = await input({ message: `Type every target to confirm deletion (space-separated):\n ${targets.join(' ')}\n` }, { output: process.stderr });
78
158
  if (!confirmationMatches(answer, targets)) {
79
159
  fail('Confirmation did not match — nothing was deleted.', `Expected: ${targets.join(' ')}`);
80
160
  }
81
161
  }
162
+ async function confirmInstanceDeleteOrFail(instanceId) {
163
+ assertPromptableOrFail('delete');
164
+ if (!(await confirmOnStderr(`Delete instance ${instanceId}?`))) {
165
+ fail('Cancelled — nothing was deleted.');
166
+ }
167
+ }
@@ -11,8 +11,9 @@ export interface InstanceContext {
11
11
  * ready to pass straight to an engine verb, so callers never re-assemble it. */
12
12
  scope: EngineScope;
13
13
  /** The located instance. Already fetched by the fan-out to derive the scope,
14
- * so a command that needs its state (set-stage's stage picker reads the
15
- * pinned definition snapshot and `currentStage`) reuses it, not re-fetch. */
14
+ * so a command that needs its state reuses it rather than re-fetching —
15
+ * set-stage's stage picker reads the pinned definition snapshot and
16
+ * `currentStage`; nuke's instance mode reads the terminal state. */
16
17
  instance: WorkflowInstance;
17
18
  }
18
19
  /**
@@ -75,6 +76,25 @@ export declare function resolveInstanceContext(flags: {
75
76
  deployment?: string | undefined;
76
77
  tag?: string | undefined;
77
78
  }, instanceId: string): Promise<InstanceContext>;
79
+ /** An instance located without interpreting its shape — the resource that held
80
+ * it and the {@link InstanceStamps} a delete gates on. */
81
+ export interface InstanceStampsContext {
82
+ resource: WorkflowResource;
83
+ stamps: InstanceStamps;
84
+ }
85
+ /**
86
+ * Locate an instance by the same fan-out and `<tag>.` fast-path as
87
+ * {@link resolveInstanceContext}, but reading only a projection — for the one
88
+ * caller that must not be refused by the reader-floor gate. See
89
+ * {@link findInstanceStamps} for why `nuke --instance` is that caller.
90
+ *
91
+ * Returns no client: a projection cannot be acted on through an engine verb, so
92
+ * the caller builds its own write clients from the deployment it resolves.
93
+ */
94
+ export declare function resolveInstanceStamps(flags: {
95
+ deployment?: string | undefined;
96
+ tag?: string | undefined;
97
+ }, instanceId: string): Promise<InstanceStampsContext>;
78
98
  /** A read target that turned out to hold the instance being looked up. */
79
99
  export interface InstanceHit extends ReadTarget {
80
100
  instance: WorkflowInstance;
@@ -88,16 +108,25 @@ export declare function soleHitOrFail<T extends {
88
108
  resource: WorkflowResource;
89
109
  }>(hits: T[], subject: string): T | undefined;
90
110
  /**
91
- * Look an instance id up across every read target. Exactly one hit wins
92
- * instance ids are random and collision-free, so a single match IS the
93
- * instance. Several hits (id reuse across datasets) fail listing the
94
- * resources rather than silently picking one. `undefined` means confidently
95
- * not found: when a probe throws and no other target has the id, that error
96
- * propagates instead — an unreadable dataset must not masquerade as "no such
97
- * instance".
111
+ * Look an instance id up across every read target, reading each hit as a full
112
+ * engine-gated instance document the locate every command that then acts
113
+ * through an engine verb uses. See {@link findAcrossTargets} for the
114
+ * one-hit-wins semantics.
98
115
  */
99
116
  export declare function findInstance({ targets, instanceId, requestTag, }: {
100
117
  targets: ReadTarget[];
101
118
  instanceId: string;
102
119
  requestTag: string;
103
120
  }): Promise<InstanceHit | undefined>;
121
+ /**
122
+ * The four persisted fields a delete needs to decide it may proceed, read
123
+ * WITHOUT interpreting the document's shape: `_type` to prove it is an engine
124
+ * instance and not co-located content, `tag` to locate the owning deployment,
125
+ * and the terminal stamps to prove it has settled.
126
+ */
127
+ export interface InstanceStamps {
128
+ _type: string;
129
+ tag?: string;
130
+ completedAt?: string;
131
+ abortedAt?: string;
132
+ }
@@ -34,7 +34,13 @@ const LOCATE_TAG = 'instance.load';
34
34
  export async function resolveInstanceContext(flags, instanceId) {
35
35
  const config = await loadWorkflowConfig();
36
36
  const token = await resolveTokenOrFail();
37
- const hit = await locateInstance({ config, flags, instanceId, token });
37
+ const hit = await locateInstance({
38
+ config,
39
+ flags,
40
+ instanceId,
41
+ token,
42
+ find: (targets) => findInstance({ targets, instanceId, requestTag: LOCATE_TAG }),
43
+ });
38
44
  if (hit === undefined) {
39
45
  fail(`Workflow instance ${instanceId} not found`);
40
46
  }
@@ -44,9 +50,24 @@ export async function resolveInstanceContext(flags, instanceId) {
44
50
  instance: hit.instance,
45
51
  };
46
52
  }
47
- async function locateInstance({ config, flags, instanceId, token, }) {
53
+ export async function resolveInstanceStamps(flags, instanceId) {
54
+ const config = await loadWorkflowConfig();
55
+ const token = await resolveTokenOrFail();
56
+ const hit = await locateInstance({
57
+ config,
58
+ flags,
59
+ instanceId,
60
+ token,
61
+ find: (targets) => findInstanceStamps({ targets, instanceId, requestTag: LOCATE_TAG }),
62
+ });
63
+ if (hit === undefined) {
64
+ fail(`Workflow instance ${instanceId} not found`);
65
+ }
66
+ return { resource: hit.resource, stamps: hit.stamps };
67
+ }
68
+ async function locateInstance({ config, flags, instanceId, token, find, }) {
48
69
  const resources = await instanceCandidateResources(config, flags);
49
- const fanOut = () => findInstance({ targets: targetsFor(resources, token), instanceId, requestTag: LOCATE_TAG });
70
+ const fanOut = () => find(targetsFor(resources, token));
50
71
  const explicit = flags.deployment !== undefined || flags.tag !== undefined;
51
72
  const hinted = explicit || resources.length <= 1
52
73
  ? []
@@ -54,12 +75,12 @@ async function locateInstance({ config, flags, instanceId, token, }) {
54
75
  if (hinted.length === 0 || hinted.length === resources.length) {
55
76
  return fanOut();
56
77
  }
57
- const hit = await hintedProbe(targetsFor(hinted, token), instanceId);
78
+ const hit = await hintedProbe(() => find(targetsFor(hinted, token)));
58
79
  return hit ?? fanOut();
59
80
  }
60
- async function hintedProbe(targets, instanceId) {
81
+ async function hintedProbe(probe) {
61
82
  try {
62
- return await findInstance({ targets, instanceId, requestTag: LOCATE_TAG });
83
+ return await probe();
63
84
  }
64
85
  catch {
65
86
  return undefined;
@@ -89,26 +110,43 @@ export function soleHitOrFail(hits, subject) {
89
110
  }
90
111
  return sole;
91
112
  }
92
- export async function findInstance({ targets, instanceId, requestTag, }) {
93
- const probes = await Promise.allSettled(targets.map(async (target) => {
94
- const instance = await target.client.getDocument(instanceId, {
95
- tag: requestTag,
96
- });
97
- return instance === undefined
98
- ? undefined
99
- : { ...target, instance: assertReadableModel(instance) };
100
- }));
113
+ async function findAcrossTargets({ targets, subject, probe, }) {
114
+ const probes = await Promise.allSettled(targets.map(probe));
101
115
  const hits = probes
102
- .filter((probe) => probe.status === 'fulfilled')
103
- .map((probe) => probe.value)
116
+ .filter((settled) => settled.status === 'fulfilled')
117
+ .map((settled) => settled.value)
104
118
  .filter((hit) => hit !== undefined);
105
- const sole = soleHitOrFail(hits, `Instance ${instanceId}`);
119
+ const sole = soleHitOrFail(hits, subject);
106
120
  if (sole !== undefined) {
107
121
  return sole;
108
122
  }
109
- const failedProbe = probes.find((probe) => probe.status === 'rejected');
123
+ const failedProbe = probes.find((settled) => settled.status === 'rejected');
110
124
  if (failedProbe !== undefined) {
111
125
  throw failedProbe.reason;
112
126
  }
113
127
  return undefined;
114
128
  }
129
+ export function findInstance({ targets, instanceId, requestTag, }) {
130
+ return findAcrossTargets({
131
+ targets,
132
+ subject: `Instance ${instanceId}`,
133
+ probe: async (target) => {
134
+ const instance = await target.client.getDocument(instanceId, {
135
+ tag: requestTag,
136
+ });
137
+ return instance === undefined
138
+ ? undefined
139
+ : { ...target, instance: assertReadableModel(instance) };
140
+ },
141
+ });
142
+ }
143
+ function findInstanceStamps({ targets, instanceId, requestTag, }) {
144
+ return findAcrossTargets({
145
+ targets,
146
+ subject: `Instance ${instanceId}`,
147
+ probe: async (target) => {
148
+ const stamps = await target.client.fetch(`*[_id == $instanceId][0]{_type, tag, completedAt, abortedAt}`, { instanceId }, { perspective: 'raw', tag: requestTag });
149
+ return stamps === null ? undefined : { ...target, stamps };
150
+ },
151
+ });
152
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SanityClient } from '@sanity/client';
2
- import { type DeployedDefinition, type WorkflowDefinition } from '@sanity/workflow-engine';
2
+ import { type DeployedDefinition, type WorkflowConfig, type WorkflowDefinition } from '@sanity/workflow-engine';
3
3
  interface DefinitionShowQueryArgs {
4
4
  name: string;
5
5
  tag?: string | undefined;
@@ -10,10 +10,9 @@ export declare function buildDefinitionShowQuery(flags: DefinitionShowQueryArgs)
10
10
  params: Record<string, unknown>;
11
11
  };
12
12
  /**
13
- * The tag partitions holding any version of a definition name within one
14
- * dataset. An untagged lookup that would span several partitions must error
15
- * (the highest version ACROSS partitions is meaningless — it could show a
16
- * prod operator the dev definition), the same way a cross-dataset hit does.
13
+ * The engine's {@link definitionTagsGroq} in this package's `{groq, params}`
14
+ * calling shape. Refusing an untagged lookup that spans partitions mirrors the
15
+ * cross-dataset rule: ambiguity errors, never picks.
17
16
  */
18
17
  export declare function buildDefinitionTagsQuery(name: string): {
19
18
  groq: string;
@@ -31,6 +30,20 @@ export declare function fetchDeployedDefinition({ client, name, tag, version, }:
31
30
  tag: string;
32
31
  version: number | undefined;
33
32
  }): Promise<DeployedDefinition | undefined>;
33
+ /**
34
+ * Gate a definition filter against the whole config before a command resolves
35
+ * which deployment it targets — a name that matches no definition anywhere can
36
+ * never deploy, whichever deployment is chosen, so it fails here rather than
37
+ * behind the interactive deployment picker. Membership in the targeted
38
+ * deployment stays with {@link selectDefinitions}, which fails on a name only
39
+ * some other deployment carries.
40
+ *
41
+ * The filter and the deployment selector both take a bare name, so a value
42
+ * naming a deployment is pointed at the flag it belongs to instead of getting a
43
+ * dead-end list of definitions it was never going to be in — in which case the
44
+ * available-definitions hint is replaced, not appended to.
45
+ */
46
+ export declare function assertKnownDefinition(config: WorkflowConfig, only: string | undefined): void;
34
47
  /**
35
48
  * Narrow a batch to one definition name (`only`), failing with the available
36
49
  * names when there's no match. With `only` unset the whole batch passes through
@@ -1,4 +1,4 @@
1
- import { WORKFLOW_DEFINITION_TYPE, assertReadableModel, errorMessage, tagScopeFilter, validateDefinition, } from '@sanity/workflow-engine';
1
+ import { WORKFLOW_DEFINITION_TYPE, assertReadableModel, definitionTagsGroq, 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 };
@@ -16,8 +16,8 @@ export function buildDefinitionShowQuery(flags) {
16
16
  }
17
17
  export function buildDefinitionTagsQuery(name) {
18
18
  return {
19
- groq: `array::unique(*[_type == "${WORKFLOW_DEFINITION_TYPE}" && name == $name].tag)`,
20
- params: { name },
19
+ groq: definitionTagsGroq(),
20
+ params: { definition: name },
21
21
  };
22
22
  }
23
23
  export async function fetchDeployedDefinition({ client, name, tag, version, }) {
@@ -30,14 +30,29 @@ export async function fetchDeployedDefinition({ client, name, tag, version, }) {
30
30
  }
31
31
  return deployed === null ? undefined : assertReadableModel(deployed);
32
32
  }
33
+ function availableDefinitions(names) {
34
+ return `Available: ${names.join(', ')}`;
35
+ }
36
+ export function assertKnownDefinition(config, only) {
37
+ if (!only) {
38
+ return;
39
+ }
40
+ const names = new Set(config.deployments.flatMap((d) => d.definitions.map((def) => def.name)));
41
+ if (names.has(only)) {
42
+ return;
43
+ }
44
+ const namesADeployment = config.deployments.some((d) => d.name === only);
45
+ fail(`No definition named "${only}".`, namesADeployment
46
+ ? `"${only}" is a deployment — did you mean --deployment ${only}?`
47
+ : availableDefinitions([...names]));
48
+ }
33
49
  export function selectDefinitions(allDefinitions, { only, context = '' }) {
34
50
  if (!only) {
35
51
  return [...allDefinitions];
36
52
  }
37
53
  const filtered = allDefinitions.filter((d) => d.name === only);
38
54
  if (filtered.length === 0) {
39
- const available = allDefinitions.map((d) => d.name).join(', ');
40
- fail(`${context}No definition named "${only}".`, `Available: ${available}`);
55
+ fail(`${context}No definition named "${only}".`, availableDefinitions(allDefinitions.map((d) => d.name)));
41
56
  }
42
57
  return filtered;
43
58
  }
@@ -10,7 +10,8 @@ export interface NukeTarget {
10
10
  /** One resolved resource of a {@link NukePlan}: the ids to delete, by doc type. */
11
11
  export interface NukeResourcePlan {
12
12
  resource: WorkflowResource;
13
- /** The `project.dataset` (or `<type>:<id>`) label the operator confirms by. */
13
+ /** The `project.dataset` (or `<type>:<id>`) label the plan shows this resource
14
+ * as — and, in tag mode, the one the operator confirms by. */
14
15
  label: string;
15
16
  client: WorkflowClient;
16
17
  holdsEngineDocs: boolean;
@@ -18,9 +19,14 @@ export interface NukeResourcePlan {
18
19
  definitionIds: string[];
19
20
  guardIds: string[];
20
21
  }
21
- /** The dry-run plan: what a `nuke --tag <tag>` would delete, per resource. */
22
+ /** Which selector resolved a plan. Decides the delete wave order — see
23
+ * {@link WAVE_ORDER} — so it travels with the plan rather than being re-derived. */
24
+ export type NukeMode = 'tag' | 'instance';
25
+ /** The dry-run plan both modes render and execute: what a `nuke --tag <tag>` or
26
+ * a `nuke --instance <id>` would delete, per resource. */
22
27
  export interface NukePlan {
23
28
  tag: string;
29
+ mode: NukeMode;
24
30
  resources: NukeResourcePlan[];
25
31
  }
26
32
  /** Per-doc-type totals across the whole plan, plus the count of resources that
@@ -59,9 +65,31 @@ export declare function resolveNukePlan(args: {
59
65
  tag: string;
60
66
  targets: NukeTarget[];
61
67
  }): Promise<NukePlan>;
62
- /** The `project.dataset` labels of resources the plan actually deletes from
63
- * the exact set the operator must type back to confirm. A resource with
64
- * nothing to delete is not "involved" and is omitted. */
68
+ /** An instance a caller has already located: its id plus the resource whose
69
+ * document the locate actually read. The plan derives its instance row from
70
+ * that resource rather than assuming the engine one holds it, so a plan can
71
+ * only ever claim an instance the caller really found. */
72
+ export interface LocatedInstance {
73
+ id: string;
74
+ resource: WorkflowResource;
75
+ }
76
+ /**
77
+ * Resolve what deleting ONE located instance sweeps, per resource — the
78
+ * single-instance sibling of {@link resolveNukePlan}. The instance document is
79
+ * deleted from the resource that held it; its guards are read from every target,
80
+ * since a guard co-locates with the subject it locks and may live in an
81
+ * alias-bound dataset. Definitions are never in scope. Raw perspective, like the
82
+ * tag-scoped plan.
83
+ */
84
+ export declare function resolveInstanceNukePlan(args: {
85
+ instance: LocatedInstance;
86
+ tag: string;
87
+ targets: NukeTarget[];
88
+ }): Promise<NukePlan>;
89
+ /** The `project.dataset` labels of resources the plan actually deletes from. A
90
+ * resource with nothing to delete is not "involved" and is omitted. Tag mode
91
+ * makes the operator type this set back; instance mode confirms with a plain
92
+ * y/N and consumes it only for the dataset count in its summary. */
65
93
  export declare function involvedTargets(plan: NukePlan): string[];
66
94
  /** Roll the plan up into per-doc-type totals for the summary and the
67
95
  * nothing-to-do short-circuit. */
@@ -77,14 +105,21 @@ export declare function confirmationMatches(input: string, targets: string[]): b
77
105
  * same-named projects on different environments apart), and a per-resource,
78
106
  * per-doc-type count table. A guard-only foreign resource shows `—` for the
79
107
  * engine-only columns, since those docs can't live there. */
80
- export declare function renderNukePlan(plan: NukePlan, apiHost: string): string[];
108
+ export declare function renderNukePlan(plan: NukePlan, { apiHost, heading }: {
109
+ apiHost: string;
110
+ heading?: string;
111
+ }): string[];
81
112
  /** The success line: what was deleted, and across how many datasets. */
82
113
  export declare function formatNukeSummary(tag: string, counts: PlanCounts): string;
114
+ /** The success line for a single-instance delete: the instance is named, so only
115
+ * its swept guards need counting, across every dataset the delete touched (the
116
+ * engine one counts for the instance doc alone, so a guardless instance still
117
+ * reports one). */
118
+ export declare function formatInstanceNukeSummary(instanceId: string, counts: Pick<PlanCounts, 'datasets' | 'guards'>): string;
83
119
  /**
84
- * Delete exactly what the plan printed, in uniform waves over every resource.
85
- * Wave order is referrers-first instances, then definitions, then guards —
86
- * a nicety for an interrupted run (engine docs link by plain strings, so the
87
- * lake enforces no order); non-engine resources simply carry empty
88
- * instance/definition lists. Idempotent: an empty plan deletes nothing.
120
+ * Delete exactly what the plan printed, in uniform waves over every resource,
121
+ * ordered by the plan's mode ({@link WAVE_ORDER}). Non-engine resources simply
122
+ * carry empty instance/definition lists. Idempotent: an empty plan deletes
123
+ * nothing.
89
124
  */
90
125
  export declare function executeNuke(plan: NukePlan): Promise<void>;
package/dist/lib/nuke.js CHANGED
@@ -27,37 +27,66 @@ export function refuseOverlappingNuke(deployments, selected) {
27
27
  export async function resolveNukePlan(args) {
28
28
  const { tag, targets } = args;
29
29
  const resources = await Promise.all(targets.map((target) => resolveResourcePlan({ tag, target })));
30
- return { tag, resources };
30
+ return { tag, mode: 'tag', resources };
31
31
  }
32
32
  async function resolveResourcePlan(args) {
33
33
  const { tag, target } = args;
34
- const { resource, client, holdsEngineDocs } = target;
34
+ const { client, holdsEngineDocs } = target;
35
35
  const noIds = [];
36
36
  const [instanceIds, definitionIds, guardIds] = await Promise.all([
37
37
  holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_INSTANCE_TYPE, tag }) : noIds,
38
38
  holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_DEFINITION_TYPE, tag }) : noIds,
39
39
  tagGuardIds({ client, tag }),
40
40
  ]);
41
+ return resourcePlan(target, { instanceIds, definitionIds, guardIds });
42
+ }
43
+ function resourcePlan(target, ids) {
41
44
  return {
42
- resource,
43
- label: resourceLabel(resource),
44
- client,
45
- holdsEngineDocs,
46
- instanceIds,
47
- definitionIds,
48
- guardIds,
45
+ resource: target.resource,
46
+ label: resourceLabel(target.resource),
47
+ client: target.client,
48
+ holdsEngineDocs: target.holdsEngineDocs,
49
+ ...ids,
49
50
  };
50
51
  }
52
+ export async function resolveInstanceNukePlan(args) {
53
+ const { instance, tag, targets } = args;
54
+ const resources = await Promise.all(targets.map((target) => resolveInstanceResourcePlan({ instance, target })));
55
+ return { tag, mode: 'instance', resources };
56
+ }
57
+ async function resolveInstanceResourcePlan(args) {
58
+ const { instance, target } = args;
59
+ const guardIds = await instanceGuardIds({ client: target.client, instanceId: instance.id });
60
+ const holdsInstance = resourceGdr(target.resource) === resourceGdr(instance.resource);
61
+ return resourcePlan(target, {
62
+ instanceIds: holdsInstance ? [instance.id] : [],
63
+ definitionIds: [],
64
+ guardIds,
65
+ });
66
+ }
67
+ function instanceGuardIds(args) {
68
+ return guardIdsMatching({
69
+ client: args.client,
70
+ match: 'sourceInstanceId == $instanceId || string::startsWith(_id, $guardIdPrefix)',
71
+ params: {
72
+ instanceId: args.instanceId,
73
+ guardIdPrefix: `${GUARD_DOC_TYPE}.${args.instanceId}.`,
74
+ },
75
+ });
76
+ }
51
77
  function engineDocIds(args) {
52
78
  return args.client.fetch(`*[_type == $type && ${tagScopeFilter()}]._id`, { type: args.type, tag: args.tag }, { perspective: 'raw', tag: REQUEST_TAG });
53
79
  }
54
- async function tagGuardIds(args) {
80
+ function tagGuardIds(args) {
55
81
  const instancePrefix = `${args.tag}.wf-instance.`;
56
- const guardIdPrefix = `${GUARD_DOC_TYPE}.${instancePrefix}`;
57
- return args.client.fetch(`*[_type == $type && (
58
- string::startsWith(sourceInstanceId, $instancePrefix) ||
59
- string::startsWith(_id, $guardIdPrefix)
60
- )]._id`, { type: GUARD_DOC_TYPE, instancePrefix, guardIdPrefix }, { perspective: 'raw', tag: REQUEST_TAG });
82
+ return guardIdsMatching({
83
+ client: args.client,
84
+ match: 'string::startsWith(sourceInstanceId, $instancePrefix) || string::startsWith(_id, $guardIdPrefix)',
85
+ params: { instancePrefix, guardIdPrefix: `${GUARD_DOC_TYPE}.${instancePrefix}` },
86
+ });
87
+ }
88
+ function guardIdsMatching(args) {
89
+ return args.client.fetch(`*[_type == $type && (${args.match})]._id`, { type: GUARD_DOC_TYPE, ...args.params }, { perspective: 'raw', tag: REQUEST_TAG });
61
90
  }
62
91
  export function involvedTargets(plan) {
63
92
  return plan.resources
@@ -84,7 +113,7 @@ export function confirmationMatches(input, targets) {
84
113
  const expected = new Set(targets);
85
114
  return typed.size === expected.size && [...expected].every((target) => typed.has(target));
86
115
  }
87
- export function renderNukePlan(plan, apiHost) {
116
+ export function renderNukePlan(plan, { apiHost, heading = `nuke — tag "${plan.tag}"` }) {
88
117
  const rows = plan.resources.map((resource) => [
89
118
  resource.label,
90
119
  resource.holdsEngineDocs ? String(resource.instanceIds.length) : styleText('dim', '—'),
@@ -92,7 +121,7 @@ export function renderNukePlan(plan, apiHost) {
92
121
  String(resource.guardIds.length),
93
122
  ]);
94
123
  return [
95
- sectionHeader(`nuke — tag "${plan.tag}"`),
124
+ sectionHeader(heading),
96
125
  styleText('dim', 'engine-owned documents only — content is never touched'),
97
126
  styleText('dim', `API host: ${apiHost}`),
98
127
  ...formatTable(['target', 'instances', 'definitions', 'guards'], rows),
@@ -106,18 +135,22 @@ export function formatNukeSummary(tag, counts) {
106
135
  ].join(', ');
107
136
  return `Nuked tag "${tag}": ${parts} across ${pluralize(counts.datasets, 'dataset')}.`;
108
137
  }
138
+ export function formatInstanceNukeSummary(instanceId, counts) {
139
+ return (`Deleted instance ${instanceId}: ${pluralize(counts.guards, 'guard')} ` +
140
+ `across ${pluralize(counts.datasets, 'dataset')}.`);
141
+ }
109
142
  function pluralize(count, noun) {
110
143
  return `${count} ${noun}${count === 1 ? '' : 's'}`;
111
144
  }
145
+ const WAVE_ORDER = {
146
+ instance: ['guardIds', 'definitionIds', 'instanceIds'],
147
+ tag: ['instanceIds', 'definitionIds', 'guardIds'],
148
+ };
112
149
  export async function executeNuke(plan) {
113
- for (const resource of plan.resources) {
114
- await deleteIds(resource.client, resource.instanceIds);
115
- }
116
- for (const resource of plan.resources) {
117
- await deleteIds(resource.client, resource.definitionIds);
118
- }
119
- for (const resource of plan.resources) {
120
- await deleteIds(resource.client, resource.guardIds);
150
+ for (const idField of WAVE_ORDER[plan.mode]) {
151
+ for (const resource of plan.resources) {
152
+ await deleteIds(resource.client, resource[idField]);
153
+ }
121
154
  }
122
155
  }
123
156
  async function deleteIds(client, ids) {
@@ -26,3 +26,10 @@ export declare function selectOnStderr(config: {
26
26
  * picker opens where it already is. */
27
27
  default?: string;
28
28
  }): Promise<string>;
29
+ /**
30
+ * Ask a yes/no question on stderr, defaulting to NO — the {@link selectOnStderr}
31
+ * sibling for a destructive confirmation. `default: false` both renders the
32
+ * `[y/N]` hint and makes a bare Enter a decline, so proceeding always takes a
33
+ * deliberate keystroke.
34
+ */
35
+ export declare function confirmOnStderr(message: string): Promise<boolean>;
@@ -1,8 +1,11 @@
1
1
  import { isInteractive } from '@sanity/cli-core';
2
- import { select } from '@sanity/cli-core/ux';
2
+ import { confirm, select } from '@sanity/cli-core/ux';
3
3
  export function canPromptOnStderr() {
4
4
  return isInteractive() && process.stderr.isTTY === true;
5
5
  }
6
6
  export function selectOnStderr(config) {
7
7
  return select(config, { output: process.stderr });
8
8
  }
9
+ export function confirmOnStderr(message) {
10
+ return confirm({ message, default: false }, { output: process.stderr });
11
+ }
@@ -1,6 +1,6 @@
1
1
  import { type DeployTarget, type WorkflowConfig, type WorkflowDeployment } from '@sanity/workflow-engine';
2
2
  export type ChooseDeploymentName = (deployments: WorkflowDeployment[]) => Promise<string>;
3
- interface DeploymentSelectionOptions {
3
+ export interface DeploymentSelectionOptions {
4
4
  name: string | undefined;
5
5
  tag: string | undefined;
6
6
  allTags: boolean;
@@ -54,4 +54,3 @@ export declare function selectDeployments(config: WorkflowConfig, { name, tag, a
54
54
  * consume — expanding its handle bindings into the alias map in the same step.
55
55
  */
56
56
  export declare function deploymentToTarget(deployment: WorkflowDeployment): DeployTarget;
57
- export {};
@@ -118,7 +118,7 @@
118
118
  "type": "boolean"
119
119
  },
120
120
  "only": {
121
- "description": "Limit deploy/check/diff to a single workflow definition by name. Every targeted deployment must contain it.",
121
+ "description": "Limit deploy/check/diff to a single workflow definition by name (deployments are selected with --deployment). Every targeted deployment must contain it.",
122
122
  "name": "only",
123
123
  "hasDynamicHelp": false,
124
124
  "multiple": false,
@@ -371,26 +371,34 @@
371
371
  "nuke"
372
372
  ],
373
373
  "args": {},
374
- "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).",
374
+ "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). --instance <id> instead deletes one terminal instance plus its guards. Content documents are never touched. Prints a dry-run plan, then confirms (--force skips the prompt; the plan still prints).",
375
375
  "examples": [
376
376
  "<%= config.bin %> nuke --deployment plugin-dev",
377
- "<%= config.bin %> nuke --tag plugin-dev --force"
377
+ "<%= config.bin %> nuke --tag plugin-dev --force",
378
+ "<%= config.bin %> nuke --instance plugin-dev.wf-instance.abc123"
378
379
  ],
379
380
  "flags": {
380
381
  "deployment": {
381
- "description": "The deployment name to reset.",
382
+ "description": "The deployment name to reset. With --instance, narrows which deployment the instance is looked up in when its tag spans several.",
382
383
  "name": "deployment",
383
384
  "hasDynamicHelp": false,
384
385
  "multiple": false,
385
386
  "type": "option"
386
387
  },
387
388
  "tag": {
388
- "description": "The deployment tag to reset (while it names exactly one deployment).",
389
+ "description": "The deployment tag to reset (while it names exactly one deployment). Not valid with --instance, which reads its tag from the instance id.",
389
390
  "name": "tag",
390
391
  "hasDynamicHelp": false,
391
392
  "multiple": false,
392
393
  "type": "option"
393
394
  },
395
+ "instance": {
396
+ "description": "Delete a single terminal instance by id, plus its guard docs, instead of a tag.",
397
+ "name": "instance",
398
+ "hasDynamicHelp": false,
399
+ "multiple": false,
400
+ "type": "option"
401
+ },
394
402
  "force": {
395
403
  "description": "Skip the confirmation prompt (for scripts/CI). The plan still prints.",
396
404
  "name": "force",
@@ -405,7 +413,7 @@
405
413
  "pluginName": "@sanity/workflow-cli",
406
414
  "pluginType": "core",
407
415
  "strict": true,
408
- "summary": "Delete every engine-owned document for a deployment tag the dev-period big red button.",
416
+ "summary": "Delete engine-owned documents a whole deployment tag, or a single instance.",
409
417
  "isESM": true,
410
418
  "relativePath": [
411
419
  "dist",
@@ -970,5 +978,5 @@
970
978
  ]
971
979
  }
972
980
  },
973
- "version": "0.23.0"
981
+ "version": "0.25.0"
974
982
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/workflow-cli",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.",
5
5
  "keywords": [
6
6
  "cli",
@@ -62,12 +62,12 @@
62
62
  "@types/node": "^24.12.4",
63
63
  "oclif": "^4.23.16",
64
64
  "vitest": "^4.1.8",
65
- "@sanity/workflow-engine": "0.23.0",
66
- "@sanity/workflow-engine-test": "0.23.0",
67
- "@sanity/workflow-examples": "0.10.2"
65
+ "@sanity/workflow-engine": "0.25.0",
66
+ "@sanity/workflow-engine-test": "0.25.0",
67
+ "@sanity/workflow-examples": "0.10.4"
68
68
  },
69
69
  "peerDependencies": {
70
- "@sanity/workflow-engine": "0.23.0"
70
+ "@sanity/workflow-engine": "0.25.0"
71
71
  },
72
72
  "oclif": {
73
73
  "bin": "sanity-workflows",