@sanity/workflow-cli 0.14.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # @sanity/workflow-cli
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9273d55: List the nested `definition` commands (`list`, `show`, `diff`, `delete`) in the
8
+ standalone binary's flat `--help` output. A custom oclif help class surfaces the
9
+ bare `definition <verb>` aliases at the root instead of burying them under the
10
+ `editorial-workflows` topic, so `sanity-workflows --help` shows every command
11
+ without topic-digging. `help --all` still prints the full canonical tree.
12
+ - e7392af: Add the `resetActivity` admin verb — reset a failed (or otherwise terminal) activity in an instance's current stage back to `active` (re-run) or `skipped` (bypass), then cascade so a `$allActivitiesDone`-gated exit transition can fire. `workflow.diagnose` now marks its `reset-activity` remediation `available`, and the CLI ships the real `reset-activity <instanceId> <activity> [--skip]` command in place of the stub. Emits the `Editorial Workflows Activity Reset` adoption event on every attempt (`changed: false` on a no-op), matching the `set-stage` and `abort` overrides.
13
+
14
+ Writes strictly within the existing model-3 grammar: it reuses the `activityStatusChanged` history variant and the existing activity statuses, so it adds no persisted shape — there is no data-model version bump and no reader-floor change.
15
+
16
+ ### Patch Changes
17
+
18
+ - b248789: CLI error detail lines now render in the terminal's default colour instead of red. The red headline with its `✖` still signals the failure; the detail beneath it (validation output, auth hints, wrapped errors) is the information the user has to read, and long runs of red text were hard to read.
19
+ - f4bc057: **BREAKING:** `list_workflow_instances` now returns one page with `has_more` and an optional `next_cursor`; callers that need every match must continue with the cursor. The optional `limit` accepts 1–100 rows and defaults to 25.
20
+
21
+ Bound instance and definition list reads with lake-side filtering and consumer-specific projections.
22
+
23
+ Document filtering now excludes exited-stage references and includes live unresolved child-workflow references. Definition discovery selects and model-gates only the latest deployed version of each workflow name; historical versions remain stored and available to history-oriented APIs.
24
+
25
+ The MCP eval suite now verifies that agents follow list cursors to find a target beyond the first default page, including in persisted Braintrust runs.
26
+
27
+ MCP tool telemetry reports a `cursorUsed` boolean so continued list-page adoption is measurable without sending cursor values, arguments, or results.
28
+
29
+ - Updated dependencies [d9394e5]
30
+ - Updated dependencies [92e28bd]
31
+ - Updated dependencies [f4bc057]
32
+ - Updated dependencies [bcc30fe]
33
+ - Updated dependencies [e7392af]
34
+ - Updated dependencies [fa9c796]
35
+ - @sanity/workflow-engine@0.21.0
36
+
37
+ ## 0.20.0
38
+
39
+ ### Minor Changes
40
+
41
+ - 6e2a066: Ambiguous deployment/resource selection now prompts on an interactive terminal instead of only erroring. When a config has several deployments and no selector is given, `start`, `definition diff`, and `definition delete` show the same deployment picker `deploy` already offered. The instance-targeted commands (`abort`, `set-stage`, `fire-action`, `diagnose`) show that picker too when the config spans several resources — the interactive counterpart to their `--deployment` selector, reading from the chosen deployment's resource. Non-interactive runs (CI, piped output) keep the existing errors asking for `--deployment`/`--tag`, and `nuke` still requires an explicit selector — for a destructive command, naming exactly what is swept is the right friction.
42
+ - 7c4dd86: Reduce guard lifecycle request volume and chunk orphan cleanup transactions.
43
+ - e860898: **BREAKING:** Published Editorial Workflows packages now ship as one fixed release stack and require exact-version peers for every shared runtime package. Install the matching stack so the engine, reactive core, adapters, tools, and UI cannot silently load private or version-skewed copies.
44
+
45
+ ### Patch Changes
46
+
47
+ - efb4cd9: Error detail below the ✖ headline is no longer dimmed. Validation output, auth hints, and errors caught by command wrappers now render in legible red so the information users need to act on remains readable.
48
+ - Updated dependencies [bce09fc]
49
+ - Updated dependencies [efb4cd9]
50
+ - Updated dependencies [46b0285]
51
+ - Updated dependencies [ab5e454]
52
+ - Updated dependencies [8a76c67]
53
+ - Updated dependencies [e3122cc]
54
+ - Updated dependencies [7e8f459]
55
+ - Updated dependencies [98e488e]
56
+ - Updated dependencies [7c4dd86]
57
+ - @sanity/workflow-engine@0.20.0
58
+
3
59
  ## 0.14.0
4
60
 
5
61
  ### Minor Changes
