@sanity/workflow-cli 0.32.0 → 0.33.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.
@@ -0,0 +1,111 @@
1
+ import { styleText } from 'node:util';
2
+ import { formatDateTime } from '@sanity/cli-core/dates';
3
+ import { _additionalMissingDocuments, _fieldTargetLabel, _missingDocumentsSummary, } from '@sanity/workflow-engine';
4
+ import logSymbols from 'log-symbols';
5
+ import { sectionHeader } from "./ui.js";
6
+ function missingDocumentsDetail(documents) {
7
+ return {
8
+ headline: 'referenced document missing',
9
+ why: [
10
+ ...documents.map(({ target, reference }) => `${_fieldTargetLabel(target)}: ${reference.id}`),
11
+ _missingDocumentsSummary(documents),
12
+ ],
13
+ };
14
+ }
15
+ function additionalDocumentLines(diagnosis, allMissingDocuments) {
16
+ const documents = _additionalMissingDocuments(diagnosis, allMissingDocuments);
17
+ if (documents.length === 0)
18
+ return [];
19
+ return ['', sectionHeader('Missing references'), ...missingDocumentsDetail(documents).why];
20
+ }
21
+ function failedEffectDetail(effect) {
22
+ const ran = effect.durationMs !== undefined ? ` (after ${effect.durationMs}ms)` : '';
23
+ return {
24
+ headline: `a failed effect from action '${effect.origin.name}' is blocking its activity`,
25
+ why: [
26
+ styleText('red', `${logSymbols.error} failed effect: ${effect.name}`),
27
+ ` queued by action '${effect.origin.name}', failed ${formatDateTime(effect.ranAt)}${ran}`,
28
+ ...(effect.error !== undefined ? [` error: ${effect.error.message}`] : []),
29
+ '',
30
+ `The activity that fired '${effect.origin.name}' is waiting on this effect. It failed`,
31
+ `against an external system, so the activity never resolves and the stage can't advance.`,
32
+ ],
33
+ };
34
+ }
35
+ function hungEffectDetail(effect) {
36
+ return {
37
+ headline: `effect '${effect.name}' was claimed but never completed`,
38
+ why: [
39
+ styleText('yellow', `${logSymbols.warning} hung effect: ${effect.name}`),
40
+ ` claimed ${formatDateTime(effect.claim?.claimedAt ?? '?')} but never reported back — the`,
41
+ ` drainer likely died mid-dispatch, so it won't drain on its own.`,
42
+ ],
43
+ };
44
+ }
45
+ function failedActivityDetail(activity) {
46
+ return {
47
+ headline: `activity '${activity}' failed`,
48
+ why: [
49
+ styleText('red', `${logSymbols.error} activity '${activity}' is in a terminal failed state.`),
50
+ `Any exit transition gated on '${activity}' being done can never fire.`,
51
+ ],
52
+ };
53
+ }
54
+ function noTransitionDetail() {
55
+ return {
56
+ headline: `no exit transition's trigger is satisfied`,
57
+ why: [
58
+ `${logSymbols.info} every activity is resolved, but no exit transition's \`when\` is true.`,
59
+ `Likely a routing state value a trigger reads never got written.`,
60
+ ],
61
+ };
62
+ }
63
+ function transitionUnevaluableDetail(transitions) {
64
+ return {
65
+ headline: `an exit transition's trigger could not be evaluated`,
66
+ why: [
67
+ `${logSymbols.info} every activity is resolved, but ${transitions.join(', ')} reads an operand`,
68
+ `that is missing or unreadable (GROQ null), so routing is held rather than`,
69
+ `falling through. Make the data the trigger reads readable — publish the`,
70
+ `subject (or fill the field) — and the instance advances on its own; no`,
71
+ `set-stage needed.`,
72
+ ],
73
+ };
74
+ }
75
+ function remediationLines(remediations) {
76
+ return remediations.map((r) => ` • ${r.verb} — ${r.rationale}`);
77
+ }
78
+ function causeDetail(cause) {
79
+ switch (cause.kind) {
80
+ case 'document-missing':
81
+ return missingDocumentsDetail(cause.documents);
82
+ case 'failed-effect':
83
+ return failedEffectDetail(cause.effect);
84
+ case 'hung-effect':
85
+ return hungEffectDetail(cause.effect);
86
+ case 'failed-activity':
87
+ return failedActivityDetail(cause.activity);
88
+ case 'no-transition-fires':
89
+ return noTransitionDetail();
90
+ case 'transition-unevaluable':
91
+ return transitionUnevaluableDetail(cause.transitions);
92
+ }
93
+ }
94
+ export function stuckHeadline(cause) {
95
+ return `${logSymbols.warning} ${styleText('yellow', 'STUCK')} — ${causeDetail(cause).headline}`;
96
+ }
97
+ export function stuckDiagnosisLines({ diagnosis, allMissingDocuments, remediations, }) {
98
+ if (diagnosis.state !== 'stuck')
99
+ return [];
100
+ const detail = causeDetail(diagnosis.cause);
101
+ const runnable = remediations.filter((remediation) => remediation.available);
102
+ return [
103
+ '',
104
+ sectionHeader("Why it's stuck"),
105
+ ...detail.why,
106
+ ...additionalDocumentLines(diagnosis, allMissingDocuments),
107
+ ...(runnable.length > 0
108
+ ? ['', sectionHeader('Suggested fix'), ...remediationLines(runnable)]
109
+ : []),
110
+ ];
111
+ }
@@ -1,4 +1,18 @@
1
1
  import { type WorkflowConfig } from '@sanity/workflow-engine';
