@sanity/workflow-cli 0.9.0 → 0.10.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/README.md +62 -18
  3. package/bin/run.js +0 -4
  4. package/dist/commands/abort.d.ts +4 -6
  5. package/dist/commands/abort.js +13 -24
  6. package/dist/commands/definition/delete.d.ts +2 -2
  7. package/dist/commands/definition/delete.js +14 -27
  8. package/dist/commands/definition/diff.d.ts +2 -2
  9. package/dist/commands/definition/diff.js +3 -12
  10. package/dist/commands/definition/list.d.ts +3 -4
  11. package/dist/commands/definition/list.js +40 -46
  12. package/dist/commands/definition/show.d.ts +22 -3
  13. package/dist/commands/definition/show.js +23 -21
  14. package/dist/commands/deploy.d.ts +11 -5
  15. package/dist/commands/deploy.js +54 -57
  16. package/dist/commands/diagnose.d.ts +22 -4
  17. package/dist/commands/diagnose.js +39 -45
  18. package/dist/commands/fire-action.d.ts +2 -4
  19. package/dist/commands/fire-action.js +27 -61
  20. package/dist/commands/list.d.ts +7 -8
  21. package/dist/commands/list.js +43 -56
  22. package/dist/commands/reset-activity.js +0 -1
  23. package/dist/commands/set-stage.d.ts +2 -2
  24. package/dist/commands/set-stage.js +14 -30
  25. package/dist/commands/show.d.ts +2 -2
  26. package/dist/commands/show.js +12 -24
  27. package/dist/commands/start.d.ts +4 -7
  28. package/dist/commands/start.js +18 -54
  29. package/dist/commands/tail.d.ts +2 -2
  30. package/dist/commands/tail.js +20 -25
  31. package/dist/hooks/finally/telemetry.d.ts +8 -0
  32. package/dist/hooks/finally/telemetry.js +13 -0
  33. package/dist/hooks/prerun/telemetry.d.ts +5 -0
  34. package/dist/hooks/prerun/telemetry.js +5 -0
  35. package/dist/index.js +0 -3
  36. package/dist/lib/base-command.d.ts +14 -0
  37. package/dist/lib/base-command.js +10 -0
  38. package/dist/lib/client.js +13 -30
  39. package/dist/lib/context.d.ts +49 -13
  40. package/dist/lib/context.js +49 -48
  41. package/dist/lib/definitions.js +5 -24
  42. package/dist/lib/diff.d.ts +5 -2
  43. package/dist/lib/diff.js +61 -27
  44. package/dist/lib/env.d.ts +4 -0
  45. package/dist/lib/env.js +4 -3
  46. package/dist/lib/fail.d.ts +12 -6
  47. package/dist/lib/fail.js +24 -25
  48. package/dist/lib/flags.d.ts +0 -7
  49. package/dist/lib/flags.js +0 -17
  50. package/dist/lib/load-config.d.ts +11 -7
  51. package/dist/lib/load-config.js +40 -20
  52. package/dist/lib/operation-args.d.ts +14 -23
  53. package/dist/lib/operation-args.js +6 -38
  54. package/dist/lib/ops-report.d.ts +18 -10
  55. package/dist/lib/ops-report.js +20 -16
  56. package/dist/lib/params.js +0 -8
  57. package/dist/lib/read-fanout.d.ts +22 -0
  58. package/dist/lib/read-fanout.js +28 -0
  59. package/dist/lib/select-deployment.js +0 -21
  60. package/dist/lib/share-definitions.d.ts +86 -0
  61. package/dist/lib/share-definitions.js +106 -0
  62. package/dist/lib/stub.js +0 -7
  63. package/dist/lib/telemetry-setup.d.ts +66 -0
  64. package/dist/lib/telemetry-setup.js +92 -0
  65. package/dist/lib/telemetry.d.ts +100 -0
  66. package/dist/lib/telemetry.js +89 -0
  67. package/dist/lib/ui.d.ts +33 -1
  68. package/dist/lib/ui.js +32 -21
  69. package/oclif.manifest.json +8 -47
  70. package/package.json +14 -8
@@ -1,20 +1,20 @@
1
1
  import { styleText } from 'node:util';
2
- import { Args, Command, Flags } from '@oclif/core';
2
+ import { Args, Flags } from '@oclif/core';
3
3
  import { isStartableDefinition, workflow, } from '@sanity/workflow-engine';