package/README.md CHANGED
@@ -60,7 +60,7 @@ import {articleReview, urlDraft} from './src/workflows.ts'
60
60
  export default defineWorkflowConfig({
61
61
  deployments: [
62
62
  {
63
- expectedMinReaderModel: 2,
63
+ expectedMinReaderModel: 4,
64
64
  name: 'production', // the deployment's unique identity (lowercase letters, digits, dashes)
65
65
  tag: 'prod', // the environment partition the engine's docs are scoped to
66
66
  workflowResource: {type: 'dataset', id: 'acme.workflows'}, // where those docs live
@@ -87,9 +87,12 @@ Pick a deployment with `--deployment <name>`; with a single deployment configure
87
87
  can omit it. `--tag <tag>` also works on single-deployment commands while the
88
88
  tag names exactly one deployment — on `deploy` it targets every deployment
89
89
  carrying the tag (a tag is an environment group). With several configured, a
90
- bare interactive `deploy` presents a keyboard-driven deployment selector (by
91
- name, tag alongside). In CI or another non-interactive shell, it fails asking
92
- for `--deployment`, `--tag`, or `--all-tags` instead of blocking for input.
90
+ bare interactive run presents a keyboard-driven deployment selector (by name,
91
+ tag alongside) on `deploy`, `start`, and `definition diff`/`delete`, and on
92
+ the instance-targeted commands (`abort`, `set-stage`, `reset-activity`,
93
+ `fire-action`, `diagnose`) when the config spans several resources. In CI or another
94
+ non-interactive shell, the command fails asking for `--deployment` or `--tag`
95
+ (`deploy` also suggests `--all-tags`) instead of blocking for input.
93
96
  `--all-tags` deploys every deployment in the config in one run: a failure in one doesn't
94
97
  stop the rest — the run continues, prints a summary of what failed, and exits
95
98
  non-zero. The client's project + dataset are derived from the deployment's
@@ -167,6 +170,7 @@ stale compiled output.
167
170
  | `tail <instance-id>` | wired — `client.listen()` over the instance, prints new history entries |
168
171
  | `abort <instance-id>` | wired — calls `workflow.abortInstance` (hard stop: cancels pending effects, removes guards) |
169
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) |
170
174
  | `fire-action <instance-id>` | wired — `workflow.availableActions` lists actions; `workflow.fireAction` fires one |
171
175
  | `definition list` | wired — `client.fetch` over `sanity.workflow.definition` documents |
172
176
  | `definition show <name>` | wired — `client.fetch`, latest version unless `--version` |
@@ -1,4 +1,4 @@
1
- import { type TerminalState } from '@sanity/workflow-engine';
1
+ import { type GdrUri, type TerminalState } from '@sanity/workflow-engine';
2
2
  import { WorkflowCommand } from '../../lib/base-command.ts';
3
3
  interface InstanceRow {
4
4
  _id: string;
@@ -19,17 +19,19 @@ interface ListFlags {
19
19
  tag?: string | undefined;
20
20
  limit: number;
21
21
  }
22
+ interface ListQueryClient {
23
+ fetch<T>(groq: string, params: Record<string, unknown>, options: {
24
+ tag: string;
25
+ }): Promise<T>;
26
+ }
22
27
  /**
23
28
  * Build the GROQ query + params for `list` from its flags. Pure so the
24
29
  * filter combinations can be asserted without a live dataset. `--tag` is an
25
30
  * optional filter: omit it and the listing spans every partition.
26
31
  *
27
- * With `--document` the lake filter is the engine's prefilter arm — a
28
- * deliberate superset narrowed to the exact watch-set in JS
29
- * ({@link instanceWatchesDocument}) so the rows come back as full
30
- * documents (the narrowing reads fields/stages/ancestors) and UNCAPPED: a
31
- * capped superset could silently hide matches. The display limit applies
32
- * after narrowing.
32
+ * With `--document` the lake filter is the engine's exact watch-set arm. The
33
+ * projection keeps its ref-bearing fields for a defensive JS recheck while
34
+ * omitting the instance's unbounded ledgers and definition snapshot.
33
35
  */
34
36
  export declare function buildListQuery(flags: ListFlags): {
35
37
  groq: string;
@@ -58,4 +60,15 @@ export default class List extends WorkflowCommand {
58
60
  };
59
61
  run(): Promise<void>;
60
62
  }
63
+ /** Fetch one target's projected rows, defensively rechecking document refs. */
64
+ export declare function fetchRows({ client, groq, params, document, limit, }: {
65
+ client: ListQueryClient;
66
+ groq: string;
67
+ params: Record<string, unknown>;
68
+ document: GdrUri | undefined;
69
+ limit: number;
70
+ }): Promise<{
71
+ rows: InstanceRow[];
72
+ hasMore: boolean;
73
+ }>;
61
74
  export {};
@@ -7,6 +7,23 @@ import { failOnThrow } from "../../lib/fail.js";
7
7
  import { tagFlags } from "../../lib/flags.js";
8
8
  import { runReadAcrossTargets } from "../../lib/read-fanout.js";
9
9
  import { formatAge, logClippedTable } from "../../lib/ui.js";
10
+ const DOCUMENT_LIST_PROJECTION = `{
11
+ _type,
12
+ _id,
13
+ workflowResource,
14
+ modelVersion,
15
+ minReaderModel,
16
+ definition,
17
+ tag,
18
+ currentStage,
19
+ fields,
20
+ stages,
21
+ ancestors,
22
+ subworkflows,
23
+ completedAt,
24
+ abortedAt,
25
+ lastChangedAt
26
+ }`;
10
27
  export function buildListQuery(flags) {
11
28
  const filters = [`_type == "${WORKFLOW_INSTANCE_TYPE}"`];
12
29
  const params = {};
@@ -26,7 +43,11 @@ export function buildListQuery(flags) {
26
43
  const armParams = {};
27
44
  filters.push(`(${documentPrefilter([flags.document], armParams)})`);
28
45
  Object.assign(params, armParams);
29
- return { groq: `*[${filters.join(' && ')}] | order(lastChangedAt desc)`, params };
46
+ params['limit'] = flags.limit + 1;
47
+ return {
48
+ groq: `*[${filters.join(' && ')}] | order(lastChangedAt desc)[0...$limit]${DOCUMENT_LIST_PROJECTION}`,
49
+ params,
50
+ };
30
51
  }
31
52
  params['limit'] = flags.limit + 1;
32
53
  const groq = `*[${filters.join(' && ')}] | order(lastChangedAt desc) [0...$limit]{
@@ -96,14 +117,20 @@ export default class List extends WorkflowCommand {
96
117
  targets,
97
118
  log: (line) => this.log(line),
98
119
  run: async ({ client }) => {
99
- const fetched = await fetchRows({ client, groq, params, document });
100
- if (fetched.length === 0) {
101
- this.log(`${logSymbols.info} no instances match`);
120
+ const fetched = await fetchRows({ client, groq, params, document, limit: flags.limit });
121
+ if (fetched.rows.length === 0) {
122
+ if (fetched.hasMore) {
123
+ this.log(`${logSymbols.info} no matches in the first ${flags.limit} candidates — raise --limit to search further`);
124
+ }
125
+ else {
126
+ this.log(`${logSymbols.info} no instances match`);
127
+ }
102
128
  return;
103
129
  }
104
130
  logClippedTable({
105
- rows: fetched,
131
+ rows: fetched.rows,
106
132
  limit: flags.limit,
133
+ moreAvailable: fetched.hasMore,
107
134
  headers: ['instance', 'workflow', 'tag', 'stage', 'status', 'updated'],
108
135
  toCells: (r) => {
109
136
  const row = instanceRow(r);
@@ -125,13 +152,16 @@ export default class List extends WorkflowCommand {
125
152
  }
126
153
  }
127
154
  }
128
- async function fetchRows({ client, groq, params, document, }) {
155
+ export async function fetchRows({ client, groq, params, document, limit, }) {
129
156
  if (document === undefined) {
130
157
  const rows = await client.fetch(groq, params, { tag: 'list' });
131
- return rows.map(assertReadableModel);
158
+ return { rows: rows.map(assertReadableModel), hasMore: rows.length > limit };
132
159
  }
133
160
  const candidates = await client.fetch(groq, params, { tag: 'list' });
134
- return candidates
135
- .map(assertReadableModel)
136
- .filter((instance) => instanceWatchesDocument(instance, document));
161
+ return {
162
+ rows: candidates
163
+ .map(assertReadableModel)
164
+ .filter((instance) => instanceWatchesDocument(instance, document)),
165
+ hasMore: candidates.length > limit,
166
+ };
137
167
  }
@@ -39,7 +39,7 @@ export default class Nuke extends WorkflowCommand {
39
39
  async run() {
40
40
  const { flags } = await this.parse(Nuke);
41
41
  const config = await loadWorkflowConfig();
42
- const deployment = selectDeployment(config, { name: flags.deployment, tag: flags.tag });
42
+ const deployment = await selectDeployment(config, { name: flags.deployment, tag: flags.tag });
43
43
  refuseOverlappingNuke(config.deployments, deployment);
44
44
  const targets = buildTargets(deployment, await resolveTokenOrFail());
45
45
  const plan = await resolveNukePlan({ tag: deployment.tag, targets });
@@ -1,10 +1,31 @@
1
- import { StubCommand } from '../../lib/stub.ts';
2
- export default class ResetActivity extends StubCommand {
3
- static hiddenAliases: string[];
4
- static hidden: boolean;
1
+ import { type ResetActivityTarget, type WorkflowInstance } from '@sanity/workflow-engine';
2
+ import { WorkflowCommand } from '../../lib/base-command.ts';
3
+ import { type WriteOutcome, type WriteReport } from '../../lib/ops-report.ts';
4
+ export default class ResetActivity extends WorkflowCommand {
5
+ static aliases: string[];
5
6
  static description: string;
7
+ static examples: string[];
6
8
  static args: {
7
9
  instanceId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
10
  activity: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
9
11
  };
12
+ static flags: {
13
+ skip: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ };
16
+ run(): Promise<void>;
17
+ }
18
+ /** {@link WriteOutcome} with the terminal stamp the no-op copy reads. */
19
+ interface ResetActivityOutcome extends WriteOutcome {
20
+ instance: Pick<WorkflowInstance, 'currentStage' | 'completedAt'>;
10
21
  }
22
+ /**
23
+ * Turn a `resetActivity` result into the spinner message. Pure so the
24
+ * skipped / re-run / already-at-target / terminal / cascaded permutations can
25
+ * be asserted without driving a real reset.
26
+ */
27
+ export declare function resetActivityReport(result: ResetActivityOutcome, { activity, to }: {
28
+ activity: string;
29
+ to: ResetActivityTarget;
30
+ }): WriteReport;
31
+ export {};
@@ -1,11 +1,61 @@
1
- import { Args } from '@oclif/core';
2
- import { StubCommand } from "../../lib/stub.js";
3
- export default class ResetActivity extends StubCommand {
4
- static hiddenAliases = ['reset-activity'];
5
- static hidden = true;
6
- static description = 'Reset a failed activity on an in-flight instance — back to pending (re-run) or to skipped (bypass).';
1
+ import { styleText } from 'node:util';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { workflow } from '@sanity/workflow-engine';
4
+ import { WorkflowCommand } from "../../lib/base-command.js";
5
+ import { resolveInstanceContext } from "../../lib/context.js";
6
+ import { tagFlags } from "../../lib/flags.js";
7
+ import { buildOperationArgs } from "../../lib/operation-args.js";
8
+ import { cascadeTail, runWriteVerb, } from "../../lib/ops-report.js";
9
+ export default class ResetActivity extends WorkflowCommand {
10
+ static aliases = ['reset-activity'];
11
+ static description = 'Reset a failed activity on an in-flight instance — back to active to re-run it, or --skip to bypass it (mark it skipped) so a gated exit transition can fire.';
12
+ static examples = [
13
+ '<%= config.bin %> reset-activity wf-instance.abc123 legal-review',
14
+ '<%= config.bin %> reset-activity wf-instance.abc123 legal-review --skip',
15
+ ];
7
16
  static args = {
8
17
  instanceId: Args.string({ required: true, description: 'Workflow instance id.' }),
9
18
  activity: Args.string({ required: true, description: 'Activity name within the current stage.' }),
10
19
  };
20
+ static flags = {
21
+ ...tagFlags,
22
+ skip: Flags.boolean({
23
+ default: false,
24
+ description: 'Bypass the activity (mark it skipped) instead of re-running it (back to active).',
25
+ }),
26
+ };
27
+ async run() {
28
+ const { args, flags } = await this.parse(ResetActivity);
29
+ const { client, scope } = await resolveInstanceContext(flags, args.instanceId);
30
+ const to = flags.skip ? 'skipped' : 'active';
31
+ await runWriteVerb({
32
+ startLabel: `Resetting ${args.activity} on ${args.instanceId}…`,
33
+ failLabel: 'Reset rejected',
34
+ failHeadline: 'reset-activity error:',
35
+ run: () => workflow.resetActivity({
36
+ client,
37
+ ...buildOperationArgs({ scope, instanceId: args.instanceId, reason: undefined }),
38
+ activity: args.activity,
39
+ to,
40
+ }),
41
+ report: (result) => resetActivityReport(result, { activity: args.activity, to }),
42
+ log: (line) => this.log(line),
43
+ });
44
+ }
45
+ }
46
+ export function resetActivityReport(result, { activity, to }) {
47
+ const name = styleText('bold', activity);
48
+ if (!result.changed) {
49
+ const { completedAt } = result.instance;
50
+ const message = completedAt !== undefined
51
+ ? `reset-activity changed nothing — instance is terminal (since ${completedAt})`
52
+ : `reset-activity changed nothing — ${name} is already ${to}`;
53
+ return { changed: false, message };
54
+ }
55
+ const stage = styleText('bold', result.instance.currentStage);
56
+ const verb = to === 'skipped' ? 'Skipped' : 'Reset';
57
+ return {
58
+ changed: true,
59
+ message: `${verb} ${name} — now at ${stage}${cascadeTail(result.cascaded)}`,
60
+ };
11
61
  }
package/dist/help.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Help } from '@oclif/core';
2
+ export declare const isBareSurface: (idOrName: string) => boolean;
3
+ export declare const keepCommandAtRoot: (id: string) => boolean;
4
+ /** Standalone-binary help that lists the nested `definition` commands flat in
5
+ * `--help` rather than under a topic. A mounted host governs its own root help. */
6
+ export default class WorkflowHelp extends Help {
7
+ protected showRootHelp(): Promise<void>;
8
+ }
package/dist/help.js ADDED
@@ -0,0 +1,23 @@
1
+ import { Help } from '@oclif/core';
2
+ export const isBareSurface = (idOrName) => !idOrName.startsWith('editorial-workflows');
3
+ export const keepCommandAtRoot = (id) => id !== '' && isBareSurface(id);
4
+ export default class WorkflowHelp extends Help {
5
+ async showRootHelp() {
6
+ if (this.opts.all) {
7
+ await super.showRootHelp();
8
+ return;
9
+ }
10
+ this.log(this.formatRoot());
11
+ this.log('');
12
+ const topics = this.sortedTopics.filter((topic) => isBareSurface(topic.name));
13
+ if (topics.length > 0) {
14
+ this.log(this.formatTopics(topics));
15
+ this.log('');
16
+ }
17
+ const commands = this.sortedCommands.filter((command) => keepCommandAtRoot(command.id));
18
+ if (commands.length > 0) {
19
+ this.log(this.formatCommands(commands));
20
+ this.log('');
21
+ }
22
+ }
23
+ }
@@ -1,6 +1,7 @@
1
1
  import type { SanityClient } from '@sanity/client';
2
2
  import { type WorkflowConfig, type WorkflowDeployment, type WorkflowInstance, type WorkflowResource } from '@sanity/workflow-engine';
3
3
  import type { EngineScope } from './operation-args.ts';
4
+ import { type ChooseDeploymentName } from './select-deployment.ts';
4
5
  export interface DeploymentContext {
5
6
  deployment: WorkflowDeployment;
6
7
  client: SanityClient;
@@ -50,10 +51,11 @@ export declare function dedupeResources(resources: WorkflowResource[]): Workflow
50
51
  * tag (tags may repeat across deployments), or — untagged — every distinct
51
52
  * one the config's deployments mention ({@link dedupeResources}). */
52
53
  export declare function resolveReadResources(config: WorkflowConfig, tag: string | undefined): WorkflowResource[];
53
- /** The single resource a read should target, or fail asking which when the
54
- * config spans more than one. For the paths that need one definite dataset
55
- * (the instance-targeted verbs); listings fan out via
56
- * {@link resolveReadResources} instead. */
54
+ /** The single resource an instance-keyed command reads from when it isn't
55
+ * disambiguated interactively: the sole resource, the `--tag`-narrowed one, or
56
+ * a `fail` asking for `--deployment`/`--tag` when the config spans several. The
57
+ * interactive picker lives in {@link resolveInstanceResource}; listings fan out
58
+ * via {@link resolveReadResources} instead. */
57
59
  export declare function resolveReadResource(config: WorkflowConfig, tag: string | undefined): WorkflowResource;
58
60
  /**
59
61
  * The resolution an instance-id-targeted command shares, read or write: an
@@ -74,16 +76,22 @@ export declare function resolveInstanceContext(flags: {
74
76
  }, instanceId: string): Promise<InstanceContext>;
75
77
  /**
76
78
  * Which resource an instance-keyed command reads from: the one `--deployment`
77
- * names (resolved by its unique deployment name), else the sole or
78
- * `--tag`-narrowed resource ({@link resolveReadResource}). The instance id is
79
- * globally unique, so this only locates the resource to look in never the
80
- * instance's `tag` partition, which the caller takes from the loaded instance.
81
- * The shared path behind all four instance-keyed commands.
79
+ * names (resolved by its unique deployment name); otherwise, when the config
80
+ * spans several resources, an interactive terminal is prompted to pick a
81
+ * deployment ({@link canPromptOnStderr}) its resource is the read source, the
82
+ * interactive counterpart to `--deployment`. A run that can't prompt falls to
83
+ * the sole or `--tag`-narrowed resource, or the ambiguity error
84
+ * ({@link resolveReadResource}). The instance id is globally unique, so this
85
+ * only locates the resource to look in — never the instance's `tag` partition,
86
+ * which the caller takes from the loaded instance. The shared path behind all
87
+ * four instance-keyed commands.
82
88
  */
83
- export declare function resolveInstanceResource(config: WorkflowConfig, { deployment, tag }: {
89
+ export declare function resolveInstanceResource(config: WorkflowConfig, { deployment, tag, interactive, chooseDeployment, }: {
84
90
  deployment?: string | undefined;
85
91
  tag?: string | undefined;
86
- }): WorkflowResource;
92
+ interactive?: boolean | undefined;
93
+ chooseDeployment?: ChooseDeploymentName | undefined;
94
+ }): Promise<WorkflowResource>;
87
95
  /** Fetch an instance by id, exiting cleanly when the resource has no such
88
96
  * document — the diagnostic an operator sees on a mistyped id. Split out from
89
97
  * {@link resolveInstanceContext} so the not-found path is unit-testable
@@ -2,11 +2,12 @@ import { assertReadableModel, resourceGdr, } from '@sanity/workflow-engine';
2
2
  import { clientFor, resolveTokenOrFail } from "./client.js";
3
3
  import { fail } from "./fail.js";
4
4
  import { loadWorkflowConfig } from "./load-config.js";
5
- import { availableDeployments, deploymentsForTag, selectDeployment } from "./select-deployment.js";
5
+ import { canPromptOnStderr } from "./prompt.js";
6
+ import { availableDeployments, deploymentsForTag, selectDeployment, } from "./select-deployment.js";
6
7
  import { resourceLabel } from "./ui.js";
7
8
  export async function resolveContext(flags) {
8
9
  const config = await loadWorkflowConfig();
9
- const deployment = selectDeployment(config, { name: flags.deployment, tag: flags.tag });
10
+ const deployment = await selectDeployment(config, { name: flags.deployment, tag: flags.tag });
10
11
  return { deployment, client: clientFor(deployment.workflowResource, await resolveTokenOrFail()) };
11
12
  }
12
13
  export async function resolveReadTargets(flags) {
@@ -42,14 +43,25 @@ export function resolveReadResource(config, tag) {
42
43
  }
43
44
  export async function resolveInstanceContext(flags, instanceId) {
44
45
  const config = await loadWorkflowConfig();
45
- const workflowResource = resolveInstanceResource(config, flags);
46
+ const workflowResource = await resolveInstanceResource(config, flags);
46
47
  const client = clientFor(workflowResource, await resolveTokenOrFail());
47
48
  const instance = await loadInstanceOrFail(client, instanceId);
48
49
  return { client, scope: { tag: instance.tag, workflowResource } };
49
50
  }
50
- export function resolveInstanceResource(config, { deployment, tag }) {
51
+ export async function resolveInstanceResource(config, { deployment, tag, interactive, chooseDeployment, }) {
51
52
  if (deployment !== undefined) {
52
- return selectDeployment(config, { name: deployment, tag: undefined }).workflowResource;
53
+ return (await selectDeployment(config, { name: deployment, tag: undefined })).workflowResource;
54
+ }
55
+ if (tag === undefined &&
56
+ (interactive ?? canPromptOnStderr()) &&
57
+ resolveReadResources(config, undefined).length > 1) {
58
+ const chosen = await selectDeployment(config, {
59
+ name: undefined,
60
+ tag: undefined,
61
+ interactive: true,
62
+ chooseName: chooseDeployment,
63
+ });
64
+ return chosen.workflowResource;
53
65
  }
54
66
  return resolveReadResource(config, tag);
55
67
  }
package/dist/lib/fail.js CHANGED
@@ -8,7 +8,7 @@ export function fail(headline, detail) {
8
8
  process.stderr.write(`${styleText('red', `${logSymbols.error} ${headline}`)}\n`);
9
9
  if (detail !== undefined && detail !== '') {
10
10
  for (const line of detail.split('\n')) {
11
- process.stderr.write(` ${styleText(['dim', 'red'], line)}\n`);
11
+ process.stderr.write(` ${line}\n`);
12
12
  }
13
13
  }
14
14
  return Errors.exit(1);
@@ -59,19 +59,6 @@ export declare function resolveNukePlan(args: {
59
59
  tag: string;
60
60
  targets: NukeTarget[];
61
61
  }): Promise<NukePlan>;
62
- /**
63
- * Does a guard doc belong to `tag`? A guard's `sourceInstanceId` is the id of
64
- * the instance that registered it (`<tag>.wf-instance.<random>`), and its own
65
- * `_id` embeds that same instance id
66
- * (`temp.system.guard.<tag>.wf-instance.<random>.<name>`). Matching on either —
67
- * with the trailing `.` so `prod` never matches `prod-eu` — catches guards
68
- * whose `sourceInstanceId` was never written as well as guards whose instance
69
- * is already gone.
70
- */
71
- export declare function guardMatchesTag(guard: {
72
- _id: string;
73
- sourceInstanceId?: string;
74
- }, tag: string): boolean;
75
62
  /** The `project.dataset` labels of resources the plan actually deletes from —
76
63
  * the exact set the operator must type back to confirm. A resource with
77
64
  * nothing to delete is not "involved" and is omitted. */
package/dist/lib/nuke.js CHANGED
@@ -52,14 +52,12 @@ function engineDocIds(args) {
52
52
  return args.client.fetch(`*[_type == $type && ${tagScopeFilter()}]._id`, { type: args.type, tag: args.tag }, { perspective: 'raw', tag: REQUEST_TAG });
53
53
  }
54
54
  async function tagGuardIds(args) {
55
- const guards = await args.client.fetch(`*[_type == $type]{_id, sourceInstanceId}`, { type: GUARD_DOC_TYPE }, { perspective: 'raw', tag: REQUEST_TAG });
56
- return guards.filter((guard) => guardMatchesTag(guard, args.tag)).map((guard) => guard._id);
57
- }
58
- export function guardMatchesTag(guard, tag) {
59
- const instancePrefix = `${tag}.wf-instance.`;
55
+ const instancePrefix = `${args.tag}.wf-instance.`;
60
56
  const guardIdPrefix = `${GUARD_DOC_TYPE}.${instancePrefix}`;
61
- return ((guard.sourceInstanceId?.startsWith(instancePrefix) ?? false) ||
62
- guard._id.startsWith(guardIdPrefix));
57
+ return args.client.fetch(`*[_type == $type && (
58
+ string::startsWith(sourceInstanceId, $instancePrefix) ||
59
+ string::startsWith(_id, $guardIdPrefix)
60
+ )]._id`, { type: GUARD_DOC_TYPE, instancePrefix, guardIdPrefix }, { perspective: 'raw', tag: REQUEST_TAG });
63
61
  }
64
62
  export function involvedTargets(plan) {
65
63
  return plan.resources
@@ -9,3 +9,17 @@
9
9
  * prompts such as nuke's confirmation.
10
10
  */
11
11
  export declare function canPromptOnStderr(): boolean;
12
+ /**
13
+ * Ask a `select` question on stderr — the channel this package renders prompts
14
+ * on so a redirected stdout can't swallow the question (the display side of the
15
+ * {@link canPromptOnStderr} gate). The single site that routes a `select` to
16
+ * stderr, so no `select` prompt can silently regress to stdout.
17
+ */
18
+ export declare function selectOnStderr(config: {
19
+ message: string;
20
+ choices: readonly {
21
+ name: string;
22
+ value: string;
23
+ description?: string;
24
+ }[];
25
+ }): Promise<string>;
@@ -1,4 +1,8 @@
1
1
  import { isInteractive } from '@sanity/cli-core';
2
+ import { select } from '@sanity/cli-core/ux';
2
3
  export function canPromptOnStderr() {
3
4
  return isInteractive() && process.stderr.isTTY === true;
4
5
  }
6
+ export function selectOnStderr(config) {
7
+ return select(config, { output: process.stderr });
8
+ }
@@ -1,5 +1,5 @@
1
1
  import { type DeployTarget, type WorkflowConfig, type WorkflowDeployment } from '@sanity/workflow-engine';
2
- type ChooseDeploymentName = (deployments: WorkflowDeployment[]) => Promise<string>;
2
+ export type ChooseDeploymentName = (deployments: WorkflowDeployment[]) => Promise<string>;
3
3
  interface DeploymentSelectionOptions {
4
4
  name: string | undefined;
5
5
  tag: string | undefined;
@@ -19,17 +19,20 @@ export declare function deploymentLabel({ name, tag }: Pick<WorkflowDeployment,
19
19
  * so `--deployment` can disambiguate.
20
20
  *
21
21
  * With no selector: fall back to the sole deployment when there's exactly one
22
- * (the common single-environment case), otherwise fail asking for
23
- * `--deployment` or `--tag` a multi-deployment config is ambiguous without
24
- * one. `orAlternative`
25
- * extends that ambiguity message for callers with another way out (deploy's
22
+ * (the common single-environment case). When several are configured, an
23
+ * interactive terminal is prompted to pick one ({@link canPromptOnStderr}); a
24
+ * run that cannot prompt fails asking for `--deployment` or `--tag` — a
25
+ * multi-deployment config is ambiguous without one. `orAlternative` extends
26
+ * that ambiguity message for callers with another way out (deploy's
26
27
  * `--all-tags`).
27
28
  */
28
- export declare function selectDeployment(config: WorkflowConfig, { name, tag, orAlternative, }: {
29
+ export declare function selectDeployment(config: WorkflowConfig, { name, tag, orAlternative, interactive, chooseName, }: {
29
30
  name: string | undefined;
30
31
  tag: string | undefined;
31
32
  orAlternative?: string;
32
- }): WorkflowDeployment;
33
+ interactive?: boolean | undefined;
34
+ chooseName?: ChooseDeploymentName | undefined;
35
+ }): Promise<WorkflowDeployment>;
33
36
  /**
34
37
  * Every deployment carrying `tag`, failing when the tag matches none — the
35
38
  * shared resolution for the callers that accept a whole tag group (deploy's
@@ -1,11 +1,10 @@
1
- import { select } from '@sanity/cli-core/ux';
2
1
  import { resourceAliasesToMap, } from '@sanity/workflow-engine';
3
2
  import { fail } from "./fail.js";
4
- import { canPromptOnStderr } from "./prompt.js";
3
+ import { canPromptOnStderr, selectOnStderr } from "./prompt.js";
5
4
  export function deploymentLabel({ name, tag }) {
6
5
  return `${name} (${tag})`;
7
6
  }
8
- export function selectDeployment(config, { name, tag, orAlternative = '', }) {
7
+ export async function selectDeployment(config, { name, tag, orAlternative = '', interactive, chooseName = chooseDeploymentName, }) {
9
8
  if (name !== undefined) {
10
9
  const deployment = config.deployments.find((candidate) => candidate.name === name);
11
10
  if (deployment === undefined) {
@@ -21,7 +20,14 @@ export function selectDeployment(config, { name, tag, orAlternative = '', }) {
21
20
  }
22
21
  return sole;
23
22
  }
23
+ return selectDefaultDeployment(config, { orAlternative, interactive, chooseName });
24
+ }
25
+ async function selectDefaultDeployment(config, { orAlternative, interactive, chooseName, }) {
24
26
  if (config.deployments.length > 1) {
27
+ if (interactive ?? canPromptOnStderr()) {
28
+ const selectedName = await chooseName(config.deployments);
29
+ return selectDeployment(config, { name: selectedName, tag: undefined });
30
+ }
25
31
  fail(`Multiple deployments configured — pass --deployment or --tag${orAlternative}.`, availableDeployments(config));
26
32
  }
27
33
  const [sole] = config.deployments;
@@ -37,43 +43,28 @@ export function deploymentsForTag(config, tag) {
37
43
  }
38
44
  return matches;
39
45
  }
40
- export async function selectDeployments(config, { name, tag, allTags, interactive, chooseName = chooseDeploymentName }) {
46
+ export async function selectDeployments(config, { name, tag, allTags, interactive, chooseName }) {
41
47
  if (allTags) {
42
48
  return config.deployments;
43
49
  }
44
50
  if (tag !== undefined) {
45
51
  return deploymentsForTag(config, tag);
46
52
  }
47
- if (name === undefined && config.deployments.length > 1) {
48
- if (!(interactive ?? canPromptOnStderr())) {
49
- return [
50
- selectDeployment(config, {
51
- name,
52
- tag,
53
- orAlternative: ', or --all-tags to deploy every deployment',
54
- }),
55
- ];
56
- }
57
- const selectedName = await chooseName(config.deployments);
58
- return [selectDeployment(config, { name: selectedName, tag: undefined })];
59
- }
60
53
  return [
61
- selectDeployment(config, {
54
+ await selectDeployment(config, {
62
55
  name,
63
56
  tag,
64
57
  orAlternative: ', or --all-tags to deploy every deployment',
58
+ interactive,
59
+ chooseName,
65
60
  }),
66
61
  ];
67
62
  }
68
63
  async function chooseDeploymentName(deployments) {
69
- return select({
64
+ return selectOnStderr({
70
65
  message: 'Select a deployment',
71
- choices: deployments.map(({ name, tag }) => ({
72
- name,
73
- value: name,
74
- description: `tag: ${tag}`,
75
- })),
76
- }, { output: process.stderr });
66
+ choices: deployments.map(({ name, tag }) => ({ name, value: name, description: `tag: ${tag}` })),
67
+ });
77
68
  }
78
69
  export function availableDeployments(config, deployments = config.deployments) {
79
70
  return `Available deployments: ${deployments.map(deploymentLabel).join(', ')}`;
package/dist/lib/ui.d.ts CHANGED
@@ -37,9 +37,10 @@ export declare function clipToLimit<T>(rows: T[], limit: number): {
37
37
  * rows, render them through {@link formatTable}, print every line, and print
38
38
  * the clip note (when present) beneath the table.
39
39
  */
40
- export declare function logClippedTable<T>({ rows, limit, headers, toCells, log, }: {
40
+ export declare function logClippedTable<T>({ rows, limit, moreAvailable, headers, toCells, log, }: {
41
41
  rows: T[];
42
42
  limit: number;
43
+ moreAvailable?: boolean;
43
44
  headers: string[];
44
45
  toCells: (row: T) => string[];
45
46
  log: (line: string) => void;
package/dist/lib/ui.js CHANGED
@@ -31,13 +31,14 @@ export function clipToLimit(rows, limit) {
31
31
  note: styleText('dim', `showing the first ${limit} — raise --limit to see more`),
32
32
  };
33
33
  }
34
- export function logClippedTable({ rows, limit, headers, toCells, log, }) {
34
+ export function logClippedTable({ rows, limit, moreAvailable, headers, toCells, log, }) {
35
35
  const { rows: clipped, note } = clipToLimit(rows, limit);
36
36
  for (const line of formatTable(headers, clipped.map(toCells))) {
37
37
  log(line);
38
38
  }
39
- if (note) {
40
- log(note);
39
+ if (note || moreAvailable) {
40
+ log(note ??
41
+ styleText('dim', `searched the first ${limit} candidates — raise --limit to see more`));
41
42
  }
42
43
  }
43
44
  export function groupBanner({ label, index, total, }) {
@@ -408,7 +408,9 @@
408
408
  ]
409
409
  },
410
410
  "editorial-workflows:reset-activity": {
411
- "aliases": [],
411
+ "aliases": [
412
+ "reset-activity"
413
+ ],
412
414
  "args": {
413
415
  "instanceId": {
414
416
  "description": "Workflow instance id.",
@@ -421,13 +423,28 @@
421
423
  "required": true
422
424
  }
423
425
  },
424
- "description": "Reset a failed activity on an in-flight instance — back to pending (re-run) or to skipped (bypass).",
425
- "flags": {},
426
- "hasDynamicHelp": false,
427
- "hidden": true,
428
- "hiddenAliases": [
429
- "reset-activity"
426
+ "description": "Reset a failed activity on an in-flight instance — back to active to re-run it, or --skip to bypass it (mark it skipped) so a gated exit transition can fire.",
427
+ "examples": [
428
+ "<%= config.bin %> reset-activity wf-instance.abc123 legal-review",
429
+ "<%= config.bin %> reset-activity wf-instance.abc123 legal-review --skip"
430
430
  ],
431
+ "flags": {
432
+ "tag": {
433
+ "description": "Workflow environment tag (e.g. prod, test) — an optional query filter, and the resource disambiguator when the config spans several.",
434
+ "name": "tag",
435
+ "hasDynamicHelp": false,
436
+ "multiple": false,
437
+ "type": "option"
438
+ },
439
+ "skip": {
440
+ "description": "Bypass the activity (mark it skipped) instead of re-running it (back to active).",
441
+ "name": "skip",
442
+ "allowNo": false,
443
+ "type": "boolean"
444
+ }
445
+ },
446
+ "hasDynamicHelp": false,
447
+ "hiddenAliases": [],
431
448
  "id": "editorial-workflows:reset-activity",
432
449
  "pluginAlias": "@sanity/workflow-cli",
433
450
  "pluginName": "@sanity/workflow-cli",
@@ -913,5 +930,5 @@
913
930
  ]
914
931
  }
915
932
  },
916
- "version": "0.14.0"
933
+ "version": "0.21.0"
917
934
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/workflow-cli",
3
- "version": "0.14.0",
3
+ "version": "0.21.0",
4
4
  "description": "Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.",
5
5
  "keywords": [
6
6
  "cli",
@@ -55,21 +55,25 @@
55
55
  "diff": "^9.0.0",
56
56
  "jiti": "^2.7.0",
57
57
  "log-symbols": "^7.0.1",
58
- "ora": "^9.4.0",
59
- "@sanity/workflow-engine": "0.19.0"
58
+ "ora": "^9.4.0"
60
59
  },
61
60
  "devDependencies": {
62
61
  "@types/diff": "^8.0.0",
63
62
  "@types/node": "^24.12.4",
64
63
  "oclif": "^4.23.16",
65
64
  "vitest": "^4.1.8",
66
- "@sanity/workflow-engine-test": "0.13.1",
67
- "@sanity/workflow-examples": "0.8.1"
65
+ "@sanity/workflow-engine": "0.21.0",
66
+ "@sanity/workflow-engine-test": "0.21.0",
67
+ "@sanity/workflow-examples": "0.10.0"
68
+ },
69
+ "peerDependencies": {
70
+ "@sanity/workflow-engine": "0.21.0"
68
71
  },
69
72
  "oclif": {
70
73
  "bin": "sanity-workflows",
71
74
  "commands": "./dist/commands",
72
75
  "dirname": "sanity-workflows",
76
+ "helpClass": "./dist/help",
73
77
  "hooks": {
74
78
  "finally": "./dist/hooks/finally/telemetry",
75
79
  "prerun": "./dist/hooks/prerun/telemetry"