@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 +58 -0
- package/README.md +22 -21
- package/dist/commands/editorial-workflows/definition/show.js +1 -1
- package/dist/commands/editorial-workflows/deploy.d.ts +11 -1
- package/dist/commands/editorial-workflows/deploy.js +10 -7
- package/dist/commands/editorial-workflows/nuke.d.ts +64 -0
- package/dist/commands/editorial-workflows/nuke.js +101 -15
- package/dist/lib/context.d.ts +38 -9
- package/dist/lib/context.js +57 -19
- package/dist/lib/definitions.d.ts +18 -5
- package/dist/lib/definitions.js +20 -5
- package/dist/lib/nuke.d.ts +46 -11
- package/dist/lib/nuke.js +58 -25
- package/dist/lib/prompt.d.ts +7 -0
- package/dist/lib/prompt.js +4 -1
- package/dist/lib/select-deployment.d.ts +1 -2
- package/oclif.manifest.json +15 -7
- package/package.json +5 -5
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
|
|
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.
|
|
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 =
|
|
60
|
-
|
|
61
|
-
tag: flags.tag,
|
|
62
|
-
|
|
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
|
|
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).
|
|
20
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
function assertPromptableOrFail(action) {
|
|
74
151
|
if (!canPromptOnStderr()) {
|
|
75
|
-
fail(
|
|
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
|
+
}
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
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
|
|
92
|
-
* instance
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
+
}
|
package/dist/lib/context.js
CHANGED
|
@@ -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({
|
|
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
|
|
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 = () =>
|
|
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)
|
|
78
|
+
const hit = await hintedProbe(() => find(targetsFor(hinted, token)));
|
|
58
79
|
return hit ?? fanOut();
|
|
59
80
|
}
|
|
60
|
-
async function hintedProbe(
|
|
81
|
+
async function hintedProbe(probe) {
|
|
61
82
|
try {
|
|
62
|
-
return await
|
|
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
|
-
|
|
93
|
-
const probes = await Promise.allSettled(targets.map(
|
|
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((
|
|
103
|
-
.map((
|
|
116
|
+
.filter((settled) => settled.status === 'fulfilled')
|
|
117
|
+
.map((settled) => settled.value)
|
|
104
118
|
.filter((hit) => hit !== undefined);
|
|
105
|
-
const sole = soleHitOrFail(hits,
|
|
119
|
+
const sole = soleHitOrFail(hits, subject);
|
|
106
120
|
if (sole !== undefined) {
|
|
107
121
|
return sole;
|
|
108
122
|
}
|
|
109
|
-
const failedProbe = probes.find((
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
package/dist/lib/definitions.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/nuke.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
63
|
-
* the
|
|
64
|
-
*
|
|
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
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
function tagGuardIds(args) {
|
|
55
81
|
const instancePrefix = `${args.tag}.wf-instance.`;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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) {
|
package/dist/lib/prompt.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/prompt.js
CHANGED
|
@@ -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 {};
|
package/oclif.manifest.json
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
66
|
-
"@sanity/workflow-engine-test": "0.
|
|
67
|
-
"@sanity/workflow-examples": "0.10.
|
|
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.
|
|
70
|
+
"@sanity/workflow-engine": "0.25.0"
|
|
71
71
|
},
|
|
72
72
|
"oclif": {
|
|
73
73
|
"bin": "sanity-workflows",
|