4
- import ora from 'ora';
4
+ import { WorkflowCommand } from "../lib/base-command.js";
5
5
  import { resolveContext } from "../lib/context.js";
6
6
  import { fetchDeployedDefinition } from "../lib/definitions.js";
7
- import { errorMessage, fail, failOnThrow } from "../lib/fail.js";
8
- import { actorFlags, jsonFlags, tagFlags } from "../lib/flags.js";
9
- import { baseEngineArgs, resolveActor } from "../lib/operation-args.js";
7
+ import { fail, failOnThrow, failureDetail } from "../lib/fail.js";
8
+ import { jsonFlags, tagFlags } from "../lib/flags.js";
9
+ import { baseEngineArgs } from "../lib/operation-args.js";
10
+ import { runWriteVerb } from "../lib/ops-report.js";
10
11
  import { parseParams } from "../lib/params.js";
11
- export default class Start extends Command {
12
+ export default class Start extends WorkflowCommand {
12
13
  static description = 'Start a workflow instance from a deployed definition. Supply values for the ' +
13
14
  "workflow's input-sourced fields with --field (e.g. the subject document ref).";
14
15
  static examples = [
15
16
  '<%= config.bin %> start productLaunch',
16
17
  '<%= config.bin %> start article-review --field subject=\'{"id":"dataset:proj:ds:article-1","type":"article"}\'',
17
- '<%= config.bin %> start article-review --as user.xyz --as-role editor',
18
18
  '<%= config.bin %> start productLaunch --version 2 --tag prod',
19
19
  ];
20
20
  static args = {
@@ -32,13 +32,11 @@ export default class Start extends Command {
32
32
  multiple: true,
33
33
  default: [],
34
34
  }),
35
- ...actorFlags,
36
35
  ...jsonFlags,
37
36
  };
38
37
  async run() {
39
38
  const { args, flags } = await this.parse(Start);
40
39
  const { deployment, client } = await resolveContext(flags);
41
- const actor = failOnThrow('start usage error:', () => resolveActor(flags.as, flags['as-role']));
42
40
  const values = failOnThrow('Invalid --field:', () => parseParams(flags.field));
43
41
  const initialFields = await resolveStartInputs({
44
42
  client,
@@ -54,33 +52,25 @@ export default class Start extends Command {
54
52
  name: args.name,
55
53
  version: flags.version,
56
54
  initialFields,
57
- actor,
58
55
  }),
59
56
  };
60
57
  if (flags.json) {
61
58
  const result = await workflow
62
59
  .startInstance(startArgs)
63
- .catch((error) => fail('start error:', errorMessage(error)));
60
+ .catch((error) => fail('start error:', failureDetail(error)));
64
61
  this.log(JSON.stringify(startResultJson(result.instance), null, 2));
65
62
  return;
66
63
  }
67
- const spinner = ora(`Starting ${args.name}…`).start();
68
- try {
69
- const result = await workflow.startInstance(startArgs);
70
- spinner.succeed(startReport(result.instance));
71
- }
72
- catch (error) {
73
- spinner.fail('Start rejected');
74
- fail('start error:', errorMessage(error));
75
- }
64
+ await runWriteVerb({
65
+ startLabel: `Starting ${args.name}…`,
66
+ failLabel: 'Start rejected',
67
+ failHeadline: 'start error:',
68
+ run: () => workflow.startInstance(startArgs),
69
+ report: (result) => ({ changed: true, message: startReport(result.instance) }),
70
+ log: (line) => this.log(line),
71
+ });
76
72
  }
77
73
  }
