@sanity/workflow-cli 0.5.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/bin/run.js +8 -0
  4. package/dist/commands/abort.d.ts +30 -0
  5. package/dist/commands/abort.js +62 -0
  6. package/dist/commands/definition/delete.d.ts +36 -0
  7. package/dist/commands/definition/delete.js +86 -0
  8. package/dist/commands/definition/diff.d.ts +13 -0
  9. package/dist/commands/definition/diff.js +57 -0
  10. package/dist/commands/definition/list.d.ts +31 -0
  11. package/dist/commands/definition/list.js +83 -0
  12. package/dist/commands/definition/show.d.ts +33 -0
  13. package/dist/commands/definition/show.js +118 -0
  14. package/dist/commands/deploy.d.ts +22 -0
  15. package/dist/commands/deploy.js +97 -0
  16. package/dist/commands/diagnose.d.ts +23 -0
  17. package/dist/commands/diagnose.js +269 -0
  18. package/dist/commands/fire-action.d.ts +63 -0
  19. package/dist/commands/fire-action.js +241 -0
  20. package/dist/commands/list.d.ts +46 -0
  21. package/dist/commands/list.js +106 -0
  22. package/dist/commands/move-stage.d.ts +47 -0
  23. package/dist/commands/move-stage.js +77 -0
  24. package/dist/commands/retry-task.d.ts +9 -0
  25. package/dist/commands/retry-task.js +11 -0
  26. package/dist/commands/set-stage.d.ts +12 -0
  27. package/dist/commands/set-stage.js +18 -0
  28. package/dist/commands/show.d.ts +23 -0
  29. package/dist/commands/show.js +85 -0
  30. package/dist/commands/tail.d.ts +15 -0
  31. package/dist/commands/tail.js +73 -0
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.js +4 -0
  34. package/dist/lib/client.d.ts +17 -0
  35. package/dist/lib/client.js +40 -0
  36. package/dist/lib/config.d.ts +18 -0
  37. package/dist/lib/config.js +50 -0
  38. package/dist/lib/definitions.d.ts +36 -0
  39. package/dist/lib/definitions.js +122 -0
  40. package/dist/lib/diff.d.ts +7 -0
  41. package/dist/lib/diff.js +49 -0
  42. package/dist/lib/env.d.ts +10 -0
  43. package/dist/lib/env.js +13 -0
  44. package/dist/lib/fail.d.ts +16 -0
  45. package/dist/lib/fail.js +33 -0
  46. package/dist/lib/flags.d.ts +5 -0
  47. package/dist/lib/flags.js +8 -0
  48. package/dist/lib/operation-args.d.ts +25 -0
  49. package/dist/lib/operation-args.js +48 -0
  50. package/dist/lib/ops-report.d.ts +30 -0
  51. package/dist/lib/ops-report.js +28 -0
  52. package/dist/lib/stub.d.ts +10 -0
  53. package/dist/lib/stub.js +24 -0
  54. package/oclif.manifest.json +684 -0
  55. package/package.json +94 -0
