@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 +30 -0
- package/README.md +31 -21
- package/dist/commands/definition/list.d.ts +3 -0
- package/dist/commands/definition/list.js +6 -3
- package/dist/commands/definition/show.js +2 -2
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +9 -4
- package/dist/commands/nuke.d.ts +11 -0
- package/dist/commands/nuke.js +76 -0
- package/dist/commands/start.d.ts +17 -2
- package/dist/commands/start.js +49 -8
- package/dist/commands/tail.js +4 -3
- package/dist/lib/client.d.ts +8 -0
- package/dist/lib/client.js +1 -1
- package/dist/lib/context.d.ts +6 -2
- package/dist/lib/context.js +9 -7
- package/dist/lib/definitions.js +2 -2
- package/dist/lib/nuke.d.ts +89 -0
- package/dist/lib/nuke.js +111 -0
- package/dist/lib/operation-args.d.ts +3 -1
- package/dist/lib/ops-report.d.ts +2 -1
- package/dist/lib/prompt.d.ts +10 -0
- package/dist/lib/prompt.js +4 -0
- package/dist/lib/share-definitions.js +5 -3
- package/oclif.manifest.json +49 -2
- package/package.json +4 -4
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.
|
|
78
|
-
|
|
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
|
}
|
package/dist/commands/list.d.ts
CHANGED
package/dist/commands/list.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/commands/start.d.ts
CHANGED
|
@@ -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. */
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/commands/tail.js
CHANGED
|
@@ -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
|
|
68
|
-
if (!
|
|
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))}`);
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/client.js
CHANGED
|
@@ -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() {
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SanityClient } from '@sanity/client';
|
|
2
|
-
import type
|
|
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 (
|
|
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
|
package/dist/lib/context.js
CHANGED
|
@@ -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
|
|
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
|
|
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')
|
package/dist/lib/definitions.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/lib/nuke.js
ADDED
|
@@ -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
|
/**
|
package/dist/lib/ops-report.d.ts
CHANGED
|
@@ -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)
|
|
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;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { styleText } from 'node:util';
|
|
2
|
-
import { getUserConfig
|
|
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 ?? (
|
|
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)),
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
67
|
-
"@sanity/workflow-examples": "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",
|