78
- /**
79
- * Everything `start` must resolve before the engine call: fetch the deployed
80
- * definition (failing when nothing is deployed under the name), refuse
81
- * non-startable ones, and type the operator's `--field` values against the
82
- * declared entries.
83
- */
84
74
  async function resolveStartInputs({ client, name, tag, version, values, }) {
85
75
  const deployed = await fetchDeployedDefinition({ client, name, tag, version });
86
76
  if (deployed === undefined) {
@@ -92,26 +82,12 @@ async function resolveStartInputs({ client, name, tag, version, values, }) {
92
82
  }
93
83
  return failOnThrow('start usage error:', () => buildInitialFields({ declared: deployed.fields ?? [], values }));
94
84
  }
95
- /**
96
- * Why a definition can't be started standalone, or `undefined` when it can.
97
- * Advisory, mirroring the Startable column in `definition list` — the engine
98
- * itself does not refuse ({@link isStartableDefinition}), but the CLI has no
99
- * way to supply the parent context a spawn-only child expects.
100
- */
101
85
  export function startRefusal(definition) {
102
86
  if (isStartableDefinition(definition)) {
103
87
  return undefined;
104
88
  }
105
89
  return "definition is spawn-only (lifecycle: 'child') — instances of it are spawned by a parent workflow's activity, not started standalone";
106
90
  }
107
- /**
108
- * Type each `--field name=value` against the workflow's declared field
109
- * entries — the engine takes typed {@link InitialFieldValue}s, and a value's
110
- * type IS its declared entry's kind. Only `input`-sourced entries read
111
- * caller values (the engine silently ignores the rest), so anything else
112
- * fails here, naming the fields that ARE settable; value validation stays
113
- * in the engine.
114
- */
115
91
  export function buildInitialFields({ declared, values, }) {
116
92
  const settable = declared.filter((f) => f.initialValue?.type === 'input');
117
93
  const settableNames = settable.map((f) => f.name).join(', ');
@@ -128,26 +104,17 @@ export function buildInitialFields({ declared, values, }) {
128
104
  ? `Input fields: ${settableNames}`
129
105
  : 'This workflow has no input fields.'));
130
106
  }
131
- // Operator-supplied JSON; the engine validates the value against the
132
- // declared kind at start, so this cast is the CLI/engine boundary.
133
107
  return { type: entry.type, name, value };
134
108
  });
135
109
  }