@@ -0,0 +1,118 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { isTerminalStage, WORKFLOW_DEFINITION_TYPE, tagScopeFilter, } from '@sanity/workflow-engine';
3
+ import boxen from 'boxen';
4
+ import logSymbols from 'log-symbols';
5
+ import { loadClient } from "../../lib/client.js";
6
+ import { tagFlags } from "../../lib/flags.js";
7
+ export function buildDefinitionShowQuery(flags) {
8
+ const params = { name: flags.name };
9
+ const filters = [`_type == "${WORKFLOW_DEFINITION_TYPE}"`, 'name == $name'];
10
+ if (flags.version !== undefined) {
11
+ params.version = flags.version;
12
+ filters.push('version == $version');
13
+ }
14
+ if (flags.tag) {
15
+ params.tag = flags.tag;
16
+ filters.push(tagScopeFilter());
17
+ }
18
+ const groq = `*[${filters.join(' && ')}] | order(version desc) [0]`;
19
+ return { groq, params };
20
+ }
21
+ export default class DefinitionShow extends Command {
22
+ static description = 'Show a deployed workflow definition.';
23
+ static args = {
24
+ name: Args.string({ required: true, description: 'Workflow definition name.' }),
25
+ };
26
+ static flags = {
27
+ ...tagFlags,
28
+ version: Flags.integer({ description: 'Specific version (default: latest).' }),
29
+ };
30
+ async run() {
31
+ const { args, flags } = await this.parse(DefinitionShow);
32
+ const { groq, params } = buildDefinitionShowQuery({
33
+ name: args.name,
34
+ tag: flags.tag,
35
+ version: flags.version,
36
+ });
37
+ const client = loadClient();
38
+ const def = await client.fetch(groq, params);
39
+ if (!def) {
40
+ const at = flags.version ? `v${flags.version}` : 'latest';
41
+ this.error(`${logSymbols.error} no definition "${args.name}" (${at})`, { exit: 1 });
42
+ }
43
+ this.log(boxen(definitionHeader(def, process.stdout.columns), {
44
+ padding: 1,
45
+ borderStyle: 'round',
46
+ title: 'definition',
47
+ }));
48
+ this.log('');
49
+ for (const line of describeDefinition(def)) {
50
+ this.log(line);
51
+ }
52
+ }
53
+ }
54
+ const LABEL_WIDTH = 19;
55
+ /**
56
+ * One `label value` row, wrapping the value to `valueWidth` columns and
57
+ * hanging-indenting continuation lines under the value column — so a long
58
+ * value (the description) stays right of the label instead of bleeding back
59
+ * to the left edge of the box.
60
+ */
61
+ function wrapField(label, value, valueWidth) {
62
+ const lines = value.split(/\s+/).reduce((acc, word) => {
63
+ const last = acc[acc.length - 1];
64
+ if (last !== undefined && last.length + 1 + word.length <= valueWidth) {
65
+ acc[acc.length - 1] = `${last} ${word}`;
66
+ }
67
+ else {
68
+ acc.push(word);
69
+ }
70
+ return acc;
71
+ }, []);
72
+ const indent = ' '.repeat(LABEL_WIDTH);
73
+ return lines
74
+ .map((text, i) => (i === 0 ? `${label.padEnd(LABEL_WIDTH)}${text}` : `${indent}${text}`))
75
+ .join('\n');
76
+ }
77
+ /**
78
+ * The boxed summary block at the top of `definition show` output. `columns`
79
+ * is the terminal width; values wrap to fit it so `boxen` never re-wraps them.
80
+ */
81
+ export function definitionHeader(def, columns = 80) {
82
+ const valueWidth = Math.max(columns - LABEL_WIDTH - 8, 24);
83
+ const field = (label, value) => wrapField(label, value, valueWidth);
84
+ return [
85
+ `${logSymbols.info} ${def.name}`,
86
+ field('workflow:', `${def.name} v${def.version}`),
87
+ field('title:', def.title),
88
+ field('description:', def.description ?? '—'),
89
+ ].join('\n');
90
+ }
91
+ /** One `· action` line, annotated with the terminal status its `status.set` op sets, if any. */
92
+ function actionLine(action) {
93
+ const statusOp = (action.ops ?? []).find((op) => op.type === 'status.set');
94
+ const status = statusOp ? ` → ${statusOp.status}` : '';
95
+ return ` · ${action.name}${status}`;
96
+ }
97
+ /** A `- task` line followed by one line per action it exposes. */
98
+ function taskBlock(task) {
99
+ const title = task.title ? ` — ${task.title}` : '';
100
+ return [` - ${task.name}${title}`, ...(task.actions ?? []).map(actionLine)];
101
+ }
102
+ /** A `• stage` line (with initial/terminal markers) followed by its task blocks. */
103
+ function stageBlock(stage, initialStage) {
104
+ const title = stage.title ? ` — ${stage.title}` : '';
105
+ const terminal = isTerminalStage(stage) ? ' (terminal)' : '';
106
+ const initial = stage.name === initialStage ? ' [initial]' : '';
107
+ return [
108
+ ` • ${stage.name}${title}${terminal}${initial}`,
109
+ ...(stage.tasks ?? []).flatMap(taskBlock),
110
+ ];
111
+ }
112
+ /**
113
+ * The body lines for `definition show`: every stage with its tasks and each
114
+ * task's actions. Pure so it can be asserted without driving the oclif command.
115
+ */
116
+ export function describeDefinition(def) {
117
+ return ['stages:', ...def.stages.flatMap((stage) => stageBlock(stage, def.initialStage))];
118
+ }
@@ -0,0 +1,22 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Deploy extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ check: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ private executeDeploy;
13
+ private runDryRun;
14
+ }
15
+ /** Reject the contradictory `--check --dry-run` combination. */
16
+ export declare function validateModeFlags(check: boolean, dryRun: boolean): void;
17
+ /** One line per deployed definition, symbol-tagged by its result status. */
18
+ export declare function deployResultLines(results: {
19
+ status: string;
20
+ definition: string;
21
+ version: number;
22
+ }[]): string[];
@@ -0,0 +1,97 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { computeDiffEntries, workflow } from '@sanity/workflow-engine';
3
+ import logSymbols from 'log-symbols';
4
+ import ora from 'ora';
5
+ import { buildClient, loadClient } from "../lib/client.js";
6
+ import { loadWorkflowConfig } from "../lib/config.js";
7
+ import { loadValidatedDefinitions } from "../lib/definitions.js";
8
+ import { diffReport } from "../lib/diff.js";
9
+ import { fail } from "../lib/fail.js";
10
+ import { tagFlags } from "../lib/flags.js";
11
+ export default class Deploy extends Command {
12
+ static description = 'Validate, diff, and deploy workflow definitions to the configured dataset.';
13
+ static examples = [
14
+ '<%= config.bin %> deploy',
15
+ '<%= config.bin %> deploy --check',
16
+ '<%= config.bin %> deploy --dry-run',
17
+ '<%= config.bin %> deploy --only productLaunch',
18
+ ];
19
+ static flags = {
20
+ ...tagFlags,
21
+ 'dry-run': Flags.boolean({
22
+ description: 'Validate + diff against the deployed version; do not write.',
23
+ default: false,
24
+ }),
25
+ check: Flags.boolean({
26
+ description: 'Validate definitions only; do not contact the dataset.',
27
+ default: false,
28
+ }),
29
+ only: Flags.string({
30
+ description: 'Limit deploy/check/diff to a single workflow definition by name.',
31
+ }),
32
+ };
33
+ async run() {
34
+ const { flags } = await this.parse(Deploy);
35
+ validateModeFlags(flags.check, flags['dry-run']);
36
+ const config = loadWorkflowConfig(flags.tag);
37
+ const selected = await loadValidatedDefinitions(flags.only);
38
+ if (flags.check) {
39
+ this.log(`${logSymbols.success} ${selected.length} definition(s) passed validation (check only — dataset not contacted).`);
40
+ return;
41
+ }
42
+ const client = loadClient();
43
+ if (flags['dry-run']) {
44
+ await this.runDryRun(client, selected, config);
45
+ return;
46
+ }
47
+ await this.executeDeploy(client, selected, config);
48
+ }
49
+ async executeDeploy(client, defs, config) {
50
+ const spinner = ora(`Deploying ${defs.length} definition(s)…`).start();
51
+ try {
52
+ const { results } = await workflow.deployDefinitions({
53
+ workflowResource: config.workflowResource,
54
+ tag: config.tag,
55
+ client,
56
+ definitions: defs,
57
+ });
58
+ spinner.succeed(`Processed ${results.length} definition(s)`);
59
+ for (const line of deployResultLines(results))
60
+ this.log(line);
61
+ }
62
+ catch (error) {
63
+ spinner.fail('Deploy failed');
64
+ throw error;
65
+ }
66
+ }
67
+ async runDryRun(client, defs, config) {
68
+ const spinner = ora(`Diffing ${defs.length} definition(s) against dataset…`).start();
69
+ try {
70
+ const entries = await computeDiffEntries(client, defs, config);
71
+ spinner.succeed(`Diffed ${defs.length} definition(s)`);
72
+ for (const line of diffReport(entries))
73
+ this.log(line);
74
+ }
75
+ catch (error) {
76
+ spinner.fail('Diff failed');
77
+ throw error;
78
+ }
79
+ }
80
+ }
81
+ /** Reject the contradictory `--check --dry-run` combination. */
82
+ export function validateModeFlags(check, dryRun) {
83
+ if (check && dryRun) {
84
+ fail('Pass either --check or --dry-run, not both.');
85
+ }
86
+ }
87
+ /** One line per deployed definition, symbol-tagged by its result status. */
88
+ export function deployResultLines(results) {
89
+ const symbols = {
90
+ created: logSymbols.success,
91
+ updated: logSymbols.info,
92
+ };
93
+ return results.map((r) => {
94
+ const symbol = symbols[r.status] ?? logSymbols.warning;
95
+ return ` ${symbol} ${r.status.padEnd(9)} ${r.definition} v${r.version}`;
96
+ });
97
+ }
@@ -0,0 +1,23 @@
1
+ import { Command } from '@oclif/core';
2
+ import { type Diagnosis, type DiagnoseInput, type SuggestedRemediation } from '@sanity/workflow-engine';
3
+ /**
4
+ * The diagnosis body: the verdict line, the current stage's tasks +
5
+ * transitions as evidence (for any in-flight state), and the cause block with
6
+ * the engine's suggested fix when stuck. Pure so every verdict permutation is
7
+ * assertable without driving a live evaluation. Takes the engine's
8
+ * {@link SuggestedRemediation}s rather than re-deriving them, so the rendered
9
+ * fix block and the `--json` output stay one computation.
10
+ */
11
+ export declare function renderDiagnosis(diagnosis: Diagnosis, input: DiagnoseInput, remediations: SuggestedRemediation[]): string[];
12
+ export default class Diagnose extends Command {
13
+ static description: string;
14
+ static examples: string[];
15
+ static args: {
16
+ instanceId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
17
+ };
18
+ static flags: {
19
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ };
22
+ run(): Promise<void>;
23
+ }
@@ -0,0 +1,269 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { diagnoseInputFromEvaluation, workflow, } from '@sanity/workflow-engine';
3
+ import boxen from 'boxen';
4
+ import logSymbols from 'log-symbols';
5
+ import pc from 'picocolors';
6
+ import { loadClient } from "../lib/client.js";
7
+ import { loadWorkflowConfig } from "../lib/config.js";
8
+ import { fail } from "../lib/fail.js";
9
+ import { tagFlags } from "../lib/flags.js";
10
+ import { instanceHeader } from "./show.js";
11
+ const TASK_ICON = {
12
+ done: pc.green(logSymbols.success),
13
+ active: pc.cyan('●'),
14
+ pending: pc.dim('○'),
15
+ failed: pc.red(logSymbols.error),
16
+ skipped: pc.dim('⊘'),
17
+ };
18
+ function formatAssignee(a) {
19
+ return a.type === 'user' ? `user:${a.id}` : `role:${a.role}`;
20
+ }
21
+ function taskLine(t, assignees) {
22
+ const who = assignees.length > 0 ? pc.dim(` assigned → ${assignees.map(formatAssignee).join(', ')}`) : '';
23
+ return ` ${TASK_ICON[t.status]} ${t.task.name} ${pc.dim(`[${t.status}]`)}${who}`;
24
+ }
25
+ function transitionLine(tr) {
26
+ const mark = tr.filterSatisfied ? pc.green('ready') : pc.red('false');
27
+ return ` → ${tr.transition.to} ${pc.dim(tr.transition.filter ?? 'true')} ${mark}`;
28
+ }
29
+ function currentStageLines(input) {
30
+ const lines = [pc.bold(`stage: ${input.instance.currentStage}`), 'tasks:'];
31
+ for (const t of input.tasks) {
32
+ lines.push(taskLine(t, input.assignees[t.task.name] ?? []));
33
+ }
34
+ if (input.transitions.length > 0) {
35
+ lines.push('', 'exit transitions:');
36
+ for (const tr of input.transitions) {
37
+ lines.push(transitionLine(tr));
38
+ }
39
+ }
40
+ return lines;
41
+ }
42
+ function failedEffectDetail(effect) {
43
+ const ran = effect.durationMs !== undefined ? ` (after ${effect.durationMs}ms)` : '';
44
+ return {
45
+ headline: `a failed effect is blocking task '${effect.origin.name}'`,
46
+ why: [
47
+ pc.red(`${logSymbols.error} failed effect: ${effect.name}`),
48
+ ` queued by task '${effect.origin.name}', failed ${effect.ranAt}${ran}`,
49
+ ...(effect.error !== undefined ? [` error: ${effect.error.message}`] : []),
50
+ '',
51
+ `Task '${effect.origin.name}' is waiting on this effect. It failed against an`,
52
+ `external system, so the task never completes and the stage can't advance.`,
53
+ ],
54
+ };
55
+ }
56
+ function hungEffectDetail(effect) {
57
+ return {
58
+ headline: `effect '${effect.name}' was claimed but never completed`,
59
+ why: [
60
+ pc.yellow(`${logSymbols.warning} hung effect: ${effect.name}`),
61
+ ` claimed ${effect.claim?.claimedAt ?? '?'} but never reported back — the`,
62
+ ` drainer likely died mid-dispatch, so it won't drain on its own.`,
63
+ ],
64
+ };
65
+ }
66
+ function failedTaskDetail(task) {
67
+ return {
68
+ headline: `task '${task}' failed`,
69
+ why: [
70
+ pc.red(`${logSymbols.error} task '${task}' is in a terminal failed state.`),
71
+ `Any exit transition gated on '${task}' being done can never fire.`,
72
+ ],
73
+ };
74
+ }
75
+ function waitingHeadline(w) {
76
+ const who = w.assignees.length > 0 ? ` on ${w.assignees.map(formatAssignee).join(', ')}` : '';
77
+ return `waiting${who} for action: ${w.actions.join(' or ')}`;
78
+ }
79
+ function waitingLines(w) {
80
+ const who = w.assignees.length > 0
81
+ ? `assigned to ${w.assignees.map(formatAssignee).join(', ')}`
82
+ : 'unassigned — anyone with permission can act';
83
+ return [
84
+ `${logSymbols.info} task '${w.task}' is active (${who}),`,
85
+ `waiting for action: ${w.actions.join(' or ')}. This is the normal in-flight`,
86
+ `state — it advances when someone acts.`,
87
+ ];
88
+ }
89
+ function blockedHeadline(b) {
90
+ const who = b.assignees.length > 0 ? ` for ${b.assignees.map(formatAssignee).join(', ')}` : '';
91
+ return `task '${b.task}'${who} is not yet ready — unmet requirement(s): ${b.requirements.join(', ')}`;
92
+ }
93
+ function blockedLines(b) {
94
+ const who = b.assignees.length > 0
95
+ ? `assigned to ${b.assignees.map(formatAssignee).join(', ')}`
96
+ : 'unassigned';
97
+ return [
98
+ `${logSymbols.info} task '${b.task}' is visible but not yet executable (${who}).`,
99
+ `Unmet requirement(s): ${b.requirements.join(', ')}. It can't be acted on until`,
100
+ `those preconditions are met — check what would satisfy them (a prerequisite`,
101
+ `task completing, required content landing). It will not advance on its own.`,
102
+ ];
103
+ }
104
+ function noTransitionDetail() {
105
+ return {
106
+ headline: `no exit transition's filter is satisfied`,
107
+ why: [
108
+ `${logSymbols.info} every task is resolved, but no exit transition's filter is true.`,
109
+ `Likely a routing state value a filter reads never got written.`,
110
+ ],
111
+ };
112
+ }
113
+ /** An unevaluable exit filter is a *recoverable* hold, not a dead end: it
114
+ * clears itself once the operand becomes readable, and no engine verb
115
+ * unsticks it (so the engine emits no remediation). The "what to do" —
116
+ * make the operand readable — lives here in the why, since there is no
117
+ * runnable fix to render. */
118
+ function transitionUnevaluableDetail(transitions) {
119
+ return {
120
+ headline: `an exit transition's filter could not be evaluated`,
121
+ why: [
122
+ `${logSymbols.info} every task is resolved, but ${transitions.join(', ')} reads an operand`,
123
+ `that is missing or unreadable (GROQ null), so routing is held rather than`,
124
+ `falling through. Make the data the filter reads readable — publish the`,
125
+ `subject (or fill the field) — and the instance advances on its own; no`,
126
+ `move-stage needed.`,
127
+ ],
128
+ };
129
+ }
130
+ const REMEDIATION_LABEL = {
131
+ 'retry-effect': 'retry-effect',
132
+ 'drain-effects': 're-run the effect drainer',
133
+ 'reset-task': 'reset-task',
134
+ 'set-stage': 'move-stage',
135
+ abort: 'abort',
136
+ };
137
+ /** Render the engine's structured remediations as the "suggested fix" block.
138
+ * An unavailable verb is dimmed and marked `(planned)` — describable but not
139
+ * yet runnable from the CLI. */
140
+ function remediationLines(remediations) {
141
+ return remediations.map((r) => {
142
+ const planned = r.available ? '' : pc.dim(' (planned)');
143
+ return ` • ${REMEDIATION_LABEL[r.verb]}${planned} — ${r.rationale}`;
144
+ });
145
+ }
146
+ function causeDetail(cause) {
147
+ switch (cause.kind) {
148
+ case 'failed-effect':
149
+ return failedEffectDetail(cause.effect);
150
+ case 'hung-effect':
151
+ return hungEffectDetail(cause.effect);
152
+ case 'failed-task':
153
+ return failedTaskDetail(cause.task);
154
+ case 'no-transition-fires':
155
+ return noTransitionDetail();
156
+ case 'transition-unevaluable':
157
+ return transitionUnevaluableDetail(cause.transitions);
158
+ }
159
+ }
160
+ function statusLine(diagnosis) {
161
+ switch (diagnosis.state) {
162
+ case 'progressing':
163
+ return `${logSymbols.success} ${pc.green('progressing')} — this instance will advance on its own.`;
164
+ case 'waiting':
165
+ return `${logSymbols.info} ${pc.cyan('waiting')} — ${waitingHeadline(diagnosis)}`;
166
+ case 'blocked':
167
+ return `${logSymbols.info} ${pc.cyan('blocked')} — ${blockedHeadline(diagnosis)}`;
168
+ case 'completed':
169
+ return `${logSymbols.success} ${pc.green('completed')} at ${diagnosis.at}.`;
170
+ case 'aborted': {
171
+ const tail = diagnosis.reason !== undefined ? ` — ${diagnosis.reason}` : '';
172
+ return `${logSymbols.info} ${pc.dim('aborted')} at ${diagnosis.at}${tail}.`;
173
+ }
174
+ case 'stuck':
175
+ return `${logSymbols.warning} ${pc.yellow('STUCK')} — ${causeDetail(diagnosis.cause).headline}`;
176
+ }
177
+ }
178
+ /**
179
+ * The diagnosis body: the verdict line, the current stage's tasks +
180
+ * transitions as evidence (for any in-flight state), and the cause block with
181
+ * the engine's suggested fix when stuck. Pure so every verdict permutation is
182
+ * assertable without driving a live evaluation. Takes the engine's
183
+ * {@link SuggestedRemediation}s rather than re-deriving them, so the rendered
184
+ * fix block and the `--json` output stay one computation.
185
+ */
186
+ export function renderDiagnosis(diagnosis, input, remediations) {
187
+ const lines = [statusLine(diagnosis)];
188
+ const inFlight = diagnosis.state === 'progressing' ||
189
+ diagnosis.state === 'waiting' ||
190
+ diagnosis.state === 'blocked' ||
191
+ diagnosis.state === 'stuck';
192
+ if (inFlight) {
193
+ lines.push('', ...currentStageLines(input));
194
+ }
195
+ if (diagnosis.state === 'waiting') {
196
+ lines.push('', ...waitingLines(diagnosis));
197
+ }
198
+ if (diagnosis.state === 'blocked') {
199
+ lines.push('', ...blockedLines(diagnosis));
200
+ }
201
+ if (diagnosis.state === 'stuck') {
202
+ const detail = causeDetail(diagnosis.cause);
203
+ lines.push('', "why it's stuck:", ...detail.why);
204
+ // A recoverable hold (e.g. an unevaluable transition filter) has no
205
+ // runnable remediation — its "what to do" is in the why above, so skip
206
+ // the empty fix header rather than dangle it.
207
+ if (remediations.length > 0) {
208
+ lines.push('', 'suggested fix:', ...remediationLines(remediations));
209
+ }
210
+ }
211
+ return lines;
212
+ }
213
+ export default class Diagnose extends Command {
214
+ static description = "Explain why a workflow instance is or isn't progressing, and what would unstick it.";
215
+ static examples = [
216
+ '<%= config.bin %> diagnose wf-instance.abc123',
217
+ '<%= config.bin %> diagnose wf-instance.abc123 --tag prod',
218
+ '<%= config.bin %> diagnose wf-instance.abc123 --json',
219
+ ];
220
+ static args = {
221
+ instanceId: Args.string({ required: true, description: 'Workflow instance id.' }),
222
+ };
223
+ static flags = {
224
+ ...tagFlags,
225
+ json: Flags.boolean({
226
+ description: 'Emit the structured diagnosis as JSON instead of rendered output.',
227
+ default: false,
228
+ }),
229
+ };
230
+ async run() {
231
+ const { args, flags } = await this.parse(Diagnose);
232
+ const config = loadWorkflowConfig(flags.tag);
233
+ const client = loadClient();
234
+ const { evaluation, diagnosis, remediations } = await workflow
235
+ .diagnose({
236
+ client,
237
+ tag: config.tag,
238
+ workflowResource: config.workflowResource,
239
+ instanceId: args.instanceId,
240
+ // Token-auth CLI can't hit /users/me; pin the same system actor the
241
+ // write verbs use. Transition filters and task status are actor-
242
+ // independent. A requirement that reads $actor/$assigned/$can is the
243
+ // exception — it's evaluated against this system actor, so a `blocked`
244
+ // verdict on such a requirement reflects the engine's view, not a
245
+ // specific assignee's.
246
+ access: { actor: { kind: 'system', id: 'workflow-cli' } },
247
+ })
248
+ .catch((error) => fail('diagnose failed:', error instanceof Error ? error.message : String(error)));
249
+ const input = diagnoseInputFromEvaluation(evaluation);
250
+ if (flags.json) {
251
+ this.log(JSON.stringify({
252
+ instanceId: evaluation.instance._id,
253
+ stage: evaluation.instance.currentStage,
254
+ diagnosis,
255
+ remediations,
256
+ }, null, 2));
257
+ return;
258
+ }
259
+ this.log(boxen(instanceHeader(evaluation.instance), {
260
+ padding: 1,
261
+ borderStyle: 'round',
262
+ title: 'diagnose',
263
+ }));
264
+ this.log('');
265
+ for (const line of renderDiagnosis(diagnosis, input, remediations)) {
266
+ this.log(line);
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,63 @@
1
+ import { Command } from '@oclif/core';
2
+ import { type AvailableAction } from '@sanity/workflow-engine';
3
+ import { type RanOp } from '../lib/ops-report.ts';
4
+ /**
5
+ * Parse repeated `--param key=value` flags into a params object. Each
6
+ * value is JSON-parsed so numbers/booleans/objects type correctly,
7
+ * falling back to the raw string. The engine validates the result against
8
+ * the action's declared param types.
9
+ */
10
+ export declare function parseParams(pairs: string[]): Record<string, unknown>;
11
+ /**
12
+ * The list-mode body: every action on the current stage's tasks, marked
13
+ * fireable or not (with the disabling reason), plus a hint showing the
14
+ * flags to fire one. Pure so the populated/empty permutations assert
15
+ * without a live evaluation.
16
+ */
17
+ export declare function renderAvailableActions(actions: AvailableAction[], stage: string, instanceId: string): string[];
18
+ interface FireOutcome {
19
+ fired: boolean;
20
+ cascaded: number;
21
+ instance: {
22
+ currentStage: string;
23
+ };
24
+ ranOps?: RanOp[];
25
+ }
26
+ /** The `--json` payload for a fired action — the structured counterpart of
27
+ * {@link fireActionReport}. Pure so its shape is asserted directly. */
28
+ export declare function fireResultJson(result: FireOutcome, ids: {
29
+ instanceId: string;
30
+ task: string;
31
+ action: string;
32
+ }): Record<string, unknown>;
33
+ export interface FireActionReport {
34
+ fired: boolean;
35
+ message: string;
36
+ opsLines: string[];
37
+ }
38
+ /**
39
+ * Turn a `fireAction` result into the spinner message + ops block. Pure so
40
+ * the fired / no-op / cascaded / ops permutations assert without a live
41
+ * fire.
42
+ */
43
+ export declare function fireActionReport(result: FireOutcome, task: string, action: string): FireActionReport;
44
+ export default class FireAction extends Command {
45
+ static description: string;
46
+ static examples: string[];
47
+ static args: {
48
+ instanceId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
49
+ };
50
+ static flags: {
51
+ task: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
52
+ action: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
53
+ param: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
54
+ as: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
55
+ 'as-role': import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
56
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
57
+ tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
58
+ };
59
+ run(): Promise<void>;
60
+ private listActions;
61
+ private fireOne;
62
+ }
63
+ export {};