2
+ /**
3
+ * The validated config a `sanity.workflow` module exports, and the names that
4
+ * module exports it alongside.
5
+ */
6
+ export interface LoadedWorkflowConfig {
7
+ config: WorkflowConfig;
8
+ /** The discovered file's name, so a diagnostic names the file the user
9
+ * actually has out of {@link CONFIG_FILE_NAMES}. */
10
+ configFile: string;
11
+ /** Every name the module exports, `default` included. `blueprint generate`
12
+ * reads it because each generated module imports its deployment as a named
13
+ * export from this file. */
14
+ exportedNames: readonly string[];
15
+ }
2
16
  /**
3
17
  * Discover the `sanity.workflow.{ts,js,mjs}` in `cwd`, import its default
4
18
  * export, and validate it through {@link defineWorkflowConfig}. Any problem —
@@ -6,6 +20,12 @@ import { type WorkflowConfig } from '@sanity/workflow-engine';
6
20
  * path.
7
21
  */
8
22
  export declare function loadWorkflowConfig(cwd?: string): Promise<WorkflowConfig>;
23
+ /**
24
+ * {@link loadWorkflowConfig} plus the names the config module exports, for a
25
+ * caller that has to check one. Takes the same clean `fail` path on every
26
+ * problem.
27
+ */
28
+ export declare function loadWorkflowConfigModule(cwd?: string): Promise<LoadedWorkflowConfig>;
9
29
  /**
10
30
  * {@link loadWorkflowConfig} for telemetry setup, which must never break (or
11
31
  * exit) a command: `undefined` when the file is absent or unusable. A broken
@@ -9,15 +9,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { basename, dirname, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
+ import { CONFIG_FILE_NAMES } from '@sanity/workflow-blueprint/generate';
12
13
  import { errorMessage } from '@sanity/workflow-engine';
13
14
  import { defineWorkflowConfig } from '@sanity/workflow-engine/define';
14
15
  import { createJiti } from 'jiti';
15
16
  import { fail } from "./fail.js";
16
- const CONFIG_FILE_NAMES = [
17
- 'sanity.workflow.ts',
18
- 'sanity.workflow.js',
19
- 'sanity.workflow.mjs',
20
- ];
21
17
  function findConfigFile(cwd) {
22
18
  for (const name of CONFIG_FILE_NAMES) {
23
19
  const candidate = join(cwd, name);
@@ -36,13 +32,13 @@ class ConfigLoadError extends Error {
36
32
  this.detail = detail;
37
33
  }
38
34
  }
39
- async function importDefault(filePath) {
35
+ async function importModule(filePath) {
40
36
  const url = pathToFileURL(filePath).href;
41
37
  try {
42
- if (filePath.endsWith('.ts')) {
43
- return await createJiti(dirname(filePath)).import(url, { default: true });
44
- }
45
- return (await import(__rewriteRelativeImportExtension(url, true))).default;
38
+ const loaded = filePath.endsWith('.ts')
39
+ ? await createJiti(dirname(filePath)).import(url)
40
+ : await import(__rewriteRelativeImportExtension(url, true));
41
+ return { default: loaded.default, names: Object.keys(loaded) };
46
42
  }
47
43
  catch (err) {
48
44
  throw new ConfigLoadError(`Failed to load ${basename(filePath)}:`, errorMessage(err));
@@ -54,9 +50,13 @@ function parseConfigFile(filePath) {
54
50
  if (cached !== undefined) {
55
51
  return cached;
56
52
  }
57
- const parsed = importDefault(filePath).then((exported) => {
53
+ const parsed = importModule(filePath).then((loaded) => {
58
54
  try {
59
- return defineWorkflowConfig(exported);
55
+ return {
56
+ config: defineWorkflowConfig(loaded.default),
57
+ configFile: basename(filePath),
58
+ exportedNames: loaded.names,
59
+ };
60
60
  }
61
61
  catch (err) {
62
62
  throw new ConfigLoadError(`Invalid config in ${basename(filePath)}:`, errorMessage(err));
@@ -66,6 +66,9 @@ function parseConfigFile(filePath) {
66
66
  return parsed;
67
67
  }
68
68
  export async function loadWorkflowConfig(cwd = process.cwd()) {
69
+ return (await loadWorkflowConfigModule(cwd)).config;
70
+ }
71
+ export async function loadWorkflowConfigModule(cwd = process.cwd()) {
69
72
  const filePath = findConfigFile(cwd);
70
73
  if (filePath === undefined) {
71
74
  fail(`No ${CONFIG_FILE_NAMES[0]} found in ${cwd}.`, 'Create one that `export default defineWorkflowConfig({deployments: [...]})`.');
@@ -85,5 +88,5 @@ export async function loadWorkflowConfigIfPresent(cwd = process.cwd()) {
85
88
  if (filePath === undefined) {
86
89
  return undefined;
87
90
  }
88
- return parseConfigFile(filePath).catch(() => undefined);
91
+ return parseConfigFile(filePath).then((loaded) => loaded.config, () => undefined);
89
92
  }
@@ -11,7 +11,7 @@ export interface ShareClient {
11
11
  tag: string;
12
12
  }): Promise<T>;
13
13
  request<T>(opts: {
14
- uri: string;
14
+ url: string;
15
15
  method: string;
16
16
  body: unknown;
17
17
  tag: string;
@@ -31,7 +31,7 @@ export interface ShareCandidate {
31
31
  }
32
32
  /** Sanity's first-party definition-feedback endpoint (editorial-ai-backend,
33
33
  * routed project-agnostically through the API gateway). */
