@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 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`, `picocolors`
122
- for terminal UX.
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`.
@@ -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 = pc.bold(result.instance.currentStage);
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 ${pc.bold(result.name)} ${versions}`];
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: '_id', title: 'Definition ID', alignment: 'left' },
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: 'version', title: 'Version', alignment: 'right' },
73
- { name: 'stageCount', title: 'Total stages', alignment: 'right' },
74
- { name: 'inFlightCount', title: 'Instances in flight', alignment: 'right' },
75
- { name: 'totalInstances', title: 'Total instances', alignment: 'right' },
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
- table.addRow(r);
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
- * The boxed summary block at the top of `definition show` output. `columns`
25
- * is the terminal width; values wrap to fit it so `boxen` never re-wraps them.
26
- */
27
- export declare function definitionHeader(def: WorkflowDefinition, columns?: number): string;
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(boxen(definitionHeader(def, process.stdout.columns), {
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 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');
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 ['stages:', ...def.stages.flatMap((stage) => stageBlock(stage, def.initialStage))];
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 ? pc.dim(` assigned → ${assignees.map(formatAssignee).join(', ')}`) : '';
23
- return ` ${TASK_ICON[t.status]} ${t.task.name} ${pc.dim(`[${t.status}]`)}${who}`;
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 ? pc.green('ready') : pc.red('false');
27
- return ` → ${tr.transition.to} ${pc.dim(tr.transition.filter ?? 'true')} ${mark}`;
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 = [pc.bold(`stage: ${input.instance.currentStage}`), 'tasks:'];
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('', 'exit transitions:');
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
- pc.red(`${logSymbols.error} failed effect: ${effect.name}`),
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
- pc.yellow(`${logSymbols.warning} hung effect: ${effect.name}`),
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
- pc.red(`${logSymbols.error} task '${task}' is in a terminal failed state.`),
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
- * An unavailable verb is dimmed and marked `(planned)` describable but not
139
- * yet runnable from the CLI. */
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} ${pc.green('progressing')} — this instance will advance on its own.`;
156
+ return `${logSymbols.success} ${styleText('green', 'progressing')} — this instance will advance on its own.`;
164
157
  case 'waiting':
165
- return `${logSymbols.info} ${pc.cyan('waiting')} — ${waitingHeadline(diagnosis)}`;
158
+ return `${logSymbols.info} ${styleText('cyan', 'waiting')} — ${waitingHeadline(diagnosis)}`;
166
159
  case 'blocked':
167
- return `${logSymbols.info} ${pc.cyan('blocked')} — ${blockedHeadline(diagnosis)}`;
160
+ return `${logSymbols.info} ${styleText('cyan', 'blocked')} — ${blockedHeadline(diagnosis)}`;
168
161
  case 'completed':
