@sanity/workflow-cli 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/run.js +8 -0
- package/dist/commands/abort.d.ts +30 -0
- package/dist/commands/abort.js +62 -0
- package/dist/commands/definition/delete.d.ts +36 -0
- package/dist/commands/definition/delete.js +86 -0
- package/dist/commands/definition/diff.d.ts +13 -0
- package/dist/commands/definition/diff.js +57 -0
- package/dist/commands/definition/list.d.ts +31 -0
- package/dist/commands/definition/list.js +83 -0
- package/dist/commands/definition/show.d.ts +33 -0
- package/dist/commands/definition/show.js +118 -0
- package/dist/commands/deploy.d.ts +22 -0
- package/dist/commands/deploy.js +97 -0
- package/dist/commands/diagnose.d.ts +23 -0
- package/dist/commands/diagnose.js +269 -0
- package/dist/commands/fire-action.d.ts +63 -0
- package/dist/commands/fire-action.js +241 -0
- package/dist/commands/list.d.ts +46 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/move-stage.d.ts +47 -0
- package/dist/commands/move-stage.js +77 -0
- package/dist/commands/retry-task.d.ts +9 -0
- package/dist/commands/retry-task.js +11 -0
- package/dist/commands/set-stage.d.ts +12 -0
- package/dist/commands/set-stage.js +18 -0
- package/dist/commands/show.d.ts +23 -0
- package/dist/commands/show.js +85 -0
- package/dist/commands/tail.d.ts +15 -0
- package/dist/commands/tail.js +73 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/lib/client.d.ts +17 -0
- package/dist/lib/client.js +40 -0
- package/dist/lib/config.d.ts +18 -0
- package/dist/lib/config.js +50 -0
- package/dist/lib/definitions.d.ts +36 -0
- package/dist/lib/definitions.js +122 -0
- package/dist/lib/diff.d.ts +7 -0
- package/dist/lib/diff.js +49 -0
- package/dist/lib/env.d.ts +10 -0
- package/dist/lib/env.js +13 -0
- package/dist/lib/fail.d.ts +16 -0
- package/dist/lib/fail.js +33 -0
- package/dist/lib/flags.d.ts +5 -0
- package/dist/lib/flags.js +8 -0
- package/dist/lib/operation-args.d.ts +25 -0
- package/dist/lib/operation-args.js +48 -0
- package/dist/lib/ops-report.d.ts +30 -0
- package/dist/lib/ops-report.js +28 -0
- package/dist/lib/stub.d.ts +10 -0
- package/dist/lib/stub.js +24 -0
- package/oclif.manifest.json +684 -0
- package/package.json +94 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { abortReason } from '@sanity/workflow-engine';
|
|
3
|
+
import boxen from 'boxen';
|
|
4
|
+
import logSymbols from 'log-symbols';
|
|
5
|
+
import { loadClient } from "../lib/client.js";
|
|
6
|
+
export default class Show extends Command {
|
|
7
|
+
static description = 'Show the state, tasks, and effects of a workflow instance.';
|
|
8
|
+
static examples = [
|
|
9
|
+
'<%= config.bin %> show wf-instance.abc123',
|
|
10
|
+
'<%= config.bin %> show wf-instance.abc123 --include history',
|
|
11
|
+
];
|
|
12
|
+
static args = {
|
|
13
|
+
instanceId: Args.string({
|
|
14
|
+
required: true,
|
|
15
|
+
description: 'Workflow instance document id.',
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
static flags = {
|
|
19
|
+
include: Flags.string({
|
|
20
|
+
description: 'Optional sections to include in output.',
|
|
21
|
+
options: ['history'],
|
|
22
|
+
multiple: true,
|
|
23
|
+
default: [],
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
async run() {
|
|
27
|
+
const { args, flags } = await this.parse(Show);
|
|
28
|
+
const client = loadClient();
|
|
29
|
+
const instance = await client.getDocument(args.instanceId);
|
|
30
|
+
if (!instance) {
|
|
31
|
+
this.error(`${logSymbols.error} no instance with id "${args.instanceId}"`, { exit: 1 });
|
|
32
|
+
}
|
|
33
|
+
this.log(boxen(instanceHeader(instance), { padding: 1, borderStyle: 'round', title: 'instance' }));
|
|
34
|
+
this.log('');
|
|
35
|
+
for (const line of describeInstance(instance, {
|
|
36
|
+
includeHistory: flags.include.includes('history'),
|
|
37
|
+
})) {
|
|
38
|
+
this.log(line);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The boxed summary block at the top of `show` output. */
|
|
43
|
+
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');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The per-instance body lines: every stage with its tasks, any pending
|
|
60
|
+
* effects, and (optionally) the history log. Pure so it can be asserted
|
|
61
|
+
* without driving the oclif command.
|
|
62
|
+
*/
|
|
63
|
+
export function describeInstance(instance, options) {
|
|
64
|
+
const lines = ['stages:'];
|
|
65
|
+
for (const stage of instance.stages) {
|
|
66
|
+
const exited = stage.exitedAt ? ` (exited ${stage.exitedAt})` : ' (current)';
|
|
67
|
+
lines.push(` • ${stage.name}${exited}`);
|
|
68
|
+
for (const task of stage.tasks) {
|
|
69
|
+
lines.push(` - ${task.name} [${task.status}]`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (instance.pendingEffects.length > 0) {
|
|
73
|
+
lines.push('', 'pending effects:');
|
|
74
|
+
for (const eff of instance.pendingEffects) {
|
|
75
|
+
lines.push(` • ${eff.name} (key=${eff._key})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (options.includeHistory) {
|
|
79
|
+
lines.push('', 'history:');
|
|
80
|
+
for (const entry of instance.history) {
|
|
81
|
+
lines.push(` • [${entry.at}] ${entry._type}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Tail extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
instanceId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Fetch the current instance state, print history entries past the
|
|
11
|
+
* given high-water mark, return the new mark. Pulled out so the
|
|
12
|
+
* async work in the subscription's `next` handler stays tidy.
|
|
13
|
+
*/
|
|
14
|
+
private printNewEntries;
|
|
15
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Args, Command } from '@oclif/core';
|
|
2
|
+
import logSymbols from 'log-symbols';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { buildClient, loadClient } from "../lib/client.js";
|
|
5
|
+
import { fail } from "../lib/fail.js";
|
|
6
|
+
export default class Tail extends Command {
|
|
7
|
+
static description = 'Stream new history entries on a workflow instance as they land in the dataset.';
|
|
8
|
+
static examples = ['<%= config.bin %> tail wf-instance.abc123'];
|
|
9
|
+
static args = {
|
|
10
|
+
instanceId: Args.string({
|
|
11
|
+
required: true,
|
|
12
|
+
description: 'Workflow instance id to tail.',
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
async run() {
|
|
16
|
+
const { args } = await this.parse(Tail);
|
|
17
|
+
const client = loadClient();
|
|
18
|
+
const initial = await client.getDocument(args.instanceId);
|
|
19
|
+
if (!initial) {
|
|
20
|
+
fail(`No instance with id "${args.instanceId}"`);
|
|
21
|
+
}
|
|
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'));
|
|
24
|
+
this.log('');
|
|
25
|
+
// Track the high-water mark so we only print history entries that
|
|
26
|
+
// landed after we started tailing. Listening with `includeResult:
|
|
27
|
+
// false` (matching what the studio does) keeps the wire payload
|
|
28
|
+
// small — we just refetch on each notification.
|
|
29
|
+
let seen = initial.history.length;
|
|
30
|
+
const subscription = client
|
|
31
|
+
.listen(`*[_id == $id]`, { id: args.instanceId }, { includeResult: false, visibility: 'query' })
|
|
32
|
+
.subscribe({
|
|
33
|
+
next: async () => {
|
|
34
|
+
try {
|
|
35
|
+
seen = await this.printNewEntries(client, args.instanceId, seen);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
process.stderr.write(`${pc.red(`${logSymbols.error} fetch failed:`)} ${message}\n`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
error: (err) => {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
fail('Listen subscription error:', message);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const shutdown = () => {
|
|
48
|
+
subscription.unsubscribe();
|
|
49
|
+
process.stderr.write(`\n${pc.dim(`${logSymbols.info} stopped`)}\n`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
};
|
|
52
|
+
process.on('SIGINT', shutdown);
|
|
53
|
+
process.on('SIGTERM', shutdown);
|
|
54
|
+
// Keep the event loop alive — `subscribe` doesn't itself anchor the
|
|
55
|
+
// process. The shutdown handlers above are the only exit path.
|
|
56
|
+
await new Promise(() => { });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fetch the current instance state, print history entries past the
|
|
60
|
+
* given high-water mark, return the new mark. Pulled out so the
|
|
61
|
+
* async work in the subscription's `next` handler stays tidy.
|
|
62
|
+
*/
|
|
63
|
+
async printNewEntries(client, instanceId, seen) {
|
|
64
|
+
const next = await client.getDocument(instanceId);
|
|
65
|
+
if (!next)
|
|
66
|
+
return seen;
|
|
67
|
+
const newEntries = next.history.slice(seen);
|
|
68
|
+
for (const entry of newEntries) {
|
|
69
|
+
this.log(`${pc.dim(`[${entry.at}]`)} ${pc.cyan(entry._type)}`);
|
|
70
|
+
}
|
|
71
|
+
return next.history.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { execute } from '@oclif/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SanityClient } from '@sanity/client';
|
|
2
|
+
export interface ClientEnv {
|
|
3
|
+
projectId: string;
|
|
4
|
+
dataset: string;
|
|
5
|
+
token: string;
|
|
6
|
+
apiHost: string;
|
|
7
|
+
apiVersion: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildClient(env?: ClientEnv): SanityClient;
|
|
10
|
+
/**
|
|
11
|
+
* {@link buildClient} but funnel a missing/empty `SANITY_*` env var
|
|
12
|
+
* through the standard {@link failOnThrow} path, so commands fail with a
|
|
13
|
+
* clean message rather than a raw stack trace. This is the boundary
|
|
14
|
+
* commands call; {@link buildClient} stays throw-based for tests and for
|
|
15
|
+
* callers that supply their own {@link ClientEnv}.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadClient(): SanityClient;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createClient } from '@sanity/client';
|
|
2
|
+
import { ENV } from "./env.js";
|
|
3
|
+
import { failOnThrow } from "./fail.js";
|
|
4
|
+
function readClientEnv() {
|
|
5
|
+
const projectId = process.env[ENV.SANITY_PROJECT_ID];
|
|
6
|
+
const token = process.env[ENV.SANITY_AUTH_TOKEN];
|
|
7
|
+
if (!projectId) {
|
|
8
|
+
throw new Error('SANITY_PROJECT_ID is required (set in root .env)');
|
|
9
|
+
}
|
|
10
|
+
if (!token) {
|
|
11
|
+
throw new Error('SANITY_AUTH_TOKEN is required with editor role (set in root .env)');
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
projectId,
|
|
15
|
+
dataset: process.env[ENV.SANITY_DATASET] ?? 'production',
|
|
16
|
+
token,
|
|
17
|
+
apiHost: process.env[ENV.SANITY_API_HOST] ?? 'https://api.sanity.io',
|
|
18
|
+
apiVersion: '2026-04-29',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function buildClient(env = readClientEnv()) {
|
|
22
|
+
return createClient({
|
|
23
|
+
projectId: env.projectId,
|
|
24
|
+
dataset: env.dataset,
|
|
25
|
+
apiVersion: env.apiVersion,
|
|
26
|
+
token: env.token,
|
|
27
|
+
apiHost: env.apiHost,
|
|
28
|
+
useCdn: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* {@link buildClient} but funnel a missing/empty `SANITY_*` env var
|
|
33
|
+
* through the standard {@link failOnThrow} path, so commands fail with a
|
|
34
|
+
* clean message rather than a raw stack trace. This is the boundary
|
|
35
|
+
* commands call; {@link buildClient} stays throw-based for tests and for
|
|
36
|
+
* callers that supply their own {@link ClientEnv}.
|
|
37
|
+
*/
|
|
38
|
+
export function loadClient() {
|
|
39
|
+
return failOnThrow('Invalid Sanity client config:', () => buildClient());
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WorkflowResource } from '@sanity/workflow-engine';
|
|
2
|
+
export interface WorkflowConfig {
|
|
3
|
+
workflowResource: WorkflowResource;
|
|
4
|
+
tag: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* {@link readWorkflowConfig} but funnel a thrown env-validation error
|
|
8
|
+
* through the standard {@link fail} path so commands don't each have to wrap.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadWorkflowConfig(tagOverride?: string): WorkflowConfig;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the `deploy` command's definitions source — a module specifier
|
|
13
|
+
* (a path like `./src/workflows.ts`, or a package name) whose
|
|
14
|
+
* `allDefinitions` export holds the batch to deploy. Required and with no
|
|
15
|
+
* default: the CLI is agnostic about *which* definitions it ships, so an
|
|
16
|
+
* unset source is an error rather than a silent fall back to a built-in set.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadDefinitionsSource(): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ENV } from "./env.js";
|
|
2
|
+
import { fail, failOnThrow } from "./fail.js";
|
|
3
|
+
const VALID_RESOURCE_TYPES = ['dataset', 'canvas', 'media-library', 'dashboard'];
|
|
4
|
+
function isResourceType(value) {
|
|
5
|
+
return VALID_RESOURCE_TYPES.includes(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the engine's `workflowResource` + tag from env vars (and an
|
|
9
|
+
* optional `tagOverride`, e.g. the `--tag` flag).
|
|
10
|
+
*
|
|
11
|
+
* - `WORKFLOW_RESOURCE_TYPE` — one of "dataset" / "canvas" / "media-library" /
|
|
12
|
+
* "dashboard". Defaults to "dataset".
|
|
13
|
+
* - `WORKFLOW_RESOURCE_ID` — the resource id (e.g. "test.test" for a
|
|
14
|
+
* dataset). Defaults to "test.test".
|
|
15
|
+
* - `WORKFLOW_TAG` — the single environment tag (e.g. "prod"). Required;
|
|
16
|
+
* `tagOverride` takes precedence when supplied.
|
|
17
|
+
*/
|
|
18
|
+
function readWorkflowConfig(tagOverride) {
|
|
19
|
+
const rawType = process.env[ENV.WORKFLOW_RESOURCE_TYPE] ?? 'dataset';
|
|
20
|
+
if (!isResourceType(rawType)) {
|
|
21
|
+
throw new Error(`WORKFLOW_RESOURCE_TYPE must be one of: ${VALID_RESOURCE_TYPES.join(', ')} (got "${rawType}")`);
|
|
22
|
+
}
|
|
23
|
+
const id = process.env[ENV.WORKFLOW_RESOURCE_ID] ?? 'test.test';
|
|
24
|
+
const tag = (tagOverride ?? process.env[ENV.WORKFLOW_TAG] ?? '').trim();
|
|
25
|
+
if (!tag) {
|
|
26
|
+
throw new Error('Workflow tag is required - pass --tag or set WORKFLOW_TAG (e.g. prod)');
|
|
27
|
+
}
|
|
28
|
+
return { workflowResource: { type: rawType, id }, tag };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* {@link readWorkflowConfig} but funnel a thrown env-validation error
|
|
32
|
+
* through the standard {@link fail} path so commands don't each have to wrap.
|
|
33
|
+
*/
|
|
34
|
+
export function loadWorkflowConfig(tagOverride) {
|
|
35
|
+
return failOnThrow('Invalid workflow config:', () => readWorkflowConfig(tagOverride));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the `deploy` command's definitions source — a module specifier
|
|
39
|
+
* (a path like `./src/workflows.ts`, or a package name) whose
|
|
40
|
+
* `allDefinitions` export holds the batch to deploy. Required and with no
|
|
41
|
+
* default: the CLI is agnostic about *which* definitions it ships, so an
|
|
42
|
+
* unset source is an error rather than a silent fall back to a built-in set.
|
|
43
|
+
*/
|
|
44
|
+
export function loadDefinitionsSource() {
|
|
45
|
+
const source = process.env[ENV.WORKFLOW_DEFS]?.trim();
|
|
46
|
+
if (source === undefined || source === '') {
|
|
47
|
+
fail('No definitions source configured.', 'Set WORKFLOW_DEFS to a module that exports `allDefinitions` — a path like ./src/workflows.ts, or a package name.');
|
|
48
|
+
}
|
|
49
|
+
return source;
|
|
50
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type WorkflowDefinition } from '@sanity/workflow-engine';
|
|
2
|
+
/**
|
|
3
|
+
* Turn a configured definitions source into a specifier `import()` can
|
|
4
|
+
* load. Path-like values (relative or absolute) resolve against `cwd` and
|
|
5
|
+
* become file URLs so they're imported as files, not bare package names;
|
|
6
|
+
* anything else passes through as a module specifier (e.g. a workspace
|
|
7
|
+
* package such as `@sanity/workflow-examples`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveDefinitionsSpecifier(source: string, cwd: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Pull the `allDefinitions` array out of an imported definitions module,
|
|
12
|
+
* failing hard with a source-specific message when the module doesn't
|
|
13
|
+
* expose it. Per-definition structure is validated downstream by
|
|
14
|
+
* {@link collectValidationErrors}, so this only asserts the array shape.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractDefinitions(mod: unknown, source: string): WorkflowDefinition[];
|
|
17
|
+
/**
|
|
18
|
+
* Narrow the local batch to one definition `name`, failing with the
|
|
19
|
+
* available names when there's no match. With `only` unset the whole batch passes
|
|
20
|
+
* through (a fresh array, so callers can mutate without aliasing).
|
|
21
|
+
*/
|
|
22
|
+
export declare function selectDefinitions(allDefinitions: WorkflowDefinition[], only: string | undefined): WorkflowDefinition[];
|
|
23
|
+
/**
|
|
24
|
+
* The full local-definitions pipeline shared by `deploy` and
|
|
25
|
+
* `definition diff`: resolve the `WORKFLOW_DEFS` source, import it,
|
|
26
|
+
* narrow to `only` (when given), and fail on any validation error.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadValidatedDefinitions(only: string | undefined): Promise<WorkflowDefinition[]>;
|
|
29
|
+
/** {@link loadValidatedDefinitions} narrowed to exactly one definition name. */
|
|
30
|
+
export declare function loadValidatedDefinition(name: string): Promise<WorkflowDefinition>;
|
|
31
|
+
/**
|
|
32
|
+
* Per-definition structural/GROQ checks plus one batch-level check the
|
|
33
|
+
* engine doesn't do for us: duplicate `name` within the local set.
|
|
34
|
+
* Returns the human-readable error lines; empty means the batch is valid.
|
|
35
|
+
*/
|
|
36
|
+
export declare function collectValidationErrors(defs: WorkflowDefinition[]): string[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { validateDefinition } from '@sanity/workflow-engine';
|
|
12
|
+
import { loadDefinitionsSource } from "./config.js";
|
|
13
|
+
import { fail } from "./fail.js";
|
|
14
|
+
/**
|
|
15
|
+
* Turn a configured definitions source into a specifier `import()` can
|
|
16
|
+
* load. Path-like values (relative or absolute) resolve against `cwd` and
|
|
17
|
+
* become file URLs so they're imported as files, not bare package names;
|
|
18
|
+
* anything else passes through as a module specifier (e.g. a workspace
|
|
19
|
+
* package such as `@sanity/workflow-examples`).
|
|
20
|
+
*/
|
|
21
|
+
export function resolveDefinitionsSpecifier(source, cwd) {
|
|
22
|
+
if (source.startsWith('.') || isAbsolute(source)) {
|
|
23
|
+
return pathToFileURL(resolve(cwd, source)).href;
|
|
24
|
+
}
|
|
25
|
+
return source;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pull the `allDefinitions` array out of an imported definitions module,
|
|
29
|
+
* failing hard with a source-specific message when the module doesn't
|
|
30
|
+
* expose it. Per-definition structure is validated downstream by
|
|
31
|
+
* {@link collectValidationErrors}, so this only asserts the array shape.
|
|
32
|
+
*/
|
|
33
|
+
export function extractDefinitions(mod, source) {
|
|
34
|
+
const exported = mod.allDefinitions;
|
|
35
|
+
if (!Array.isArray(exported)) {
|
|
36
|
+
fail(`Definitions module "${source}" has no \`allDefinitions\` array export.`, 'Export `allDefinitions: WorkflowDefinition[]` from the module named by WORKFLOW_DEFS.');
|
|
37
|
+
}
|
|
38
|
+
return exported;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the configured definitions module and dynamic-import it. The
|
|
42
|
+
* import is dynamic (not a static top-level import) so a `defineWorkflow`
|
|
43
|
+
* throw inside the module — a missing `version`, a bad schema field —
|
|
44
|
+
* surfaces here as a friendly CLI error rather than an oclif command-load
|
|
45
|
+
* crash with a raw stack trace.
|
|
46
|
+
*/
|
|
47
|
+
async function importDefinitions(source) {
|
|
48
|
+
const specifier = resolveDefinitionsSpecifier(source, process.cwd());
|
|
49
|
+
let mod;
|
|
50
|
+
try {
|
|
51
|
+
mod = await import(__rewriteRelativeImportExtension(specifier, true));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
55
|
+
fail(`Failed to load definitions from "${source}":`, message);
|
|
56
|
+
}
|
|
57
|
+
return extractDefinitions(mod, source);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Narrow the local batch to one definition `name`, failing with the
|
|
61
|
+
* available names when there's no match. With `only` unset the whole batch passes
|
|
62
|
+
* through (a fresh array, so callers can mutate without aliasing).
|
|
63
|
+
*/
|
|
64
|
+
export function selectDefinitions(allDefinitions, only) {
|
|
65
|
+
if (!only)
|
|
66
|
+
return [...allDefinitions];
|
|
67
|
+
const filtered = allDefinitions.filter((d) => d.name === only);
|
|
68
|
+
if (filtered.length === 0) {
|
|
69
|
+
const available = allDefinitions.map((d) => d.name).join(', ');
|
|
70
|
+
fail(`No definition named "${only}".`, `Available: ${available}`);
|
|
71
|
+
}
|
|
72
|
+
return filtered;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The full local-definitions pipeline shared by `deploy` and
|
|
76
|
+
* `definition diff`: resolve the `WORKFLOW_DEFS` source, import it,
|
|
77
|
+
* narrow to `only` (when given), and fail on any validation error.
|
|
78
|
+
*/
|
|
79
|
+
export async function loadValidatedDefinitions(only) {
|
|
80
|
+
const allDefinitions = await importDefinitions(loadDefinitionsSource());
|
|
81
|
+
const selected = selectDefinitions(allDefinitions, only);
|
|
82
|
+
const errors = collectValidationErrors(selected);
|
|
83
|
+
if (errors.length > 0) {
|
|
84
|
+
fail('Validation failed:', errors.join('\n'));
|
|
85
|
+
}
|
|
86
|
+
return selected;
|
|
87
|
+
}
|
|
88
|
+
/** {@link loadValidatedDefinitions} narrowed to exactly one definition name. */
|
|
89
|
+
export async function loadValidatedDefinition(name) {
|
|
90
|
+
const [def] = await loadValidatedDefinitions(name);
|
|
91
|
+
// selectDefinitions already failed on no match; this narrows the
|
|
92
|
+
// indexed access for the type checker.
|
|
93
|
+
if (!def)
|
|
94
|
+
fail(`No definition named "${name}".`);
|
|
95
|
+
return def;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Per-definition structural/GROQ checks plus one batch-level check the
|
|
99
|
+
* engine doesn't do for us: duplicate `name` within the local set.
|
|
100
|
+
* Returns the human-readable error lines; empty means the batch is valid.
|
|
101
|
+
*/
|
|
102
|
+
export function collectValidationErrors(defs) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
for (const d of defs) {
|
|
106
|
+
const count = (seen.get(d.name) ?? 0) + 1;
|
|
107
|
+
seen.set(d.name, count);
|
|
108
|
+
if (count === 2) {
|
|
109
|
+
errors.push(` · duplicate name "${d.name}" in local batch`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const d of defs) {
|
|
113
|
+
try {
|
|
114
|
+
validateDefinition(d);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
errors.push(` · ${d.name} v${d.version}: ${message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DiffEntry } from '@sanity/workflow-engine';
|
|
2
|
+
/** One summary line per diff entry, tagged create / update / unchanged. */
|
|
3
|
+
export declare function summaryLines(entries: DiffEntry[]): string[];
|
|
4
|
+
/** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines. */
|
|
5
|
+
export declare function diffLines(oldObj: object, newObj: object): string[];
|
|
6
|
+
/** The full diff report: the summary block plus a per-change diff. */
|
|
7
|
+
export declare function diffReport(entries: DiffEntry[]): string[];
|
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { diffJson } from 'diff';
|
|
2
|
+
import logSymbols from 'log-symbols';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
/** One summary line per diff entry, tagged create / update / unchanged. */
|
|
5
|
+
export function summaryLines(entries) {
|
|
6
|
+
const tags = {
|
|
7
|
+
create: pc.green('+ create '),
|
|
8
|
+
update: pc.yellow('~ update '),
|
|
9
|
+
unchanged: pc.dim(' unchanged'),
|
|
10
|
+
};
|
|
11
|
+
return entries.map((e) => {
|
|
12
|
+
return ` ${tags[e.status]} ${e.name} v${e.version} ${pc.dim(`(${e.docId})`)}`;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Render a JSON diff of `oldObj` → `newObj` as `+`/`-`/context lines. */
|
|
16
|
+
export function diffLines(oldObj, newObj) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const chunk of diffJson(oldObj, newObj)) {
|
|
19
|
+
const chunkLines = chunk.value.split('\n');
|
|
20
|
+
// diffJson appends a trailing empty string when the chunk ends in "\n";
|
|
21
|
+
// drop it so we don't emit a blank line per chunk boundary.
|
|
22
|
+
if (chunkLines.at(-1) === '')
|
|
23
|
+
chunkLines.pop();
|
|
24
|
+
for (const line of chunkLines) {
|
|
25
|
+
if (chunk.added)
|
|
26
|
+
out.push(pc.green(`+ ${line}`));
|
|
27
|
+
else if (chunk.removed)
|
|
28
|
+
out.push(pc.red(`- ${line}`));
|
|
29
|
+
else
|
|
30
|
+
out.push(pc.dim(` ${line}`));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** The full diff report: the summary block plus a per-change diff. */
|
|
36
|
+
export function diffReport(entries) {
|
|
37
|
+
const lines = ['', pc.bold('Summary'), ...summaryLines(entries)];
|
|
38
|
+
const changes = entries.filter((e) => e.status !== 'unchanged');
|
|
39
|
+
if (changes.length === 0) {
|
|
40
|
+
lines.push('', `${logSymbols.success} no changes — running deploy would be a no-op`);
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
for (const e of changes) {
|
|
44
|
+
// For net-new docs, existing is undefined — render the whole expected
|
|
45
|
+
// 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
|
+
}
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const ENV: {
|
|
2
|
+
readonly SANITY_PROJECT_ID: "SANITY_PROJECT_ID";
|
|
3
|
+
readonly SANITY_AUTH_TOKEN: "SANITY_AUTH_TOKEN";
|
|
4
|
+
readonly SANITY_DATASET: "SANITY_DATASET";
|
|
5
|
+
readonly SANITY_API_HOST: "SANITY_API_HOST";
|
|
6
|
+
readonly WORKFLOW_RESOURCE_TYPE: "WORKFLOW_RESOURCE_TYPE";
|
|
7
|
+
readonly WORKFLOW_RESOURCE_ID: "WORKFLOW_RESOURCE_ID";
|
|
8
|
+
readonly WORKFLOW_TAG: "WORKFLOW_TAG";
|
|
9
|
+
readonly WORKFLOW_DEFS: "WORKFLOW_DEFS";
|
|
10
|
+
};
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Typed env var definitions
|
|
2
|
+
export const ENV = {
|
|
3
|
+
// Sanity client
|
|
4
|
+
SANITY_PROJECT_ID: 'SANITY_PROJECT_ID',
|
|
5
|
+
SANITY_AUTH_TOKEN: 'SANITY_AUTH_TOKEN',
|
|
6
|
+
SANITY_DATASET: 'SANITY_DATASET',
|
|
7
|
+
SANITY_API_HOST: 'SANITY_API_HOST',
|
|
8
|
+
// Workflow config
|
|
9
|
+
WORKFLOW_RESOURCE_TYPE: 'WORKFLOW_RESOURCE_TYPE',
|
|
10
|
+
WORKFLOW_RESOURCE_ID: 'WORKFLOW_RESOURCE_ID',
|
|
11
|
+
WORKFLOW_TAG: 'WORKFLOW_TAG',
|
|
12
|
+
WORKFLOW_DEFS: 'WORKFLOW_DEFS',
|
|
13
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a user-facing error and exit non-zero. Writes straight to
|
|
3
|
+
* stderr — no oclif framing, no Node stack — so expected failures
|
|
4
|
+
* (validation, missing input, conflicting flags) read clean.
|
|
5
|
+
*
|
|
6
|
+
* For unexpected failures (network, engine throw) prefer letting the
|
|
7
|
+
* error propagate so the stack is visible for debugging.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fail(headline: string, detail?: string): never;
|
|
10
|
+
/**
|
|
11
|
+
* Run `fn`, funnelling a thrown env/validation error through {@link fail}
|
|
12
|
+
* so an expected, user-fixable failure (missing input, bad value) reads
|
|
13
|
+
* clean instead of as an oclif stack trace. Keeps the throwing `read*`
|
|
14
|
+
* helpers pure and unit-testable while giving callers one clean boundary.
|
|
15
|
+
*/
|
|
16
|
+
export declare function failOnThrow<T>(headline: string, fn: () => T): T;
|
package/dist/lib/fail.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import logSymbols from 'log-symbols';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
/**
|
|
4
|
+
* Render a user-facing error and exit non-zero. Writes straight to
|
|
5
|
+
* stderr — no oclif framing, no Node stack — so expected failures
|
|
6
|
+
* (validation, missing input, conflicting flags) read clean.
|
|
7
|
+
*
|
|
8
|
+
* For unexpected failures (network, engine throw) prefer letting the
|
|
9
|
+
* error propagate so the stack is visible for debugging.
|
|
10
|
+
*/
|
|
11
|
+
export function fail(headline, detail) {
|
|
12
|
+
process.stderr.write(`${pc.red(`${logSymbols.error} ${headline}`)}\n`);
|
|
13
|
+
if (detail !== undefined && detail !== '') {
|
|
14
|
+
for (const line of detail.split('\n')) {
|
|
15
|
+
process.stderr.write(` ${line}\n`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run `fn`, funnelling a thrown env/validation error through {@link fail}
|
|
22
|
+
* so an expected, user-fixable failure (missing input, bad value) reads
|
|
23
|
+
* clean instead of as an oclif stack trace. Keeps the throwing `read*`
|
|
24
|
+
* helpers pure and unit-testable while giving callers one clean boundary.
|
|
25
|
+
*/
|
|
26
|
+
export function failOnThrow(headline, fn) {
|
|
27
|
+
try {
|
|
28
|
+
return fn();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
fail(headline, err instanceof Error ? err.message : String(err));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Selects the engine environment partition (e.g. prod, test).
|
|
2
|
+
* Resolved flag → WORKFLOW_TAG (required) in {@link loadWorkflowConfig}. */
|
|
3
|
+
export declare const tagFlags: {
|
|
4
|
+
tag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
5
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
/** Selects the engine environment partition (e.g. prod, test).
|
|
3
|
+
* Resolved flag → WORKFLOW_TAG (required) in {@link loadWorkflowConfig}. */
|
|
4
|
+
export const tagFlags = {
|
|
5
|
+
tag: Flags.string({
|
|
6
|
+
description: 'Workflow environment tag (e.g. prod, test). Overrides WORKFLOW_TAG.',
|
|
7
|
+
}),
|
|
8
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Actor, OperationArgs } from '@sanity/workflow-engine';
|
|
2
|
+
import type { WorkflowConfig } from './config.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The write-verb arguments every engine operation shares: tag scope,
|
|
5
|
+
* workflow resource, and the CLI's pinned actor ({@link SYSTEM_ACTOR}).
|
|
6
|
+
* Spread by both the instance verbs ({@link buildOperationArgs}) and the
|
|
7
|
+
* definition verbs (e.g. `definition delete`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function baseEngineArgs(config: WorkflowConfig): Pick<OperationArgs, 'tag' | 'workflowResource' | 'access'>;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the actor a write is attributed to. With a named user the fire
|
|
12
|
+
* is pinned to it (plus any roles, so role-gated action filters pass);
|
|
13
|
+
* without one the {@link SYSTEM_ACTOR} fallback applies. Roles without a
|
|
14
|
+
* named user throw — they'd silently attach to nothing otherwise.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveActor(as: string | undefined, roles: string[]): Actor;
|
|
17
|
+
/**
|
|
18
|
+
* {@link baseEngineArgs} plus the instance-operation fields (everything
|
|
19
|
+
* but the client and the verb-specific fields). The `reason` is spread
|
|
20
|
+
* conditionally because the engine's optional field rejects an explicit
|
|
21
|
+
* `undefined` under `exactOptionalPropertyTypes`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildOperationArgs(config: WorkflowConfig, instanceId: string, reason: string | undefined): Omit<OperationArgs, 'client'> & {
|
|
24
|
+
reason?: string;
|
|
25
|
+
};
|