34
- export declare const SHARE_ENDPOINT_URI = "/workflow/definition-feedback";
34
+ export declare const SHARE_ENDPOINT_URL = "/workflow/definition-feedback";
35
35
  /**
36
36
  * The first-run disclosure — a product contract, pinned by test. It must
37
37
  * name the recipient (Sanity), state that the document ships VERBATIM with its
@@ -5,7 +5,7 @@ import { WORKFLOWS_DEPLOY_COMMAND_ID } from "../command-ids.js";
5
5
  import { buildDefinitionShowQuery } from "./definitions.js";
6
6
  import { canPromptOnStderr } from "./prompt.js";
7
7
  import { cliTelemetry, WorkflowDefinitionShared, WorkflowDefinitionSharingDecided, } from "./telemetry.js";
8
- export const SHARE_ENDPOINT_URI = '/workflow/definition-feedback';
8
+ export const SHARE_ENDPOINT_URL = '/workflow/definition-feedback';
9
9
  const SHARE_TAG = 'definition.share';
10
10
  const SHARE_DECISION_KEY = 'workflowCliDefinitionSharing';
11
11
  export const SHARE_FIRST_RUN_NOTICE = `${styleText('bold', 'Sharing new workflow definitions with Sanity')} to improve Workflows.\n` +
@@ -127,7 +127,7 @@ async function donate(args) {
127
127
  return false;
128
128
  }
129
129
  await shared[0].candidate.client.request({
130
- uri: SHARE_ENDPOINT_URI,
130
+ url: SHARE_ENDPOINT_URL,
131
131
  method: 'POST',
132
132
  body: { definitions: shared.map(({ entry }) => entry) },
133
133
  tag: SHARE_TAG,
@@ -41,6 +41,8 @@ export declare function setupCliTelemetry(args?: {
41
41
  /** Intake client override — tests inject a fake here, like the other
42
42
  * hook dependencies above; omitted, the real project client applies. */
43
43
  client?: TelemetryIntakeClient;
44
+ /** oclif command id — becomes the command trace's `groupOrCommand` context. */
45
+ commandId?: string;
44
46
  }): Promise<void>;
