@sanity/workflow-cli 0.5.2 → 0.6.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/README.md +4 -2
- package/dist/commands/abort.js +2 -2
- package/dist/commands/definition/delete.js +2 -2
- package/dist/commands/definition/list.d.ts +2 -1
- package/dist/commands/definition/list.js +24 -9
- package/dist/commands/definition/show.d.ts +5 -5
- package/dist/commands/definition/show.js +28 -48
- package/dist/commands/diagnose.js +30 -40
- package/dist/commands/fire-action.js +13 -16
- package/dist/commands/list.d.ts +12 -8
- package/dist/commands/list.js +39 -21
- package/dist/commands/move-stage.js +2 -2
- package/dist/commands/show.d.ts +2 -1
- package/dist/commands/show.js +38 -24
- package/dist/commands/tail.js +7 -6
- package/dist/lib/diff.js +11 -10
- package/dist/lib/fail.js +2 -2
- package/dist/lib/ops-report.js +3 -3
- package/dist/lib/ui.d.ts +24 -0
- package/dist/lib/ui.js +55 -0
- package/oclif.manifest.json +10 -9
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -118,6 +118,8 @@ Mirrors the Sanity CLI's stack so the eventual plugin port is small:
|
|
|
118
118
|
- [`@oclif/core`](https://oclif.io) for command definitions, flags,
|
|
119
119
|
help.
|
|
120
120
|
- `@oclif/plugin-help` for help output.
|
|
121
|
-
- `ora`, `boxen`, `log-symbols`, `console-table-printer
|
|
122
|
-
|
|
121
|
+
- `ora`, `boxen`, `log-symbols`, `console-table-printer` for terminal UX;
|
|
122
|
+
color comes from `node:util`'s `styleText` (no separate color dep).
|
|
123
|
+
- `date-fns` for timestamp formatting (absolute for detail views, relative
|
|
124
|
+
"… ago" for list tables).
|
|
123
125
|
- `diff` for the colored JSON unified diff in `deploy --dry-run`.
|
package/dist/commands/abort.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { workflow } from '@sanity/workflow-engine';
|
|
3
4
|
import ora from 'ora';
|
|
4
|
-
import pc from 'picocolors';
|
|
5
5
|
import { loadClient } from "../lib/client.js";
|
|
6
6
|
import { loadWorkflowConfig } from "../lib/config.js";
|
|
7
7
|
import { fail } from "../lib/fail.js";
|
|
@@ -50,7 +50,7 @@ export default class Abort extends Command {
|
|
|
50
50
|
* a real abort.
|
|
51
51
|
*/
|
|
52
52
|
export function abortReport(result) {
|
|
53
|
-
const stage =
|
|
53
|
+
const stage = styleText('bold', result.instance.currentStage);
|
|
54
54
|
if (!result.fired) {
|
|
55
55
|
const at = result.instance.completedAt ?? 'unknown';
|
|
56
56
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { workflow, } from '@sanity/workflow-engine';
|
|
3
4
|
import ora from 'ora';
|
|
4
|
-
import pc from 'picocolors';
|
|
5
5
|
import { loadClient } from "../../lib/client.js";
|
|
6
6
|
import { loadWorkflowConfig } from "../../lib/config.js";
|
|
7
7
|
import { fail } from "../../lib/fail.js";
|
|
@@ -75,7 +75,7 @@ export function deleteTargetLabel(args) {
|
|
|
75
75
|
*/
|
|
76
76
|
export function deleteReport(result) {
|
|
77
77
|
const versions = result.deletedVersions.map((v) => `v${v}`).join(', ');
|
|
78
|
-
const parts = [`Deleted ${
|
|
78
|
+
const parts = [`Deleted ${styleText('bold', result.name)} ${versions}`];
|
|
79
79
|
if (result.abortedInstanceIds.length > 0) {
|
|
80
80
|
parts.push(`aborted ${result.abortedInstanceIds.length} instance(s)`);
|
|
81
81
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
+
import { type WorkflowRole } from '@sanity/workflow-engine';
|
|
2
3
|
interface DefinitionListFlags {
|
|
3
4
|
tag?: string | undefined;
|
|
4
5
|
'workflow-id'?: string | undefined;
|
|
5
6
|
limit: number;
|
|
6
7
|
}
|
|
7
8
|
export interface DefinitionListRow {
|
|
8
|
-
_id: string;
|
|
9
9
|
name: string;
|
|
10
10
|
version: number;
|
|
11
11
|
title: string;
|
|
12
12
|
tag: string;
|
|
13
|
+
role?: WorkflowRole;
|
|
13
14
|
stageCount: number;
|
|
14
15
|
inFlightCount: number;
|
|
15
16
|
totalInstances: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import { WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, tagScopeFilter, } from '@sanity/workflow-engine';
|
|
2
|
+
import { WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, isStartableDefinition, tagScopeFilter, } from '@sanity/workflow-engine';
|
|
3
3
|
import { Table } from 'console-table-printer';
|
|
4
4
|
import logSymbols from 'log-symbols';
|
|
5
5
|
import { loadClient } from "../../lib/client.js";
|
|
@@ -27,11 +27,11 @@ export function buildDefinitionListQuery(flags) {
|
|
|
27
27
|
}
|
|
28
28
|
const instanceMatch = instanceFilters.join(' && ');
|
|
29
29
|
const groq = `*[${filters.join(' && ')}] | order(name asc, version desc) [0...$limit]{
|
|
30
|
-
_id,
|
|
31
30
|
name,
|
|
32
31
|
version,
|
|
33
32
|
title,
|
|
34
33
|
tag,
|
|
34
|
+
role,
|
|
35
35
|
"stageCount": count(stages),
|
|
36
36
|
"inFlightCount": count(*[${instanceMatch} && !defined(completedAt)]),
|
|
37
37
|
"totalInstances": count(*[${instanceMatch}])
|
|
@@ -65,18 +65,33 @@ export default class DefinitionList extends Command {
|
|
|
65
65
|
}
|
|
66
66
|
const table = new Table({
|
|
67
67
|
columns: [
|
|
68
|
-
{ name: '
|
|
68
|
+
{ name: 'workflow', title: 'Workflow', alignment: 'left' },
|
|
69
69
|
{ name: 'title', title: 'Title', alignment: 'left' },
|
|
70
|
-
{ name: 'name', title: 'Workflow', alignment: 'left' },
|
|
71
70
|
{ name: 'tag', title: 'Tag', alignment: 'left' },
|
|
72
|
-
{ name: '
|
|
73
|
-
{ name: 'stageCount', title: '
|
|
74
|
-
{ name: 'inFlightCount', title: '
|
|
75
|
-
{ name: 'totalInstances', title: '
|
|
71
|
+
{ name: 'startable', title: 'Startable', alignment: 'center' },
|
|
72
|
+
{ name: 'stageCount', title: 'Stages', alignment: 'right' },
|
|
73
|
+
{ name: 'inFlightCount', title: 'In flight', alignment: 'right' },
|
|
74
|
+
{ name: 'totalInstances', title: 'Instances', alignment: 'right' },
|
|
76
75
|
],
|
|
77
76
|
});
|
|
78
77
|
for (const r of rows) {
|
|
79
|
-
|
|
78
|
+
// The doc `_id` is `tag.name.vN`, so a Definition ID column would just
|
|
79
|
+
// repeat Workflow + Tag — and no command takes an `_id` anyway (they take
|
|
80
|
+
// name + --tag/--version). `name vN` is this CLI's identifier format (the
|
|
81
|
+
// `definition show` title). Only declared keys are passed — console-table-
|
|
82
|
+
// printer renders any extra key as a stray column.
|
|
83
|
+
//
|
|
84
|
+
// `child`-role defs are spawn-only; the Startable column tells an admin
|
|
85
|
+
// which definitions a human can actually start standalone.
|
|
86
|
+
table.addRow({
|
|
87
|
+
workflow: `${r.name} v${r.version}`,
|
|
88
|
+
title: r.title,
|
|
89
|
+
tag: r.tag,
|
|
90
|
+
startable: isStartableDefinition(r) ? 'yes' : 'spawn-only',
|
|
91
|
+
stageCount: r.stageCount,
|
|
92
|
+
inFlightCount: r.inFlightCount,
|
|
93
|
+
totalInstances: r.totalInstances,
|
|
94
|
+
});
|
|
80
95
|
}
|
|
81
96
|
table.printTable();
|
|
82
97
|
}
|
|
@@ -20,11 +20,11 @@ export default class DefinitionShow extends Command {
|
|
|
20
20
|
};
|
|
21
21
|
run(): Promise<void>;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
export declare function definitionHeader(def: WorkflowDefinition,
|
|
23
|
+
/** The summary block at the top of `definition show`: a bold name + version
|
|
24
|
+
* title, then the human title, description, and deploy `tag` as aligned
|
|
25
|
+
* detail rows. `tag` is deployment metadata (the partition), not part of the
|
|
26
|
+
* authored definition, so it's passed separately and omitted when unknown. */
|
|
27
|
+
export declare function definitionHeader(def: WorkflowDefinition, tag?: string): string;
|
|
28
28
|
/**
|
|
29
29
|
* The body lines for `definition show`: every stage with its tasks and each
|
|
30
30
|
* task's actions. Pure so it can be asserted without driving the oclif command.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { isTerminalStage, WORKFLOW_DEFINITION_TYPE, tagScopeFilter, } from '@sanity/workflow-engine';
|
|
3
|
-
import boxen from 'boxen';
|
|
4
4
|
import logSymbols from 'log-symbols';
|
|
5
5
|
import { loadClient } from "../../lib/client.js";
|
|
6
6
|
import { tagFlags } from "../../lib/flags.js";
|
|
7
|
+
import { formatKeyValue, sectionHeader } from "../../lib/ui.js";
|
|
7
8
|
export function buildDefinitionShowQuery(flags) {
|
|
8
9
|
const params = { name: flags.name };
|
|
9
10
|
const filters = [`_type == "${WORKFLOW_DEFINITION_TYPE}"`, 'name == $name'];
|
|
@@ -35,75 +36,51 @@ export default class DefinitionShow extends Command {
|
|
|
35
36
|
version: flags.version,
|
|
36
37
|
});
|
|
37
38
|
const client = loadClient();
|
|
39
|
+
// The authored {@link WorkflowDefinition} carries no `tag` — it's the
|
|
40
|
+
// partition the document was deployed into, stamped on the stored doc.
|
|
38
41
|
const def = await client.fetch(groq, params);
|
|
39
42
|
if (!def) {
|
|
40
43
|
const at = flags.version ? `v${flags.version}` : 'latest';
|
|
41
44
|
this.error(`${logSymbols.error} no definition "${args.name}" (${at})`, { exit: 1 });
|
|
42
45
|
}
|
|
43
|
-
this.log(
|
|
44
|
-
padding: 1,
|
|
45
|
-
borderStyle: 'round',
|
|
46
|
-
title: 'definition',
|
|
47
|
-
}));
|
|
46
|
+
this.log(definitionHeader(def, def.tag));
|
|
48
47
|
this.log('');
|
|
49
48
|
for (const line of describeDefinition(def)) {
|
|
50
49
|
this.log(line);
|
|
51
50
|
}
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
|
-
const
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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');
|
|
53
|
+
const HEADER_PAD = 11; // "Description" — the longest label in the block
|
|
54
|
+
/** The summary block at the top of `definition show`: a bold name + version
|
|
55
|
+
* title, then the human title, description, and deploy `tag` as aligned
|
|
56
|
+
* detail rows. `tag` is deployment metadata (the partition), not part of the
|
|
57
|
+
* authored definition, so it's passed separately and omitted when unknown. */
|
|
58
|
+
export function definitionHeader(def, tag) {
|
|
59
|
+
const rows = [
|
|
60
|
+
formatKeyValue('Title', def.title, { padTo: HEADER_PAD }),
|
|
61
|
+
formatKeyValue('Description', def.description ?? styleText('dim', '—'), { padTo: HEADER_PAD }),
|
|
62
|
+
];
|
|
63
|
+
if (tag !== undefined) {
|
|
64
|
+
rows.push(formatKeyValue('Tag', tag, { padTo: HEADER_PAD }));
|
|
65
|
+
}
|
|
66
|
+
return [styleText('bold', `${def.name} v${def.version}`), ...rows].join('\n');
|
|
90
67
|
}
|
|
91
68
|
/** One `· action` line, annotated with the terminal status its `status.set` op sets, if any. */
|
|
92
69
|
function actionLine(action) {
|
|
93
70
|
const statusOp = (action.ops ?? []).find((op) => op.type === 'status.set');
|
|
94
|
-
const status = statusOp ? ` → ${statusOp.status}` : '';
|
|
71
|
+
const status = statusOp ? styleText('dim', ` → ${statusOp.status}`) : '';
|
|
95
72
|
return ` · ${action.name}${status}`;
|
|
96
73
|
}
|
|
97
74
|
/** A `- task` line followed by one line per action it exposes. */
|
|
98
75
|
function taskBlock(task) {
|
|
99
|
-
const title = task.title ? ` — ${task.title}` : '';
|
|
76
|
+
const title = task.title ? styleText('dim', ` — ${task.title}`) : '';
|
|
100
77
|
return [` - ${task.name}${title}`, ...(task.actions ?? []).map(actionLine)];
|
|
101
78
|
}
|
|
102
79
|
/** A `• stage` line (with initial/terminal markers) followed by its task blocks. */
|
|
103
80
|
function stageBlock(stage, initialStage) {
|
|
104
|
-
const title = stage.title ? ` — ${stage.title}` : '';
|
|
105
|
-
const terminal = isTerminalStage(stage) ? ' (terminal)' : '';
|
|
106
|
-
const initial = stage.name === initialStage ? ' [initial]' : '';
|
|
81
|
+
const title = stage.title ? styleText('dim', ` — ${stage.title}`) : '';
|
|
82
|
+
const terminal = isTerminalStage(stage) ? styleText('dim', ' (terminal)') : '';
|
|
83
|
+
const initial = stage.name === initialStage ? styleText('cyan', ' [initial]') : '';
|
|
107
84
|
return [
|
|
108
85
|
` • ${stage.name}${title}${terminal}${initial}`,
|
|
109
86
|
...(stage.tasks ?? []).flatMap(taskBlock),
|
|
@@ -114,5 +91,8 @@ function stageBlock(stage, initialStage) {
|
|
|
114
91
|
* task's actions. Pure so it can be asserted without driving the oclif command.
|
|
115
92
|
*/
|
|
116
93
|
export function describeDefinition(def) {
|
|
117
|
-
return [
|
|
94
|
+
return [
|
|
95
|
+
sectionHeader('Stages'),
|
|
96
|
+
...def.stages.flatMap((stage) => stageBlock(stage, def.initialStage)),
|
|
97
|
+
];
|
|
118
98
|
}
|
|
@@ -1,38 +1,33 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { diagnoseInputFromEvaluation, workflow, } from '@sanity/workflow-engine';
|
|
3
|
-
import boxen from 'boxen';
|
|
4
4
|
import logSymbols from 'log-symbols';
|
|
5
|
-
import pc from 'picocolors';
|
|
6
5
|
import { loadClient } from "../lib/client.js";
|
|
7
6
|
import { loadWorkflowConfig } from "../lib/config.js";
|
|
8
7
|
import { fail } from "../lib/fail.js";
|
|
9
8
|
import { tagFlags } from "../lib/flags.js";
|
|
9
|
+
import { formatTimestamp, sectionHeader, taskIcon } from "../lib/ui.js";
|
|
10
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
11
|
function formatAssignee(a) {
|
|
19
12
|
return a.type === 'user' ? `user:${a.id}` : `role:${a.role}`;
|
|
20
13
|
}
|
|
21
14
|
function taskLine(t, assignees) {
|
|
22
|
-
const who = assignees.length > 0
|
|
23
|
-
|
|
15
|
+
const who = assignees.length > 0
|
|
16
|
+
? styleText('dim', ` assigned → ${assignees.map(formatAssignee).join(', ')}`)
|
|
17
|
+
: '';
|
|
18
|
+
return ` ${taskIcon[t.status]} ${t.task.name} ${styleText('dim', `[${t.status}]`)}${who}`;
|
|
24
19
|
}
|
|
25
20
|
function transitionLine(tr) {
|
|
26
|
-
const mark = tr.filterSatisfied ?
|
|
27
|
-
return ` → ${tr.transition.to} ${
|
|
21
|
+
const mark = tr.filterSatisfied ? styleText('green', 'ready') : styleText('red', 'false');
|
|
22
|
+
return ` → ${tr.transition.to} ${styleText('dim', tr.transition.filter ?? 'true')} ${mark}`;
|
|
28
23
|
}
|
|
29
24
|
function currentStageLines(input) {
|
|
30
|
-
const lines = [
|
|
25
|
+
const lines = [`${sectionHeader('Stage')} ${input.instance.currentStage}`, sectionHeader('Tasks')];
|
|
31
26
|
for (const t of input.tasks) {
|
|
32
27
|
lines.push(taskLine(t, input.assignees[t.task.name] ?? []));
|
|
33
28
|
}
|
|
34
29
|
if (input.transitions.length > 0) {
|
|
35
|
-
lines.push('', '
|
|
30
|
+
lines.push('', sectionHeader('Exit transitions'));
|
|
36
31
|
for (const tr of input.transitions) {
|
|
37
32
|
lines.push(transitionLine(tr));
|
|
38
33
|
}
|
|
@@ -44,8 +39,8 @@ function failedEffectDetail(effect) {
|
|
|
44
39
|
return {
|
|
45
40
|
headline: `a failed effect is blocking task '${effect.origin.name}'`,
|
|
46
41
|
why: [
|
|
47
|
-
|
|
48
|
-
` queued by task '${effect.origin.name}', failed ${effect.ranAt}${ran}`,
|
|
42
|
+
styleText('red', `${logSymbols.error} failed effect: ${effect.name}`),
|
|
43
|
+
` queued by task '${effect.origin.name}', failed ${formatTimestamp(effect.ranAt)}${ran}`,
|
|
49
44
|
...(effect.error !== undefined ? [` error: ${effect.error.message}`] : []),
|
|
50
45
|
'',
|
|
51
46
|
`Task '${effect.origin.name}' is waiting on this effect. It failed against an`,
|
|
@@ -57,8 +52,8 @@ function hungEffectDetail(effect) {
|
|
|
57
52
|
return {
|
|
58
53
|
headline: `effect '${effect.name}' was claimed but never completed`,
|
|
59
54
|
why: [
|
|
60
|
-
|
|
61
|
-
` claimed ${effect.claim?.claimedAt ?? '?'} but never reported back — the`,
|
|
55
|
+
styleText('yellow', `${logSymbols.warning} hung effect: ${effect.name}`),
|
|
56
|
+
` claimed ${formatTimestamp(effect.claim?.claimedAt ?? '?')} but never reported back — the`,
|
|
62
57
|
` drainer likely died mid-dispatch, so it won't drain on its own.`,
|
|
63
58
|
],
|
|
64
59
|
};
|
|
@@ -67,7 +62,7 @@ function failedTaskDetail(task) {
|
|
|
67
62
|
return {
|
|
68
63
|
headline: `task '${task}' failed`,
|
|
69
64
|
why: [
|
|
70
|
-
|
|
65
|
+
styleText('red', `${logSymbols.error} task '${task}' is in a terminal failed state.`),
|
|
71
66
|
`Any exit transition gated on '${task}' being done can never fire.`,
|
|
72
67
|
],
|
|
73
68
|
};
|
|
@@ -135,13 +130,11 @@ const REMEDIATION_LABEL = {
|
|
|
135
130
|
abort: 'abort',
|
|
136
131
|
};
|
|
137
132
|
/** Render the engine's structured remediations as the "suggested fix" block.
|
|
138
|
-
*
|
|
139
|
-
* yet
|
|
133
|
+
* Only the runnable ones reach here — {@link renderDiagnosis} filters out
|
|
134
|
+
* verbs the CLI can't yet perform so it never advertises a fix you can't run
|
|
135
|
+
* (the full set, planned verbs included, still rides `--json`). */
|
|
140
136
|
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
|
-
});
|
|
137
|
+
return remediations.map((r) => ` • ${REMEDIATION_LABEL[r.verb]} — ${r.rationale}`);
|
|
145
138
|
}
|
|
146
139
|
function causeDetail(cause) {
|
|
147
140
|
switch (cause.kind) {
|
|
@@ -160,19 +153,19 @@ function causeDetail(cause) {
|
|
|
160
153
|
function statusLine(diagnosis) {
|
|
161
154
|
switch (diagnosis.state) {
|
|
162
155
|
case 'progressing':
|
|
163
|
-
return `${logSymbols.success} ${
|
|
156
|
+
return `${logSymbols.success} ${styleText('green', 'progressing')} — this instance will advance on its own.`;
|
|
164
157
|
case 'waiting':
|
|
165
|
-
return `${logSymbols.info} ${
|
|
158
|
+
return `${logSymbols.info} ${styleText('cyan', 'waiting')} — ${waitingHeadline(diagnosis)}`;
|
|
166
159
|
case 'blocked':
|
|
167
|
-
return `${logSymbols.info} ${
|
|
160
|
+
return `${logSymbols.info} ${styleText('cyan', 'blocked')} — ${blockedHeadline(diagnosis)}`;
|
|
168
161
|
case 'completed':
|
|
169
|
-
return `${logSymbols.success} ${
|
|
162
|
+
return `${logSymbols.success} ${styleText('green', 'completed')} at ${formatTimestamp(diagnosis.at)}.`;
|
|
170
163
|
case 'aborted': {
|
|
171
164
|
const tail = diagnosis.reason !== undefined ? ` — ${diagnosis.reason}` : '';
|
|
172
|
-
return `${logSymbols.info} ${
|
|
165
|
+
return `${logSymbols.info} ${styleText('dim', 'aborted')} at ${formatTimestamp(diagnosis.at)}${tail}.`;
|
|
173
166
|
}
|
|
174
167
|
case 'stuck':
|
|
175
|
-
return `${logSymbols.warning} ${
|
|
168
|
+
return `${logSymbols.warning} ${styleText('yellow', 'STUCK')} — ${causeDetail(diagnosis.cause).headline}`;
|
|
176
169
|
}
|
|
177
170
|
}
|
|
178
171
|
/**
|
|
@@ -200,12 +193,13 @@ export function renderDiagnosis(diagnosis, input, remediations) {
|
|
|
200
193
|
}
|
|
201
194
|
if (diagnosis.state === 'stuck') {
|
|
202
195
|
const detail = causeDetail(diagnosis.cause);
|
|
203
|
-
|
|
196
|
+
const runnable = remediations.filter((r) => r.available);
|
|
197
|
+
lines.push('', sectionHeader("Why it's stuck"), ...detail.why);
|
|
204
198
|
// A recoverable hold (e.g. an unevaluable transition filter) has no
|
|
205
199
|
// runnable remediation — its "what to do" is in the why above, so skip
|
|
206
200
|
// the empty fix header rather than dangle it.
|
|
207
|
-
if (
|
|
208
|
-
lines.push('', '
|
|
201
|
+
if (runnable.length > 0) {
|
|
202
|
+
lines.push('', sectionHeader('Suggested fix'), ...remediationLines(runnable));
|
|
209
203
|
}
|
|
210
204
|
}
|
|
211
205
|
return lines;
|
|
@@ -256,11 +250,7 @@ export default class Diagnose extends Command {
|
|
|
256
250
|
}, null, 2));
|
|
257
251
|
return;
|
|
258
252
|
}
|
|
259
|
-
this.log(
|
|
260
|
-
padding: 1,
|
|
261
|
-
borderStyle: 'round',
|
|
262
|
-
title: 'diagnose',
|
|
263
|
-
}));
|
|
253
|
+
this.log(instanceHeader(evaluation.instance));
|
|
264
254
|
this.log('');
|
|
265
255
|
for (const line of renderDiagnosis(diagnosis, input, remediations)) {
|
|
266
256
|
this.log(line);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { workflow, } from '@sanity/workflow-engine';
|
|
3
|
-
import boxen from 'boxen';
|
|
4
4
|
import logSymbols from 'log-symbols';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
-
import pc from 'picocolors';
|
|
7
6
|
import { loadClient } from "../lib/client.js";
|
|
8
7
|
import { loadWorkflowConfig } from "../lib/config.js";
|
|
9
8
|
import { fail, failOnThrow } from "../lib/fail.js";
|
|
@@ -61,14 +60,16 @@ function paramLabel(p) {
|
|
|
61
60
|
return `${p.name}${p.required === true ? '' : '?'}:${p.type}`;
|
|
62
61
|
}
|
|
63
62
|
function actionLines(a) {
|
|
64
|
-
const mark = a.allowed ?
|
|
65
|
-
const title = a.title !== undefined ?
|
|
66
|
-
const lines = [
|
|
63
|
+
const mark = a.allowed ? logSymbols.success : logSymbols.error;
|
|
64
|
+
const title = a.title !== undefined ? styleText('dim', ` (${a.title})`) : '';
|
|
65
|
+
const lines = [
|
|
66
|
+
` ${mark} ${a.task} → ${a.action}${title} ${styleText('dim', `[task ${a.taskStatus}]`)}`,
|
|
67
|
+
];
|
|
67
68
|
if (a.params.length > 0) {
|
|
68
|
-
lines.push(
|
|
69
|
+
lines.push(styleText('dim', ` params: ${a.params.map(paramLabel).join(', ')}`));
|
|
69
70
|
}
|
|
70
71
|
if (!a.allowed && a.disabledReason !== undefined) {
|
|
71
|
-
lines.push(
|
|
72
|
+
lines.push(styleText('dim', ` disabled: ${reasonText(a.disabledReason)}`));
|
|
72
73
|
}
|
|
73
74
|
return lines;
|
|
74
75
|
}
|
|
@@ -82,11 +83,11 @@ export function renderAvailableActions(actions, stage, instanceId) {
|
|
|
82
83
|
if (actions.length === 0) {
|
|
83
84
|
return [`No actions on the tasks of the current stage ('${stage}').`];
|
|
84
85
|
}
|
|
85
|
-
const lines = [
|
|
86
|
+
const lines = [styleText('bold', `Actions on stage '${stage}':`), ''];
|
|
86
87
|
for (const a of actions) {
|
|
87
88
|
lines.push(...actionLines(a));
|
|
88
89
|
}
|
|
89
|
-
lines.push('',
|
|
90
|
+
lines.push('', styleText('dim', 'Fire one with:'), styleText('dim', ` fire-action ${instanceId} --task <task> --action <action>`));
|
|
90
91
|
return lines;
|
|
91
92
|
}
|
|
92
93
|
/** The `--json` payload for a fired action — the structured counterpart of
|
|
@@ -108,7 +109,7 @@ export function fireResultJson(result, ids) {
|
|
|
108
109
|
* fire.
|
|
109
110
|
*/
|
|
110
111
|
export function fireActionReport(result, task, action) {
|
|
111
|
-
const stage =
|
|
112
|
+
const stage = styleText('bold', result.instance.currentStage);
|
|
112
113
|
if (!result.fired) {
|
|
113
114
|
return {
|
|
114
115
|
fired: false,
|
|
@@ -119,7 +120,7 @@ export function fireActionReport(result, task, action) {
|
|
|
119
120
|
const tail = result.cascaded > 0 ? `, then cascaded ${result.cascaded} auto-transition(s)` : '';
|
|
120
121
|
return {
|
|
121
122
|
fired: true,
|
|
122
|
-
message: `Fired ${
|
|
123
|
+
message: `Fired ${styleText('bold', action)} on ${styleText('bold', task)} — now at ${stage}${tail}`,
|
|
123
124
|
opsLines: opsAppliedLines(result.ranOps),
|
|
124
125
|
};
|
|
125
126
|
}
|
|
@@ -198,11 +199,7 @@ export default class FireAction extends Command {
|
|
|
198
199
|
this.log(JSON.stringify({ instanceId, stage, actions }, null, 2));
|
|
199
200
|
return;
|
|
200
201
|
}
|
|
201
|
-
this.log(
|
|
202
|
-
padding: 1,
|
|
203
|
-
borderStyle: 'round',
|
|
204
|
-
title: 'fire-action',
|
|
205
|
-
}));
|
|
202
|
+
this.log(instanceHeader(evaluation.instance));
|
|
206
203
|
this.log('');
|
|
207
204
|
for (const line of renderAvailableActions(actions, stage, instanceId)) {
|
|
208
205
|
this.log(line);
|
package/dist/commands/list.d.ts
CHANGED
|
@@ -2,34 +2,38 @@ import { Command } from '@oclif/core';
|
|
|
2
2
|
interface InstanceRow {
|
|
3
3
|
_id: string;
|
|
4
4
|
definition: string;
|
|
5
|
+
tag: string;
|
|
5
6
|
currentStage: string;
|
|
6
|
-
startedAt: string;
|
|
7
7
|
completedAt?: string;
|
|
8
8
|
abortedAt?: string;
|
|
9
|
-
|
|
9
|
+
lastChangedAt: string;
|
|
10
10
|
}
|
|
11
|
+
type InstanceStatus = 'aborted' | 'completed' | 'in-flight';
|
|
11
12
|
interface ListFlags {
|
|
12
13
|
'in-flight': boolean;
|
|
13
14
|
failed: boolean;
|
|
14
15
|
'workflow-id'?: string | undefined;
|
|
16
|
+
tag?: string | undefined;
|
|
15
17
|
limit: number;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Build the GROQ query + params for `list` from its flags. Pure so the
|
|
19
|
-
* filter combinations can be asserted without a live dataset.
|
|
21
|
+
* filter combinations can be asserted without a live dataset. `--tag` is an
|
|
22
|
+
* optional filter: omit it and the listing spans every partition.
|
|
20
23
|
*/
|
|
21
24
|
export declare function buildListQuery(flags: ListFlags): {
|
|
22
25
|
groq: string;
|
|
23
26
|
params: Record<string, unknown>;
|
|
24
27
|
};
|
|
25
|
-
/** Map a queried instance to its display row (derives the status column).
|
|
28
|
+
/** Map a queried instance to its display row (derives the status column).
|
|
29
|
+
* Pure — status is the semantic value; the table colors it at render. */
|
|
26
30
|
export declare function instanceRow(r: InstanceRow): {
|
|
27
31
|
_id: string;
|
|
28
32
|
definition: string;
|
|
33
|
+
tag: string;
|
|
29
34
|
currentStage: string;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
failedTaskCount: number;
|
|
35
|
+
status: InstanceStatus;
|
|
36
|
+
lastChangedAt: string;
|
|
33
37
|
};
|
|
34
38
|
export default class List extends Command {
|
|
35
39
|
static description: string;
|
|
@@ -38,8 +42,8 @@ export default class List extends Command {
|
|
|
38
42
|
'in-flight': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
39
43
|
failed: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
40
44
|
'workflow-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
41
|
-
subject: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
42
45
|
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
46
|
+
tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
43
47
|
};
|
|
44
48
|
run(): Promise<void>;
|
|
45
49
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import { WORKFLOW_INSTANCE_TYPE } from '@sanity/workflow-engine';
|
|
3
|
+
import { WORKFLOW_INSTANCE_TYPE, tagScopeFilter } from '@sanity/workflow-engine';
|
|
3
4
|
import { Table } from 'console-table-printer';
|
|
4
5
|
import logSymbols from 'log-symbols';
|
|
5
6
|
import { loadClient } from "../lib/client.js";
|
|
7
|
+
import { tagFlags } from "../lib/flags.js";
|
|
8
|
+
import { formatAge } from "../lib/ui.js";
|
|
6
9
|
/**
|
|
7
10
|
* Build the GROQ query + params for `list` from its flags. Pure so the
|
|
8
|
-
* filter combinations can be asserted without a live dataset.
|
|
11
|
+
* filter combinations can be asserted without a live dataset. `--tag` is an
|
|
12
|
+
* optional filter: omit it and the listing spans every partition.
|
|
9
13
|
*/
|
|
10
14
|
export function buildListQuery(flags) {
|
|
11
15
|
const filters = [`_type == "${WORKFLOW_INSTANCE_TYPE}"`];
|
|
@@ -16,18 +20,22 @@ export function buildListQuery(flags) {
|
|
|
16
20
|
// Cheap approximation of --failed: any task in any stage with status "failed".
|
|
17
21
|
if (flags.failed)
|
|
18
22
|
filters.push('count(stages[].tasks[status == "failed"]) > 0');
|
|
19
|
-
|
|
23
|
+
if (flags.tag)
|
|
24
|
+
filters.push(tagScopeFilter());
|
|
25
|
+
const groq = `*[${filters.join(' && ')}] | order(lastChangedAt desc) [0...$limit]{
|
|
20
26
|
_id,
|
|
21
27
|
definition,
|
|
28
|
+
tag,
|
|
22
29
|
currentStage,
|
|
23
|
-
startedAt,
|
|
24
30
|
completedAt,
|
|
25
31
|
abortedAt,
|
|
26
|
-
|
|
32
|
+
lastChangedAt
|
|
27
33
|
}`;
|
|
28
34
|
const params = { limit: flags.limit };
|
|
29
35
|
if (flags['workflow-id'])
|
|
30
36
|
params['definition'] = flags['workflow-id'];
|
|
37
|
+
if (flags.tag)
|
|
38
|
+
params['tag'] = flags.tag;
|
|
31
39
|
return { groq, params };
|
|
32
40
|
}
|
|
33
41
|
/** Aborted instances also carry `completedAt`, so check `abortedAt` first. */
|
|
@@ -38,25 +46,33 @@ function deriveStatus(r) {
|
|
|
38
46
|
return 'completed';
|
|
39
47
|
return 'in-flight';
|
|
40
48
|
}
|
|
41
|
-
/** Map a queried instance to its display row (derives the status column).
|
|
49
|
+
/** Map a queried instance to its display row (derives the status column).
|
|
50
|
+
* Pure — status is the semantic value; the table colors it at render. */
|
|
42
51
|
export function instanceRow(r) {
|
|
43
52
|
return {
|
|
44
53
|
_id: r._id,
|
|
45
54
|
definition: r.definition,
|
|
55
|
+
tag: r.tag,
|
|
46
56
|
currentStage: r.currentStage,
|
|
47
|
-
startedAt: r.startedAt,
|
|
48
57
|
status: deriveStatus(r),
|
|
49
|
-
|
|
58
|
+
lastChangedAt: r.lastChangedAt,
|
|
50
59
|
};
|
|
51
60
|
}
|
|
61
|
+
const STATUS_COLOR = {
|
|
62
|
+
completed: 'green',
|
|
63
|
+
aborted: 'dim',
|
|
64
|
+
'in-flight': 'cyan',
|
|
65
|
+
};
|
|
52
66
|
export default class List extends Command {
|
|
53
67
|
static description = 'List workflow instances in the configured dataset.';
|
|
54
68
|
static examples = [
|
|
55
69
|
'<%= config.bin %> list',
|
|
56
70
|
'<%= config.bin %> list --in-flight',
|
|
57
71
|
'<%= config.bin %> list --workflow-id productLaunch',
|
|
72
|
+
'<%= config.bin %> list --tag prod',
|
|
58
73
|
];
|
|
59
74
|
static flags = {
|
|
75
|
+
...tagFlags,
|
|
60
76
|
'in-flight': Flags.boolean({
|
|
61
77
|
description: 'Only instances that have not completed.',
|
|
62
78
|
default: false,
|
|
@@ -68,9 +84,6 @@ export default class List extends Command {
|
|
|
68
84
|
'workflow-id': Flags.string({
|
|
69
85
|
description: 'Filter by workflow definition name.',
|
|
70
86
|
}),
|
|
71
|
-
subject: Flags.string({
|
|
72
|
-
description: 'Filter by subject document id. (not yet implemented)',
|
|
73
|
-
}),
|
|
74
87
|
limit: Flags.integer({
|
|
75
88
|
description: 'Maximum rows to return.',
|
|
76
89
|
default: 50,
|
|
@@ -78,9 +91,6 @@ export default class List extends Command {
|
|
|
78
91
|
};
|
|
79
92
|
async run() {
|
|
80
93
|
const { flags } = await this.parse(List);
|
|
81
|
-
if (flags.subject) {
|
|
82
|
-
this.warn('--subject is parsed but not yet implemented; ignoring.');
|
|
83
|
-
}
|
|
84
94
|
const { groq, params } = buildListQuery(flags);
|
|
85
95
|
const client = loadClient();
|
|
86
96
|
const rows = await client.fetch(groq, params);
|
|
@@ -90,16 +100,24 @@ export default class List extends Command {
|
|
|
90
100
|
}
|
|
91
101
|
const table = new Table({
|
|
92
102
|
columns: [
|
|
93
|
-
{ name: '_id', title: '
|
|
94
|
-
{ name: 'definition', title: '
|
|
95
|
-
{ name: '
|
|
96
|
-
{ name: '
|
|
97
|
-
{ name: 'status', title: '
|
|
98
|
-
{ name: '
|
|
103
|
+
{ name: '_id', title: 'Instance', alignment: 'left' },
|
|
104
|
+
{ name: 'definition', title: 'Workflow', alignment: 'left' },
|
|
105
|
+
{ name: 'tag', title: 'Tag', alignment: 'left' },
|
|
106
|
+
{ name: 'currentStage', title: 'Stage', alignment: 'left' },
|
|
107
|
+
{ name: 'status', title: 'Status', alignment: 'left' },
|
|
108
|
+
{ name: 'updated', title: 'Updated', alignment: 'left' },
|
|
99
109
|
],
|
|
100
110
|
});
|
|
101
111
|
for (const r of rows) {
|
|
102
|
-
|
|
112
|
+
const row = instanceRow(r);
|
|
113
|
+
table.addRow({
|
|
114
|
+
_id: row._id,
|
|
115
|
+
definition: row.definition,
|
|
116
|
+
tag: row.tag,
|
|
117
|
+
currentStage: row.currentStage,
|
|
118
|
+
status: styleText(STATUS_COLOR[row.status], row.status),
|
|
119
|
+
updated: formatAge(row.lastChangedAt),
|
|
120
|
+
});
|
|
103
121
|
}
|
|
104
122
|
table.printTable();
|
|
105
123
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { workflow } from '@sanity/workflow-engine';
|
|
3
4
|
import ora from 'ora';
|
|
4
|
-
import pc from 'picocolors';
|
|
5
5
|
import { loadClient } from "../lib/client.js";
|
|
6
6
|
import { loadWorkflowConfig } from "../lib/config.js";
|
|
7
7
|
import { fail } from "../lib/fail.js";
|
|
@@ -60,7 +60,7 @@ export function buildSetStageArgs(config, instanceId, targetStage, reason) {
|
|
|
60
60
|
* without driving a real transition.
|
|
61
61
|
*/
|
|
62
62
|
export function moveStageReport(result, to) {
|
|
63
|
-
const stage =
|
|
63
|
+
const stage = styleText('bold', result.instance.currentStage);
|
|
64
64
|
if (!result.fired) {
|
|
65
65
|
return {
|
|
66
66
|
fired: false,
|
package/dist/commands/show.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ export default class Show extends Command {
|
|
|
11
11
|
};
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
}
|
|
14
|
-
/** The
|
|
14
|
+
/** The summary block at the top of `show` (and reused by `diagnose` /
|
|
15
|
+
* `fire-action`): a bold workflow + id title, then aligned detail rows. */
|
|
15
16
|
export declare function instanceHeader(instance: WorkflowInstance): string;
|
|
16
17
|
/**
|
|
17
18
|
* The per-instance body lines: every stage with its tasks, any pending
|
package/dist/commands/show.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
3
|
import { abortReason } from '@sanity/workflow-engine';
|
|
3
|
-
import boxen from 'boxen';
|
|
4
4
|
import logSymbols from 'log-symbols';
|
|
5
5
|
import { loadClient } from "../lib/client.js";
|
|
6
|
+
import { formatKeyValue, formatTimestamp, sectionHeader, taskIcon } from "../lib/ui.js";
|
|
6
7
|
export default class Show extends Command {
|
|
7
8
|
static description = 'Show the state, tasks, and effects of a workflow instance.';
|
|
8
9
|
static examples = [
|
|
@@ -30,7 +31,7 @@ export default class Show extends Command {
|
|
|
30
31
|
if (!instance) {
|
|
31
32
|
this.error(`${logSymbols.error} no instance with id "${args.instanceId}"`, { exit: 1 });
|
|
32
33
|
}
|
|
33
|
-
this.log(
|
|
34
|
+
this.log(instanceHeader(instance));
|
|
34
35
|
this.log('');
|
|
35
36
|
for (const line of describeInstance(instance, {
|
|
36
37
|
includeHistory: flags.include.includes('history'),
|
|
@@ -39,21 +40,32 @@ export default class Show extends Command {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
const HEADER_PAD = 9; // "Completed" — the longest label in the block
|
|
44
|
+
/** The summary block at the top of `show` (and reused by `diagnose` /
|
|
45
|
+
* `fire-action`): a bold workflow + id title, then aligned detail rows. */
|
|
43
46
|
export function instanceHeader(instance) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
const title = `${styleText('bold', `${instance.definition} v${instance.pinnedVersion}`)} ${styleText('cyan', instance._id)}`;
|
|
48
|
+
const rows = [
|
|
49
|
+
formatKeyValue('Stage', instance.currentStage, { padTo: HEADER_PAD }),
|
|
50
|
+
formatKeyValue('Started', formatTimestamp(instance.startedAt), { padTo: HEADER_PAD }),
|
|
51
|
+
];
|
|
52
|
+
// Aborted instances also carry a completedAt stamped at the abort, so the
|
|
53
|
+
// abort line stands in for completion — showing a green "Completed" too would
|
|
54
|
+
// misread as success.
|
|
55
|
+
if (instance.abortedAt) {
|
|
56
|
+
const reason = abortReason(instance);
|
|
57
|
+
const tail = reason ? ` — ${reason}` : '';
|
|
58
|
+
const value = styleText('yellow', `${formatTimestamp(instance.abortedAt)}${tail}`);
|
|
59
|
+
rows.push(formatKeyValue('Aborted', value, { padTo: HEADER_PAD }));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const completed = instance.completedAt
|
|
63
|
+
? styleText('green', formatTimestamp(instance.completedAt))
|
|
64
|
+
: styleText('dim', '—');
|
|
65
|
+
rows.push(formatKeyValue('Completed', completed, { padTo: HEADER_PAD }));
|
|
66
|
+
}
|
|
67
|
+
rows.push(formatKeyValue('Tag', instance.tag, { padTo: HEADER_PAD }));
|
|
68
|
+
return [title, ...rows].join('\n');
|
|
57
69
|
}
|
|
58
70
|
/**
|
|
59
71
|
* The per-instance body lines: every stage with its tasks, any pending
|
|
@@ -61,24 +73,26 @@ export function instanceHeader(instance) {
|
|
|
61
73
|
* without driving the oclif command.
|
|
62
74
|
*/
|
|
63
75
|
export function describeInstance(instance, options) {
|
|
64
|
-
const lines = ['
|
|
76
|
+
const lines = [sectionHeader('Stages')];
|
|
65
77
|
for (const stage of instance.stages) {
|
|
66
|
-
const
|
|
67
|
-
|
|
78
|
+
const marker = stage.exitedAt
|
|
79
|
+
? styleText('dim', ` (exited ${formatTimestamp(stage.exitedAt)})`)
|
|
80
|
+
: styleText('cyan', ' (current)');
|
|
81
|
+
lines.push(` • ${stage.name}${marker}`);
|
|
68
82
|
for (const task of stage.tasks) {
|
|
69
|
-
lines.push(`
|
|
83
|
+
lines.push(` ${taskIcon[task.status]} ${task.name} ${styleText('dim', `[${task.status}]`)}`);
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
if (instance.pendingEffects.length > 0) {
|
|
73
|
-
lines.push('', '
|
|
87
|
+
lines.push('', sectionHeader('Pending effects'));
|
|
74
88
|
for (const eff of instance.pendingEffects) {
|
|
75
|
-
lines.push(` • ${eff.name} (key=${eff._key})`);
|
|
89
|
+
lines.push(` • ${eff.name} ${styleText('dim', `(key=${eff._key})`)}`);
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
if (options.includeHistory) {
|
|
79
|
-
lines.push('', '
|
|
93
|
+
lines.push('', sectionHeader('History'));
|
|
80
94
|
for (const entry of instance.history) {
|
|
81
|
-
lines.push(`
|
|
95
|
+
lines.push(` ${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${entry._type}`);
|
|
82
96
|
}
|
|
83
97
|
}
|
|
84
98
|
return lines;
|
package/dist/commands/tail.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { Args, Command } from '@oclif/core';
|
|
2
3
|
import logSymbols from 'log-symbols';
|
|
3
|
-
import pc from 'picocolors';
|
|
4
4
|
import { buildClient, loadClient } from "../lib/client.js";
|
|
5
5
|
import { fail } from "../lib/fail.js";
|
|
6
|
+
import { formatTimestamp } from "../lib/ui.js";
|
|
6
7
|
export default class Tail extends Command {
|
|
7
8
|
static description = 'Stream new history entries on a workflow instance as they land in the dataset.';
|
|
8
9
|
static examples = ['<%= config.bin %> tail wf-instance.abc123'];
|
|
@@ -19,8 +20,8 @@ export default class Tail extends Command {
|
|
|
19
20
|
if (!initial) {
|
|
20
21
|
fail(`No instance with id "${args.instanceId}"`);
|
|
21
22
|
}
|
|
22
|
-
this.log(
|
|
23
|
-
this.log(
|
|
23
|
+
this.log(styleText('dim', `tailing ${styleText('bold', args.instanceId)} (${initial.definition} v${initial.pinnedVersion}, ${initial.history.length} prior entries, currently in ${initial.currentStage})`));
|
|
24
|
+
this.log(styleText('dim', 'Ctrl+C to stop'));
|
|
24
25
|
this.log('');
|
|
25
26
|
// Track the high-water mark so we only print history entries that
|
|
26
27
|
// landed after we started tailing. Listening with `includeResult:
|
|
@@ -36,7 +37,7 @@ export default class Tail extends Command {
|
|
|
36
37
|
}
|
|
37
38
|
catch (err) {
|
|
38
39
|
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
-
process.stderr.write(`${
|
|
40
|
+
process.stderr.write(`${styleText('red', `${logSymbols.error} fetch failed:`)} ${message}\n`);
|
|
40
41
|
}
|
|
41
42
|
},
|
|
42
43
|
error: (err) => {
|
|
@@ -46,7 +47,7 @@ export default class Tail extends Command {
|
|
|
46
47
|
});
|
|
47
48
|
const shutdown = () => {
|
|
48
49
|
subscription.unsubscribe();
|
|
49
|
-
process.stderr.write(`\n${
|
|
50
|
+
process.stderr.write(`\n${styleText('dim', `${logSymbols.info} stopped`)}\n`);
|
|
50
51
|
process.exit(0);
|
|
51
52
|
};
|
|
52
53
|
process.on('SIGINT', shutdown);
|
|
@@ -66,7 +67,7 @@ export default class Tail extends Command {
|
|
|
66
67
|
return seen;
|
|
67
68
|
const newEntries = next.history.slice(seen);
|
|
68
69
|
for (const entry of newEntries) {
|
|
69
|
-
this.log(`${
|
|
70
|
+
this.log(`${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${styleText('cyan', entry._type)}`);
|
|
70
71
|
}
|
|
71
72
|
return next.history.length;
|
|
72
73
|
}
|
package/dist/lib/diff.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import { diffJson } from 'diff';
|
|
2
3
|
import logSymbols from 'log-symbols';
|
|
3
|
-
import
|
|
4
|
+
import { sectionHeader } from "./ui.js";
|
|
4
5
|
/** One summary line per diff entry, tagged create / update / unchanged. */
|
|
5
6
|
export function summaryLines(entries) {
|
|
6
7
|
const tags = {
|
|
7
|
-
create:
|
|
8
|
-
update:
|
|
9
|
-
unchanged:
|
|
8
|
+
create: styleText('green', '+ create '),
|
|
9
|
+
update: styleText('yellow', '~ update '),
|
|
10
|
+
unchanged: styleText('dim', ' unchanged'),
|
|
10
11
|
};
|
|
11
12
|
return entries.map((e) => {
|
|
12
|
-
return ` ${tags[e.status]} ${e.name} v${e.version} ${
|
|
13
|
+
return ` ${tags[e.status]} ${e.name} v${e.version} ${styleText('dim', `(${e.docId})`)}`;
|
|
13
14
|
});
|
|
14
15
|
}
|
|
15
16
|
/** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines. */
|
|
@@ -23,18 +24,18 @@ export function diffLines(oldObj, newObj) {
|
|
|
23
24
|
chunkLines.pop();
|
|
24
25
|
for (const line of chunkLines) {
|
|
25
26
|
if (chunk.added)
|
|
26
|
-
out.push(
|
|
27
|
+
out.push(styleText('green', `+ ${line}`));
|
|
27
28
|
else if (chunk.removed)
|
|
28
|
-
out.push(
|
|
29
|
+
out.push(styleText('red', `- ${line}`));
|
|
29
30
|
else
|
|
30
|
-
out.push(
|
|
31
|
+
out.push(styleText('dim', ` ${line}`));
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
return out;
|
|
34
35
|
}
|
|
35
36
|
/** The full diff report: the summary block plus a per-change diff. */
|
|
36
37
|
export function diffReport(entries) {
|
|
37
|
-
const lines = ['',
|
|
38
|
+
const lines = ['', sectionHeader('Summary'), ...summaryLines(entries)];
|
|
38
39
|
const changes = entries.filter((e) => e.status !== 'unchanged');
|
|
39
40
|
if (changes.length === 0) {
|
|
40
41
|
lines.push('', `${logSymbols.success} no changes — running deploy would be a no-op`);
|
|
@@ -43,7 +44,7 @@ export function diffReport(entries) {
|
|
|
43
44
|
for (const e of changes) {
|
|
44
45
|
// For net-new docs, existing is undefined — render the whole expected
|
|
45
46
|
// doc as added lines so the operator sees what would land.
|
|
46
|
-
lines.push('',
|
|
47
|
+
lines.push('', styleText('bold', `── ${e.name} v${e.version} ──`), ...diffLines(e.existing ?? {}, e.expected));
|
|
47
48
|
}
|
|
48
49
|
return lines;
|
|
49
50
|
}
|
package/dist/lib/fail.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import logSymbols from 'log-symbols';
|
|
2
|
-
import pc from 'picocolors';
|
|
3
3
|
/**
|
|
4
4
|
* Render a user-facing error and exit non-zero. Writes straight to
|
|
5
5
|
* stderr — no oclif framing, no Node stack — so expected failures
|
|
@@ -9,7 +9,7 @@ import pc from 'picocolors';
|
|
|
9
9
|
* error propagate so the stack is visible for debugging.
|
|
10
10
|
*/
|
|
11
11
|
export function fail(headline, detail) {
|
|
12
|
-
process.stderr.write(`${
|
|
12
|
+
process.stderr.write(`${styleText('red', `${logSymbols.error} ${headline}`)}\n`);
|
|
13
13
|
if (detail !== undefined && detail !== '') {
|
|
14
14
|
for (const line of detail.split('\n')) {
|
|
15
15
|
process.stderr.write(` ${line}\n`);
|
package/dist/lib/ops-report.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
1
2
|
import logSymbols from 'log-symbols';
|
|
2
|
-
import pc from 'picocolors';
|
|
3
3
|
/**
|
|
4
4
|
* The "ops applied" block shared by write-verb reports (move-stage,
|
|
5
5
|
* fire-action): a blank separator, a header, and one line per primitive
|
|
@@ -7,10 +7,10 @@ import pc from 'picocolors';
|
|
|
7
7
|
*/
|
|
8
8
|
export function opsAppliedLines(ranOps) {
|
|
9
9
|
const details = (ranOps ?? []).map((op) => {
|
|
10
|
-
const target = op.target ?
|
|
10
|
+
const target = op.target ? styleText('dim', ` → ${op.target.scope}.${op.target.state}`) : '';
|
|
11
11
|
return ` ${logSymbols.info} ${op.opType}${target}`;
|
|
12
12
|
});
|
|
13
|
-
return details.length > 0 ? ['',
|
|
13
|
+
return details.length > 0 ? ['', styleText('dim', 'ops applied:'), ...details] : [];
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Emit a write-verb report through its spinner: warn and bail when the
|
package/dist/lib/ui.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TaskStatus } from '@sanity/workflow-engine';
|
|
2
|
+
/**
|
|
3
|
+
* A bold `Title:` section heading. Mirrors the Sanity CLI's `sectionHeader`
|
|
4
|
+
* (name + shape) so it folds into the shared helper when this CLI merges in.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sectionHeader(title: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* A ` key: value` detail row — a dim, padded key then the value. Mirrors
|
|
9
|
+
* the Sanity CLI's `formatKeyValue` so it folds into the shared helper on
|
|
10
|
+
* merge; pass `padTo` (the longest key's length) to align a block of rows.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatKeyValue(key: string, value: string, options?: {
|
|
13
|
+
indent?: number;
|
|
14
|
+
padTo?: number;
|
|
15
|
+
}): string;
|
|
16
|
+
/** Status glyph per task state. log-symbols ship pre-colored (green ✔, red ✖),
|
|
17
|
+
* so those need no extra wrap; the rest carry their own state color. */
|
|
18
|
+
export declare const taskIcon: Record<TaskStatus, string>;
|
|
19
|
+
/** An engine ISO-8601 timestamp as an absolute `yyyy-MM-dd HH:mm:ss` — the
|
|
20
|
+
* Sanity CLI's audit-log format (see `backups/list`). For detail views. */
|
|
21
|
+
export declare function formatTimestamp(iso: string): string;
|
|
22
|
+
/** An engine ISO-8601 timestamp as a relative `… ago` — the Sanity CLI's
|
|
23
|
+
* job-list format (see `datasets/copy`). For scannable overview tables. */
|
|
24
|
+
export declare function formatAge(iso: string): string;
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
|
|
3
|
+
import { lightFormat } from 'date-fns/lightFormat';
|
|
4
|
+
import { parseISO } from 'date-fns/parseISO';
|
|
5
|
+
import logSymbols from 'log-symbols';
|
|
6
|
+
/**
|
|
7
|
+
* A bold `Title:` section heading. Mirrors the Sanity CLI's `sectionHeader`
|
|
8
|
+
* (name + shape) so it folds into the shared helper when this CLI merges in.
|
|
9
|
+
*/
|
|
10
|
+
export function sectionHeader(title) {
|
|
11
|
+
return styleText('bold', `${title}:`);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A ` key: value` detail row — a dim, padded key then the value. Mirrors
|
|
15
|
+
* the Sanity CLI's `formatKeyValue` so it folds into the shared helper on
|
|
16
|
+
* merge; pass `padTo` (the longest key's length) to align a block of rows.
|
|
17
|
+
*/
|
|
18
|
+
export function formatKeyValue(key, value, options) {
|
|
19
|
+
const indent = options?.indent ?? 2;
|
|
20
|
+
const padTo = options?.padTo ?? 0;
|
|
21
|
+
const paddedKey = `${key}:`.padEnd(padTo > 0 ? padTo + 1 : key.length + 1);
|
|
22
|
+
return `${' '.repeat(indent)}${styleText('dim', paddedKey)} ${value}`;
|
|
23
|
+
}
|
|
24
|
+
/** Status glyph per task state. log-symbols ship pre-colored (green ✔, red ✖),
|
|
25
|
+
* so those need no extra wrap; the rest carry their own state color. */
|
|
26
|
+
export const taskIcon = {
|
|
27
|
+
done: logSymbols.success,
|
|
28
|
+
active: styleText('cyan', '●'),
|
|
29
|
+
pending: styleText('dim', '○'),
|
|
30
|
+
failed: logSymbols.error,
|
|
31
|
+
skipped: styleText('dim', '⊘'),
|
|
32
|
+
};
|
|
33
|
+
/** Parse an engine ISO-8601 timestamp, or `undefined` when it's missing or
|
|
34
|
+
* unparseable. The engine writes valid ISO, but the lake is the only
|
|
35
|
+
* enforcement boundary — a legacy or raw-client-written doc can carry a bad
|
|
36
|
+
* or absent timestamp, and `parseISO`/`lightFormat` throw on those. Callers
|
|
37
|
+
* fall back to the raw value so one bad field never crashes a whole command. */
|
|
38
|
+
function parseTimestamp(iso) {
|
|
39
|
+
if (!iso)
|
|
40
|
+
return undefined;
|
|
41
|
+
const date = parseISO(iso);
|
|
42
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
43
|
+
}
|
|
44
|
+
/** An engine ISO-8601 timestamp as an absolute `yyyy-MM-dd HH:mm:ss` — the
|
|
45
|
+
* Sanity CLI's audit-log format (see `backups/list`). For detail views. */
|
|
46
|
+
export function formatTimestamp(iso) {
|
|
47
|
+
const date = parseTimestamp(iso);
|
|
48
|
+
return date ? lightFormat(date, 'yyyy-MM-dd HH:mm:ss') : iso;
|
|
49
|
+
}
|
|
50
|
+
/** An engine ISO-8601 timestamp as a relative `… ago` — the Sanity CLI's
|
|
51
|
+
* job-list format (see `datasets/copy`). For scannable overview tables. */
|
|
52
|
+
export function formatAge(iso) {
|
|
53
|
+
const date = parseTimestamp(iso);
|
|
54
|
+
return date ? `${formatDistanceToNow(date)} ago` : iso;
|
|
55
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -233,9 +233,17 @@
|
|
|
233
233
|
"examples": [
|
|
234
234
|
"<%= config.bin %> list",
|
|
235
235
|
"<%= config.bin %> list --in-flight",
|
|
236
|
-
"<%= config.bin %> list --workflow-id productLaunch"
|
|
236
|
+
"<%= config.bin %> list --workflow-id productLaunch",
|
|
237
|
+
"<%= config.bin %> list --tag prod"
|
|
237
238
|
],
|
|
238
239
|
"flags": {
|
|
240
|
+
"tag": {
|
|
241
|
+
"description": "Workflow environment tag (e.g. prod, test). Overrides WORKFLOW_TAG.",
|
|
242
|
+
"name": "tag",
|
|
243
|
+
"hasDynamicHelp": false,
|
|
244
|
+
"multiple": false,
|
|
245
|
+
"type": "option"
|
|
246
|
+
},
|
|
239
247
|
"in-flight": {
|
|
240
248
|
"description": "Only instances that have not completed.",
|
|
241
249
|
"name": "in-flight",
|
|
@@ -255,13 +263,6 @@
|
|
|
255
263
|
"multiple": false,
|
|
256
264
|
"type": "option"
|
|
257
265
|
},
|
|
258
|
-
"subject": {
|
|
259
|
-
"description": "Filter by subject document id. (not yet implemented)",
|
|
260
|
-
"name": "subject",
|
|
261
|
-
"hasDynamicHelp": false,
|
|
262
|
-
"multiple": false,
|
|
263
|
-
"type": "option"
|
|
264
|
-
},
|
|
265
266
|
"limit": {
|
|
266
267
|
"description": "Maximum rows to return.",
|
|
267
268
|
"name": "limit",
|
|
@@ -680,5 +681,5 @@
|
|
|
680
681
|
]
|
|
681
682
|
}
|
|
682
683
|
},
|
|
683
|
-
"version": "0.
|
|
684
|
+
"version": "0.6.0"
|
|
684
685
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/workflow-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -49,19 +49,19 @@
|
|
|
49
49
|
"@sanity/client": "^7.22.1",
|
|
50
50
|
"boxen": "^8.0.1",
|
|
51
51
|
"console-table-printer": "^2.16.0",
|
|
52
|
+
"date-fns": "^4.4.0",
|
|
52
53
|
"diff": "^9.0.0",
|
|
53
54
|
"log-symbols": "^7.0.1",
|
|
54
55
|
"ora": "^9.4.0",
|
|
55
|
-
"
|
|
56
|
-
"@sanity/workflow-engine": "0.7.0"
|
|
56
|
+
"@sanity/workflow-engine": "0.8.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/diff": "^8.0.0",
|
|
60
60
|
"@types/node": "^24.12.4",
|
|
61
61
|
"oclif": "^4.23.16",
|
|
62
62
|
"vitest": "^4.1.8",
|
|
63
|
-
"@sanity/workflow-engine-test": "0.4.
|
|
64
|
-
"@sanity/workflow-examples": "0.1.
|
|
63
|
+
"@sanity/workflow-engine-test": "0.4.2",
|
|
64
|
+
"@sanity/workflow-examples": "0.1.4"
|
|
65
65
|
},
|
|
66
66
|
"oclif": {
|
|
67
67
|
"bin": "sanity-workflows",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"engines": {
|
|
84
|
-
"node": ">=20"
|
|
84
|
+
"node": ">=20.12"
|
|
85
85
|
},
|
|
86
86
|
"scripts": {
|
|
87
87
|
"build": "tsc -p tsconfig.build.json",
|