136
- /**
137
- * The shared engine args ({@link baseEngineArgs}) plus the start-specific
138
- * fields. Optionals spread conditionally because the engine's optional args
139
- * reject an explicit `undefined` under `exactOptionalPropertyTypes`.
140
- */
141
- export function buildStartArgs({ scope, name, version, initialFields, actor, }) {
110
+ export function buildStartArgs({ scope, name, version, initialFields, }) {
142
111
  return {
143
- ...baseEngineArgs(scope, actor),
112
+ ...baseEngineArgs(scope),
144
113
  definition: name,
145
114
  ...(version !== undefined ? { version } : {}),
146
115
  ...(initialFields.length > 0 ? { initialFields } : {}),
147
116
  };
148
117
  }
149
- /** The success spinner message. Pure so the in-flight / immediately-terminal
150
- * permutations can be asserted without driving a real start. */
151
118
  export function startReport(instance) {
152
119
  const id = styleText('bold', instance._id);
153
120
  const stage = styleText('bold', instance.currentStage);
@@ -155,9 +122,6 @@ export function startReport(instance) {
155
122
  ? `Started ${id} — completed immediately at ${stage}`
156
123
  : `Started ${id} — now at ${stage}`;
157
124
  }
158
- /** The `--json` payload. Keys follow the CLI's `--json` vocabulary (matching
159
- * `fire-action`): `instanceId` carries the document `_id`, `version` the
160
- * pinned definition version; the rest mirror the instance fields. */
161
125
  export function startResultJson(instance) {
162
126
  return {
163
127
  instanceId: instance._id,
@@ -1,5 +1,5 @@
1
- import { Command } from '@oclif/core';
2
- export default class Tail extends Command {
1
+ import { WorkflowCommand } from '../lib/base-command.ts';
2
+ export default class Tail extends WorkflowCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static args: {
@@ -1,11 +1,14 @@
1
1
  import { styleText } from 'node:util';
2
- import { Args, Command } from '@oclif/core';
2
+ import { Args } from '@oclif/core';
3
+ import { displayTitle, errorMessage } from '@sanity/workflow-engine';
3
4
  import logSymbols from 'log-symbols';
4
- import { resolveReadContext } from "../lib/context.js";
5
- import { fail } from "../lib/fail.js";
5
+ import { WorkflowCommand } from "../lib/base-command.js";
6
+ import { findInstance, resolveReadTargets } from "../lib/context.js";
7
+ import { fail, failureDetail } from "../lib/fail.js";
6
8
  import { tagFlags } from "../lib/flags.js";
7
9
  import { formatTimestamp } from "../lib/ui.js";
8
- export default class Tail extends Command {
10
+ const TAIL_TAG = 'tail';
11
+ export default class Tail extends WorkflowCommand {
9
12
  static description = 'Stream new history entries on a workflow instance as they land in the dataset.';
10
13
  static examples = ['<%= config.bin %> tail wf-instance.abc123'];
11
14
  static args = {
@@ -19,34 +22,33 @@ export default class Tail extends Command {
19
22
  };
20
23
  async run() {
21
24
  const { args, flags } = await this.parse(Tail);
22
- const { client } = await resolveReadContext(flags);
23
- const initial = await client.getDocument(args.instanceId);
24
- if (!initial) {
25
+ const targets = await resolveReadTargets(flags);
26
+ const hit = await findInstance({ targets, instanceId: args.instanceId, requestTag: TAIL_TAG });
27
+ if (!hit) {
25
28
  fail(`No instance with id "${args.instanceId}"`);
26
29
  }
30
+ const { client, instance: initial } = hit;
27
31
  this.log(styleText('dim', `tailing ${styleText('bold', args.instanceId)} (${initial.definition} v${initial.pinnedVersion}, ${initial.history.length} prior entries, currently in ${initial.currentStage})`));
28
32
  this.log(styleText('dim', 'Ctrl+C to stop'));
29
33
  this.log('');
30
- // Track the high-water mark so we only print history entries that
31
- // landed after we started tailing. Listening with `includeResult:
32
- // false` (matching what the studio does) keeps the wire payload
33
- // small — we just refetch on each notification.
34
34
  let seen = initial.history.length;
35
35
  const subscription = client
36
- .listen(`*[_id == $id]`, { id: args.instanceId }, { includeResult: false, visibility: 'query' })
36
+ .listen(`*[_id == $id]`, { id: args.instanceId }, {
37
+ includeResult: false,
38
+ visibility: 'query',
39
+ tag: TAIL_TAG,
40
+ })
37
41
  .subscribe({
38
42
  next: async () => {
39
43
  try {
40
44
  seen = await this.printNewEntries({ client, instanceId: args.instanceId, seen });
41
45
  }
42
46
  catch (err) {
43
- const message = err instanceof Error ? err.message : String(err);
44
- process.stderr.write(`${styleText('red', `${logSymbols.error} fetch failed:`)} ${message}\n`);
47
+ process.stderr.write(`${styleText('red', `${logSymbols.error} fetch failed:`)} ${errorMessage(err)}\n`);
45
48
  }
46
49
  },
47
50
  error: (err) => {
48
- const message = err instanceof Error ? err.message : String(err);
49
- fail('Listen subscription error:', message);
51
+ fail('Listen subscription error:', failureDetail(err));
50
52
  },
51
53
  });
52
54
  const shutdown = () => {
@@ -56,22 +58,15 @@ export default class Tail extends Command {
56
58
  };
57
59
  process.on('SIGINT', shutdown);
58
60
  process.on('SIGTERM', shutdown);
59
- // Keep the event loop alive — `subscribe` doesn't itself anchor the
60
- // process. The shutdown handlers above are the only exit path.
61
61
  await new Promise(() => { });
62
62
  }
63
- /**
64
- * Fetch the current instance state, print history entries past the
65
- * given high-water mark, return the new mark. Pulled out so the
66
- * async work in the subscription's `next` handler stays tidy.
67
- */
68
63
  async printNewEntries({ client, instanceId, seen, }) {
69
- const next = await client.getDocument(instanceId);
64
+ const next = await client.getDocument(instanceId, { tag: TAIL_TAG });
70
65
  if (!next)
71
66
  return seen;
72
67
  const newEntries = next.history.slice(seen);
73
68
  for (const entry of newEntries) {
74
- this.log(`${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${styleText('cyan', entry._type)}`);
69
+ this.log(`${styleText('dim', `[${formatTimestamp(entry.at)}]`)} ${styleText('cyan', displayTitle(entry._type))}`);
75
70
  }
76
71
  return next.history.length;
77
72
  }
@@ -0,0 +1,8 @@
1
+ import type { Hook } from '@oclif/core';
2
+ /**
3
+ * Completes the command trace — command id, declared-flag names, success —
4
+ * and flushes the final batch. Runs on success and on error alike (oclif's
5
+ * `finally`); an invocation the prerun hook left with telemetry off no-ops.
6
+ */
7
+ declare const hook: Hook<'finally'>;
8
+ export default hook;
@@ -0,0 +1,13 @@
1
+ import { finishCliTelemetry, usedFlagNames } from "../../lib/telemetry.js";
2
+ const hook = async function (options) {
3
+ try {
4
+ await finishCliTelemetry({
5
+ command: options.id,
6
+ flags: usedFlagNames(options.argv, options.Command?.flags),
7
+ success: options.error === undefined,
8
+ });
9
+ }
10
+ catch {
11
+ }
12
+ };
13
+ export default hook;
@@ -0,0 +1,5 @@
1
+ import type { Hook } from '@oclif/core';
2
+ /** Builds and installs the invocation's telemetry shell before the command
3
+ * runs; the `finally` hook completes the trace and flushes. Never throws. */
4
+ declare const hook: Hook<'prerun'>;
5
+ export default hook;
@@ -0,0 +1,5 @@
1
+ import { setupCliTelemetry } from "../../lib/telemetry-setup.js";
2
+ const hook = async function ({ config }) {
3
+ await setupCliTelemetry({ cliVersion: config.version });
4
+ };
5
+ export default hook;
package/dist/index.js CHANGED
@@ -1,4 +1 @@
1
- // Library entry for the package's `.` export. The CLI itself runs through the
2
- // `bin/run.js` (published) and `bin/dev.js` (dev) entries; this module exists
3
- // only to satisfy the repo's source-first exports map.
4
1
  export { execute } from '@oclif/core';
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * Base for the API-backed workflow commands: the backstop that renders an
4
+ * auth rejection (HTTP 401) escaping a command's `run` through the clean
5
+ * {@link fail} path — with {@link failureDetail}'s recovery hint — instead
6
+ * of an oclif error dump. Errors caught inside commands render the same
7
+ * detail at their own `fail` sites; every other error propagates to oclif
8
+ * unchanged.
9
+ */
10
+ export declare abstract class WorkflowCommand extends Command {
11
+ protected catch(err: Error & {
12
+ exitCode?: number;
13
+ }): Promise<unknown>;
14
+ }
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ import { fail, failureDetail, isAuthRejection } from "./fail.js";
3
+ export class WorkflowCommand extends Command {
4
+ async catch(err) {
5
+ if (isAuthRejection(err)) {
6
+ fail('Authentication failed:', failureDetail(err));
7
+ }
8
+ return super.catch(err);
9
+ }
10
+ }
@@ -1,28 +1,16 @@
1
1
  import { getCliToken, isStaging } from '@sanity/cli-core';
2
2
  import { createClient } from '@sanity/client';
3
- import { datasetResourceParts, ENGINE_API_VERSION, } from '@sanity/workflow-engine';
4
- import { ENV } from "./env.js";
3
+ import { clientConfigFromResource, ENGINE_API_VERSION, errorMessage, } from '@sanity/workflow-engine';
4
+ import { ENV, envAuthToken } from "./env.js";
5
5
  import { fail } from "./fail.js";
6
6
  const API_VERSION = ENGINE_API_VERSION;
7
7
  const STAGING_API_HOST = 'https://api.sanity.work';
8
- /**
9
- * The API host to hit: an explicit `SANITY_API_HOST` wins; otherwise mirror
10
- * the Sanity CLI — staging routes to the staging API, prod falls through to
11
- * `@sanity/client`'s default. Pairs with {@link resolveToken}, which reads the
12
- * matching session token.
13
- */
14
8
  function resolveApiHost() {
15
9
  return process.env[ENV.SANITY_API_HOST] ?? (isStaging() ? STAGING_API_HOST : undefined);
16
10
  }
17
- /**
18
- * Resolve the Sanity auth token. An explicit `SANITY_AUTH_TOKEN` wins (CI /
19
- * scripted use); otherwise fall back to the `sanity login` session token.
20
- * Throws when neither exists rather than building an unauthenticated client
21
- * that only 401s once it hits the API.
22
- */
23
11
  export async function resolveToken() {
24
- const fromEnv = process.env[ENV.SANITY_AUTH_TOKEN]?.trim();
25
- if (fromEnv) {
12
+ const fromEnv = envAuthToken();
13
+ if (fromEnv !== undefined) {
26
14
  return fromEnv;
27
15
  }
28
16
  const fromSession = await getCliToken();
@@ -31,22 +19,17 @@ export async function resolveToken() {
31
19
  }
32
20
  throw new Error('No Sanity token found — run `sanity login`, or set SANITY_AUTH_TOKEN.');
33
21
  }
34
- /** {@link resolveToken}, funnelling the no-token case through the clean
35
- * {@link fail} path rather than letting it surface as an oclif stack. */
36
22
  export async function resolveTokenOrFail() {
37
- return resolveToken().catch((err) => fail('Authentication required:', err instanceof Error ? err.message : String(err)));
23
+ return resolveToken().catch((err) => fail('Authentication required:', errorMessage(err)));
38
24
  }
39
- /** Build a client for a workflow resource, authenticated with `token`. */
40
25
  export function clientFor(resource, token) {
41
26
  const apiHost = resolveApiHost();
42
- const baseParams = { token, apiVersion: API_VERSION, useCdn: false, ...(apiHost ? { apiHost } : {}) };
43
- // A dataset goes through the classic {projectId, dataset} config — the proven
44
- // path, and it keeps `tail`'s client.listen() on well-trodden ground. Other
45
- // resource types have no projectId/dataset split, so they pass through as a
46
- // resource directly.
47
- if (resource.type === 'dataset') {
48
- const { projectId, dataset } = datasetResourceParts(resource.id);
49
- return createClient({ ...baseParams, projectId, dataset });
50
- }
51
- return createClient({ ...baseParams, resource });
27
+ const baseParams = {
28
+ token,
29
+ apiVersion: API_VERSION,
30
+ useCdn: false,
31
+ requestTagPrefix: 'sanity.workflows.cli',
32
+ ...(apiHost ? { apiHost } : {}),
33
+ };
34
+ return createClient({ ...baseParams, ...clientConfigFromResource(resource) });
52
35
  }
@@ -20,24 +20,34 @@ export interface InstanceContext {
20
20
  export declare function resolveContext(flags: {
21
21
  tag?: string | undefined;
22
22
  }): Promise<DeploymentContext>;
23
+ /** One resource a read inspects, with its authenticated client. */
24
+ export interface ReadTarget {
25
+ resource: WorkflowResource;
26
+ client: SanityClient;
27
+ }
23
28
  /**
24
- * The resolution a READ shares: an authenticated client for the resource to
25
- * inspect — and nothing more. A read lists/inspects whatever is in the Content
26
- * Lake, so it must NOT inherit a deployment's tag or definition set as a
27
- * filter. The config only locates the resource here; the command applies the
28
- * user's explicit `--tag` (if any) as a query filter itself.
29
+ * The resolution every READ shares: an authenticated client per resource to
30
+ * inspect — and nothing more. A read lists/inspects whatever is in the
31
+ * Content Lake, so it must NOT inherit a deployment's tag or definition set
32
+ * as a filter. The config only locates the resources here; the command
33
+ * applies the user's explicit `--tag` (if any) as a query filter itself.
29
34
  *
30
- * Resource selection: when every deployment targets the same resource (the
31
- * common case) it's used with no `--tag` needed; only a config that spans
32
- * *different* resources needs `--tag` to say which dataset to read.
35
+ * Untagged, this is every distinct resource the config mentions — a read is
36
+ * harmless to fan out, so "show me what's deployed" spans the whole config.
37
+ * `--tag` narrows to that deployment's resource. The token resolves once for
38
+ * the run, however many targets it spans.
33
39
  */
34
- export declare function resolveReadContext(flags: {
40
+ export declare function resolveReadTargets(flags: {
35
41
  tag?: string | undefined;
36
- }): Promise<{
37
- client: SanityClient;
38
- }>;
42
+ }): Promise<ReadTarget[]>;
43
+ /** The distinct resources a read targets: the tagged deployment's sole
44
+ * resource, or — untagged — every distinct one the config's deployments
45
+ * mention (deduped by `type:id`). */
46
+ export declare function resolveReadResources(config: WorkflowConfig, tag: string | undefined): WorkflowResource[];
39
47
  /** The single resource a read should target, or fail asking which when the
40
- * config spans more than one. */
48
+ * config spans more than one. For the paths that need one definite dataset
49
+ * (the instance-targeted verbs); listings fan out via
50
+ * {@link resolveReadResources} instead. */
41
51
  export declare function resolveReadResource(config: WorkflowConfig, tag: string | undefined): WorkflowResource;
42
52
  /**
43
53
  * The resolution an instance-id-targeted command shares, read or write: an
@@ -60,3 +70,29 @@ export declare function resolveInstanceContext(flags: {
60
70
  * {@link resolveInstanceContext} so the not-found path is unit-testable
61
71
  * without a live config/token/client. */
62
72
  export declare function loadInstanceOrFail(client: Pick<SanityClient, 'getDocument'>, instanceId: string): Promise<WorkflowInstance>;
73
+ /** A read target that turned out to hold the instance being looked up. */
74
+ export interface InstanceHit extends ReadTarget {
75
+ instance: WorkflowInstance;
76
+ }
77
+ /**
78
+ * The sole cross-resource hit, or fail listing every holding resource —
79
+ * ambiguity across datasets must error, never pick. `subject` names the
80
+ * thing being looked up in the diagnostic (`Instance <id>`, `Definition "x"`).
81
+ */
82
+ export declare function soleHitOrFail<T extends {
83
+ resource: WorkflowResource;
84
+ }>(hits: T[], subject: string): T | undefined;
85
+ /**
86
+ * Look an instance id up across every read target. Exactly one hit wins —
87
+ * instance ids are random and collision-free, so a single match IS the
88
+ * instance. Several hits (id reuse across datasets) fail listing the
89
+ * resources rather than silently picking one. `undefined` means confidently
90
+ * not found: when a probe throws and no other target has the id, that error
91
+ * propagates instead — an unreadable dataset must not masquerade as "no such
92
+ * instance".
93
+ */
94
+ export declare function findInstance({ targets, instanceId, requestTag, }: {
95
+ targets: ReadTarget[];
96
+ instanceId: string;
97
+ requestTag: string;
98
+ }): Promise<InstanceHit | undefined>;
@@ -2,66 +2,41 @@ import { clientFor, resolveTokenOrFail } from "./client.js";
2
2
  import { fail } from "./fail.js";
3
3
  import { loadWorkflowConfig } from "./load-config.js";
4
4
  import { selectDeployment } from "./select-deployment.js";
5
- /**
6
- * The resolution a WRITE shares: discover the config, pick the deployment for
7
- * `--tag` (or the sole one), and build an authenticated client for that
8
- * deployment's resource. A write acts on one specific deployment, so a
9
- * single, definite target is exactly what it needs.
10
- */
5
+ import { resourceLabel } from "./ui.js";
11
6
  export async function resolveContext(flags) {
12
7
  const config = await loadWorkflowConfig();
13
8
  const deployment = selectDeployment(config, { tag: flags.tag });
14
9
  return { deployment, client: clientFor(deployment.workflowResource, await resolveTokenOrFail()) };
15
10
  }
16
- /**
17
- * The resolution a READ shares: an authenticated client for the resource to
18
- * inspect — and nothing more. A read lists/inspects whatever is in the Content
19
- * Lake, so it must NOT inherit a deployment's tag or definition set as a
20
- * filter. The config only locates the resource here; the command applies the
21
- * user's explicit `--tag` (if any) as a query filter itself.
22
- *
23
- * Resource selection: when every deployment targets the same resource (the
24
- * common case) it's used with no `--tag` needed; only a config that spans
25
- * *different* resources needs `--tag` to say which dataset to read.
26
- */
27
- export async function resolveReadContext(flags) {
11
+ export async function resolveReadTargets(flags) {
28
12
  const config = await loadWorkflowConfig();
29
- return { client: clientFor(resolveReadResource(config, flags.tag), await resolveTokenOrFail()) };
13
+ const resources = resolveReadResources(config, flags.tag);
14
+ const token = await resolveTokenOrFail();
15
+ return resources.map((resource) => ({ resource, client: clientFor(resource, token) }));
30
16
  }
31
- /** The single resource a read should target, or fail asking which when the
32
- * config spans more than one. */
33
- export function resolveReadResource(config, tag) {
17
+ export function resolveReadResources(config, tag) {
18
+ if (tag !== undefined) {
19
+ return [selectDeployment(config, { tag }).workflowResource];
20
+ }
34
21
  const byKey = new Map(config.deployments.map((d) => [
35
22
  `${d.workflowResource.type}:${d.workflowResource.id}`,
36
23
  d.workflowResource,
37
24
  ]));
38
25
  const resources = [...byKey.values()];
39
- const [sole] = resources;
40
- if (sole === undefined) {
26
+ if (resources.length === 0) {
41
27
  fail('No deployments configured.');
42
28
  }
43
- if (resources.length === 1) {
29
+ return resources;
30
+ }
31
+ export function resolveReadResource(config, tag) {
32
+ const resources = resolveReadResources(config, tag);
33
+ const [sole] = resources;
34
+ if (resources.length === 1 && sole !== undefined) {
44
35
  return sole;
45
36
  }
46
- if (tag === undefined) {
47
- const tags = config.deployments.map((d) => d.tag).join(', ');
48
- fail('Config spans multiple resources — pass --tag to choose one.', `Available tags: ${tags}`);
49
- }
50
- return selectDeployment(config, { tag }).workflowResource;
37
+ const tags = config.deployments.map((d) => d.tag).join(', ');
38
+ fail('Config spans multiple resources pass --tag to choose one.', `Available tags: ${tags}`);
51
39
  }
52
- /**
53
- * The resolution an instance-id-targeted command shares, read or write: an
54
- * authenticated client for the resource, plus the instance itself — so the
55
- * command derives its partition (`tag`) from the instance, never from the
56
- * config's declared deployments. An instance id is globally unique and carries
57
- * its own `tag`, so a command that names one acts on that instance regardless
58
- * of which tags the config happens to deploy; the config only locates the
59
- * resource (via {@link resolveReadResource}, no tag filter).
60
- *
61
- * Contrast {@link resolveContext}: the deploy/diff/delete path IS scoped to a
62
- * declared deployment because it acts on the authored definition set (or a
63
- * definition name, which — unlike an instance id — isn't unique across tags).
64
- */
65
40
  export async function resolveInstanceContext(flags, instanceId) {
66
41
  const config = await loadWorkflowConfig();
67
42
  const workflowResource = resolveReadResource(config, flags.tag);
@@ -69,14 +44,40 @@ export async function resolveInstanceContext(flags, instanceId) {
69
44
  const instance = await loadInstanceOrFail(client, instanceId);
70
45
  return { client, scope: { tag: instance.tag, workflowResource } };
71
46
  }
72
- /** Fetch an instance by id, exiting cleanly when the resource has no such
73
- * document — the diagnostic an operator sees on a mistyped id. Split out from
74
- * {@link resolveInstanceContext} so the not-found path is unit-testable
75
- * without a live config/token/client. */
76
47
  export async function loadInstanceOrFail(client, instanceId) {
77
- const instance = await client.getDocument(instanceId);
48
+ const instance = await client.getDocument(instanceId, {
49
+ tag: 'instance.load',
50
+ });
78
51
  if (!instance) {
79
52
  fail(`Workflow instance ${instanceId} not found`);
80
53
  }
81
54
  return instance;
82
55
  }
56
+ export function soleHitOrFail(hits, subject) {
57
+ const [sole, ...rest] = hits;
58
+ if (rest.length > 0) {
59
+ fail(`${subject} exists in several resources — pass --tag to choose one.`, hits.map((hit) => resourceLabel(hit.resource)).join('\n'));
60
+ }
61
+ return sole;
62
+ }
63
+ export async function findInstance({ targets, instanceId, requestTag, }) {
64
+ const probes = await Promise.allSettled(targets.map(async (target) => {
65
+ const instance = await target.client.getDocument(instanceId, {
66
+ tag: requestTag,
67
+ });
68
+ return instance === undefined ? undefined : { ...target, instance };
69
+ }));
70
+ const hits = probes
71
+ .filter((probe) => probe.status === 'fulfilled')
72
+ .map((probe) => probe.value)
73
+ .filter((hit) => hit !== undefined);
74
+ const sole = soleHitOrFail(hits, `Instance ${instanceId}`);
75
+ if (sole !== undefined) {
76
+ return sole;
77
+ }
78
+ const failedProbe = probes.find((probe) => probe.status === 'rejected');
79
+ if (failedProbe !== undefined) {
80
+ throw failedProbe.reason;
81
+ }
82
+ return undefined;
83
+ }