45
47
  /**
46
48
  * The invocation's environment user properties — the sanity CLI's precedent
@@ -51,7 +51,7 @@ export async function setupCliTelemetry(args) {
51
51
  env: args?.env ?? process.env,
52
52
  forceSend: args?.forceSend ?? false,
53
53
  });
54
- setCliTelemetry(telemetry);
54
+ setCliTelemetry(telemetry, args?.commandId !== undefined ? { commandId: args.commandId } : undefined);
55
55
  attachBuiltinContext({ telemetry, project, client, deps: args });
56
56
  }
57
57
  catch {
@@ -87,7 +87,7 @@ export function cliUserProperties(args) {
87
87
  export async function resolveOrgId(client, projectId) {
88
88
  try {
89
89
  const project = await raceWithDeadline(client.request({
90
- uri: `/projects/${projectId}`,
90
+ url: `/projects/${encodeURIComponent(projectId)}`,
91
91
  tag: CONTEXT_TAG,
92
92
  timeout: ORG_LOOKUP_DEADLINE_MS,
93
93
  }), ORG_LOOKUP_DEADLINE_MS);
@@ -58,8 +58,11 @@ export type CliTelemetry = {
58
58
  };
59
59
  export declare function cliTelemetry(): CliTelemetry;
60
60
  /** Install the invocation's shell and, for the built-in store, start the
61
- * command trace (completed by {@link finishCliTelemetry}). */
62
- export declare function setCliTelemetry(telemetry: CliTelemetry): void;
61
+ * command trace (completed by {@link finishCliTelemetry}). `commandId`
62
+ * becomes that trace's `groupOrCommand` context. */
63
+ export declare function setCliTelemetry(telemetry: CliTelemetry, options?: {
64
+ commandId?: string;
65
+ }): void;
63
66
  export declare function clearCliTelemetry(): void;
64
67
  /** Build the built-in Sanity-intake shell over an authenticated,
65
68
  * project-bound client. `forceSend` overrides the environment denial
@@ -1,5 +1,5 @@
1
1
  import { createBatchedStore, createSessionId, defineEvent, defineTrace, } from '@sanity/telemetry';
2
- import { createTelemetryIntake, isTelemetryEnvDenied, noopTelemetry, } from '@sanity/workflow-engine';
2
+ import { createTelemetryIntake, _resolveTelemetryEnvironment, isTelemetryEnvDenied, noopTelemetry, } from '@sanity/workflow-engine';
3
3
  export const WorkflowCliCommandExecuted = defineTrace({
4
4
  name: 'Workflows CLI Command Executed',
5
5
  version: 1,
@@ -21,10 +21,11 @@ let activeTrace;
21
21
  export function cliTelemetry() {
22
22
  return current;
23
23
  }
24
- export function setCliTelemetry(telemetry) {
24
+ export function setCliTelemetry(telemetry, options) {
25
25
  current = telemetry;
26
26
  if (telemetry.kind === 'builtin') {
27
- activeTrace = telemetry.store.logger.trace(WorkflowCliCommandExecuted);
27
+ const context = options?.commandId !== undefined ? { groupOrCommand: options.commandId } : undefined;
28
+ activeTrace = telemetry.store.logger.trace(WorkflowCliCommandExecuted, context);
28
29
  activeTrace.start();
29
30
  }
30
31
  }
@@ -35,7 +36,15 @@ export function clearCliTelemetry() {
35
36
  export function createBuiltinTelemetry(args) {
36
37
  const { client, projectId, env, forceSend = false } = args;
37
38
  const envDenied = isTelemetryEnvDenied(env);
38
- const store = createBatchedStore(createSessionId(), createTelemetryIntake({ client, projectId, denied: forceSend ? false : envDenied }));
39
+ const store = createBatchedStore(createSessionId(), createTelemetryIntake({
40
+ client,
41
+ projectId,
42
+ denied: forceSend ? false : envDenied,
43
+ context: {
44
+ surface: 'cli',
45
+ environment: _resolveTelemetryEnvironment(env.NODE_ENV, 'production'),
46
+ },
47
+ }));
39
48
  return { kind: 'builtin', logger: store.logger, store, envDenied, forceSend };
40
49
  }
41
50
  function traceAsLogEvent(trace) {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * First argv tokens that belong under {@link WORKFLOWS_TOPIC}.
3
3
  * Keep in sync with `src/commands/workflows/` (top-level command
4
- * files plus the `definition` subtopic directory).
4
+ * files plus the `blueprint` and `definition` subtopic directories).
5
5
  */
6
- export declare const STANDALONE_ROOT_COMMANDS: readonly ["abort", "definition", "deploy", "diagnose", "fire-action", "list", "nuke", "reset-activity", "set-stage", "show", "start", "tail"];
6
+ export declare const STANDALONE_ROOT_COMMANDS: readonly ["abort", "blueprint", "definition", "deploy", "diagnose", "fire-action", "list", "nuke", "reset-activity", "set-stage", "show", "start", "tail"];
7
7
  /** Index of the first argv token that is not a flag (and not `--`). */