169
- return `${logSymbols.success} ${pc.green('completed')} at ${diagnosis.at}.`;
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} ${pc.dim('aborted')} at ${diagnosis.at}${tail}.`;
165
+ return `${logSymbols.info} ${styleText('dim', 'aborted')} at ${formatTimestamp(diagnosis.at)}${tail}.`;
173
166
  }
174
167
  case 'stuck':
175
- return `${logSymbols.warning} ${pc.yellow('STUCK')} — ${causeDetail(diagnosis.cause).headline}`;
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
- lines.push('', "why it's stuck:", ...detail.why);
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 (remediations.length > 0) {
208
- lines.push('', 'suggested fix:', ...remediationLines(remediations));
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(boxen(instanceHeader(evaluation.instance), {
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 ? pc.green(logSymbols.success) : pc.red(logSymbols.error);
65
- const title = a.title !== undefined ? pc.dim(` (${a.title})`) : '';
66
- const lines = [` ${mark} ${a.task} → ${a.action}${title} ${pc.dim(`[task ${a.taskStatus}]`)}`];
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(pc.dim(` params: ${a.params.map(paramLabel).join(', ')}`));
69
+ lines.push(styleText('dim', ` params: ${a.params.map(paramLabel).join(', ')}`));
69
70
  }
70
71
  if (!a.allowed && a.disabledReason !== undefined) {
71
- lines.push(pc.dim(` disabled: ${reasonText(a.disabledReason)}`));
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 = [pc.bold(`actions on stage '${stage}':`), ''];
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('', pc.dim('fire one with:'), pc.dim(` fire-action ${instanceId} --task <task> --action <action>`));
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 = pc.bold(result.instance.currentStage);
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 ${pc.bold(action)} on ${pc.bold(task)} — now at ${stage}${tail}`,
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(boxen(instanceHeader(evaluation.instance), {
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);
@@ -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
- failedTaskCount: number;
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
- startedAt: string;
31
- status: 'aborted' | 'completed' | 'in-flight';
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
  }
@@ -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
- const groq = `*[${filters.join(' && ')}] | order(startedAt desc) [0...$limit]{
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
- "failedTaskCount": count(stages[].tasks[status == "failed"])
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
- failedTaskCount: r.failedTaskCount,
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: 'instance', alignment: 'left' },
94
- { name: 'definition', title: 'workflow', alignment: 'left' },
95
- { name: 'currentStage', title: 'stage', alignment: 'left' },
96
- { name: 'startedAt', title: 'started', alignment: 'left' },
97
- { name: 'status', title: 'status', alignment: 'left' },
98
- { name: 'failedTaskCount', title: 'failed', alignment: 'right' },
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
- table.addRow(instanceRow(r));
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 = pc.bold(result.instance.currentStage);
63
+ const stage = styleText('bold', result.instance.currentStage);
64
64
  if (!result.fired) {
65
65
  return {
66
66
  fired: false,
@@ -11,7 +11,8 @@ export default class Show extends Command {
11
11
  };
12
12
  run(): Promise<void>;
13
13
  }
14
- /** The boxed summary block at the top of `show` output. */
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
@@ -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(boxen(instanceHeader(instance), { padding: 1, borderStyle: 'round', title: 'instance' }));
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
- /** The boxed summary block at the top of `show` output. */
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 reason = abortReason(instance);
45
- const abortedLine = instance.abortedAt
46
- ? [`aborted: ${instance.abortedAt}${reason ? ` — ${reason}` : ''}`]
47
- : [];
48
- return [
49
- `instance: ${instance._id}`,
50
- `workflow: ${instance.definition} v${instance.pinnedVersion}`,
51
- `stage: ${instance.currentStage}`,
52
- `started: ${instance.startedAt}`,
53
- `completed: ${instance.completedAt ?? '—'}`,
54
- ...abortedLine,
55
- `tag: ${instance.tag}`,
56
- ].join('\n');
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 = ['stages:'];
76
+ const lines = [sectionHeader('Stages')];
65
77
  for (const stage of instance.stages) {
66
- const exited = stage.exitedAt ? ` (exited ${stage.exitedAt})` : ' (current)';
67
- lines.push(` ${stage.name}${exited}`);
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(` - ${task.name} [${task.status}]`);
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('', 'pending effects:');
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('', 'history:');
93
+ lines.push('', sectionHeader('History'));
80
94
  for (const entry of instance.history) {
81
- lines.push(` [${entry.at}] ${entry._type}`);
95
+ lines.push(` ${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${entry._type}`);
82
96
  }
83
97
  }
84
98
  return lines;
@@ -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(pc.dim(`tailing ${pc.bold(args.instanceId)} (${initial.definition} v${initial.pinnedVersion}, ${initial.history.length} prior entries, currently in ${initial.currentStage})`));
23
- this.log(pc.dim('Ctrl+C to stop'));
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(`${pc.red(`${logSymbols.error} fetch failed:`)} ${message}\n`);
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${pc.dim(`${logSymbols.info} stopped`)}\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(`${pc.dim(`[${entry.at}]`)} ${pc.cyan(entry._type)}`);
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 pc from 'picocolors';
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: pc.green('+ create '),
8
- update: pc.yellow('~ update '),
9
- unchanged: pc.dim(' 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} ${pc.dim(`(${e.docId})`)}`;
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(pc.green(`+ ${line}`));
27
+ out.push(styleText('green', `+ ${line}`));
27
28
  else if (chunk.removed)
28
- out.push(pc.red(`- ${line}`));
29
+ out.push(styleText('red', `- ${line}`));
29
30
  else
30
- out.push(pc.dim(` ${line}`));
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 = ['', pc.bold('Summary'), ...summaryLines(entries)];
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('', pc.bold(`── ${e.name} v${e.version} ──`), ...diffLines(e.existing ?? {}, e.expected));
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(`${pc.red(`${logSymbols.error} ${headline}`)}\n`);
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`);
@@ -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 ? pc.dim(` → ${op.target.scope}.${op.target.state}`) : '';
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 ? ['', pc.dim('ops applied:'), ...details] : [];
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
@@ -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
+ }
@@ -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.5.2"
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.5.2",
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
- "picocolors": "^1.1.1",
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.1",
64
- "@sanity/workflow-examples": "0.1.3"
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",