8
8
  export declare function firstPositionalIndex(args: string[]): number;
9
9
  /**
@@ -1,6 +1,7 @@
1
1
  import { WORKFLOWS_TOPIC } from "./command-ids.js";
2
2
  export const STANDALONE_ROOT_COMMANDS = [
3
3
  'abort',
4
+ 'blueprint',
4
5
  'definition',
5
6
  'deploy',
6
7
  'diagnose',
@@ -739,6 +739,38 @@
739
739
  "tail.js"
740
740
  ]
741
741
  },
742
+ "workflows:blueprint:generate": {
743
+ "aliases": [],
744
+ "args": {},
745
+ "description": "Experimental: generate the Sanity Blueprints runtime the definitions require, next to sanity.workflow.ts. Writes the workflow resources, one function per derived need, the effect-handler registry, and a handler stub per declared effect. Covers every deployment in the config, because the emitted resources module declares them all. These flags and this output may change before the Blueprints backend accepts the sanity.workflow resource.",
746
+ "examples": [
747
+ "<%= config.bin %> workflows blueprint generate",
748
+ "<%= config.bin %> workflows blueprint generate --check"
749
+ ],
750
+ "flags": {
751
+ "check": {
752
+ "description": "Experimental: verify the tree on disk still matches the definitions; write nothing and exit non-zero on any difference. The CI drift gate.",
753
+ "name": "check",
754
+ "allowNo": false,
755
+ "type": "boolean"
756
+ }
757
+ },
758
+ "hasDynamicHelp": false,
759
+ "hiddenAliases": [],
760
+ "id": "workflows:blueprint:generate",
761
+ "pluginAlias": "@sanity/workflow-cli",
762
+ "pluginName": "@sanity/workflow-cli",
763
+ "pluginType": "core",
764
+ "strict": true,
765
+ "isESM": true,
766
+ "relativePath": [
767
+ "dist",
768
+ "commands",
769
+ "workflows",
770
+ "blueprint",
771
+ "generate.js"
772
+ ]
773
+ },
742
774
  "workflows:definition:delete": {
743
775
  "aliases": [],
744
776
  "args": {
@@ -975,5 +1007,5 @@
975
1007
  ]
976
1008
  }
977
1009
  },
978
- "version": "0.32.0"
1010
+ "version": "0.33.0"
979
1011
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/workflow-cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.",
5
5
  "keywords": [
6
6
  "cli",
@@ -53,21 +53,27 @@
53
53
  "diff": "^9.0.0",
54
54
  "jiti": "^2.7.0",
55
55
  "log-symbols": "^7.0.1",
56
- "ora": "^9.4.0"
56
+ "ora": "^9.4.0",
57
+ "typescript": "^6.0.3"
57
58
  },
58
59
  "devDependencies": {
60
+ "@sanity/blueprints": "^0.21.0",
59
61
  "@sanity/cli-core": "^3.6.0",
62
+ "@sanity/functions": "^1.7.2",
60
63
  "@types/diff": "^8.0.0",
61
64
  "@types/node": "^24.12.4",
62
65
  "oclif": "^4.23.16",
63
66
  "vitest": "^4.1.8",
64
- "@sanity/workflow-engine": "0.32.0",
65
- "@sanity/workflow-engine-test": "0.32.0",
66
- "@sanity/workflow-examples": "0.12.0"
67
+ "@sanity/workflow-blueprint": "0.33.0",
68
+ "@sanity/workflow-engine": "0.33.0",
69
+ "@sanity/workflow-engine-test": "0.33.0",
70
+ "@sanity/workflow-examples": "0.12.1",
71
+ "@sanity/workflow-test-fixtures": "0.0.0"
67
72
  },
68
73
  "peerDependencies": {
69
74
  "@sanity/cli-core": "^3.6.0",
70
- "@sanity/workflow-engine": "0.32.0"
75
+ "@sanity/workflow-blueprint": "0.33.0",
76
+ "@sanity/workflow-engine": "0.33.0"
71
77
  },
72
78
  "oclif": {
73
79
  "bin": "sanity-workflows",
@@ -86,6 +92,9 @@
86
92
  "workflows": {
87
93
  "description": "Deploy, inspect, and administer Workflows definitions and instances",
88
94
  "subtopics": {
95
+ "blueprint": {
96
+ "description": "Generate and verify the Sanity Blueprints runtime the definitions require"
97
+ },
89
98
  "definition": {
90
99
  "description": "Read and manage workflow definitions"
91
100
  }