@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,10 +1,7 @@
1
1
  import { styleText } from 'node:util';
2
2
  import logSymbols from 'log-symbols';
3
- /**
4
- * The "ops applied" block shared by write-verb reports (set-stage,
5
- * fire-action): a blank separator, a header, and one line per primitive
6
- * that ran. Empty when nothing ran, so callers append it unconditionally.
7
- */
3
+ import ora from 'ora';
4
+ import { fail, failureDetail } from "./fail.js";
8
5
  export function opsAppliedLines(ranOps) {
9
6
  const details = (ranOps ?? []).map((op) => {
10
7
  const target = op.target ? styleText('dim', ` → ${op.target.scope}.${op.target.field}`) : '';
@@ -12,16 +9,23 @@ export function opsAppliedLines(ranOps) {
12
9
  });
13
10
  return details.length > 0 ? ['', styleText('dim', 'ops applied:'), ...details] : [];
14
11
  }
15
- /**
16
- * Emit a write-verb report through its spinner: warn and bail on a no-op
17
- * (`changed: false`), otherwise succeed and print the ops block. Shared by
18
- * every write command so the changed / no-op branches stay consistent.
19
- */
20
- export function emitWriteReport({ spinner, report, log, }) {
21
- if (!report.changed) {
22
- spinner.warn(report.message);
23
- return;
12
+ export function cascadeTail(cascaded) {
13
+ return cascaded > 0 ? `, then cascaded ${cascaded} auto-transition(s)` : '';
14
+ }
15
+ export async function runWriteVerb({ startLabel, failLabel, failHeadline, run, report, log, }) {
16
+ const spinner = ora(startLabel).start();
17
+ try {
18
+ const result = await run();
19
+ const emitted = report(result);
20
+ if (!emitted.changed) {
21
+ spinner.warn(emitted.message);
22
+ return;
23
+ }
24
+ spinner.succeed(emitted.message);
25
+ emitted.opsLines?.forEach(log);
26
+ }
27
+ catch (error) {
28
+ spinner.fail(failLabel);
29
+ fail(failHeadline, failureDetail(error));
24
30
  }
25
- spinner.succeed(report.message);
26
- report.opsLines.forEach(log);
27
31
  }
@@ -3,8 +3,6 @@ function coerceValue(raw) {
3
3
  return JSON.parse(raw);
4
4
  }
5
5
  catch {
6
- // Not valid JSON — treat it as a plain string so `--param note=hi`
7
- // needs no quoting. The engine validates the typed value either way.
8
6
  return raw;
9
7
  }
10
8
  }
@@ -15,12 +13,6 @@ function parsePair(pair) {
15
13
  }
16
14
  return [pair.slice(0, eq), coerceValue(pair.slice(eq + 1))];
17
15
  }
18
- /**
19
- * Parse repeated `key=value` flag pairs (`--param`, `--field`) into an
20
- * object. Each value is JSON-parsed so numbers/booleans/objects type
21
- * correctly, falling back to the raw string. The engine validates the
22
- * result against the declared types the consuming flag targets.
23
- */
24
16
  export function parseParams(pairs) {
25
17
  return Object.fromEntries(pairs.map(parsePair));
26
18
  }
@@ -0,0 +1,22 @@
1
+ import type { WorkflowResource } from '@sanity/workflow-engine';
2
+ import type { ReadTarget } from './context.ts';
3
+ /** A read target whose query threw — noted in place and reflected in the
4
+ * command's exit code. */
5
+ export interface ReadFailure {
6
+ resource: WorkflowResource;
7
+ message: string;
8
+ }
9
+ /**
10
+ * Run a read over every target, grouping output per resource: a banner when
11
+ * the read spans several, then whatever `run` logs for that target. A throw
12
+ * from one target is noted under its banner and the rest still render — a
13
+ * token that can't read one dataset must not sink the whole listing. A
14
+ * single-target run rethrows instead, keeping the plain unprefixed output
15
+ * and clean error rendering at the command boundary. Callers exit non-zero
16
+ * when failures come back.
17
+ */
18
+ export declare function runReadAcrossTargets({ targets, log, run, }: {
19
+ targets: ReadTarget[];
20
+ log: (line: string) => void;
21
+ run: (target: ReadTarget) => Promise<void>;
22
+ }): Promise<ReadFailure[]>;
@@ -0,0 +1,28 @@
1
+ import logSymbols from 'log-symbols';
2
+ import { failureDetail } from "./fail.js";
3
+ import { groupBanner, resourceLabel } from "./ui.js";
4
+ export async function runReadAcrossTargets({ targets, log, run, }) {
5
+ const failures = [];
6
+ for (const [index, target] of targets.entries()) {
7
+ const banner = groupBanner({
8
+ label: resourceLabel(target.resource),
9
+ index,
10
+ total: targets.length,
11
+ });
12
+ if (banner !== undefined) {
13
+ log(banner);
14
+ }
15
+ try {
16
+ await run(target);
17
+ }
18
+ catch (error) {
19
+ if (targets.length === 1) {
20
+ throw error;
21
+ }
22
+ const message = failureDetail(error);
23
+ failures.push({ resource: target.resource, message });
24
+ log(`${logSymbols.error} ${message}`);
25
+ }
26
+ }
27
+ return failures;
28
+ }
@@ -1,15 +1,5 @@
1
1
  import { resourceAliasesToMap, } from '@sanity/workflow-engine';
2
2
  import { fail } from "./fail.js";
3
- /**
4
- * Pick the deployment for the requested `--tag`. Tags are unique across a
5
- * config (enforced by defineWorkflowConfig), so a tag names at most one
6
- * deployment.
7
- *
8
- * With no tag: fall back to the sole deployment when there's exactly one
9
- * (the common single-environment case), otherwise fail asking for `--tag` —
10
- * a multi-deployment config is ambiguous without it. `orAlternative` extends
11
- * that ambiguity message for callers with another way out (deploy's `--all-tags`).
12
- */
13
3
  export function selectDeployment(config, { tag, orAlternative = '' }) {
14
4
  if (tag === undefined) {
15
5
  if (config.deployments.length > 1) {
@@ -27,12 +17,6 @@ export function selectDeployment(config, { tag, orAlternative = '' }) {
27
17
  }
28
18
  return deployment;
29
19
  }
30
- /**
31
- * The deployments a `deploy` run acts on: every one with `--all-tags`,
32
- * otherwise a single target via {@link selectDeployment} and its failure
33
- * modes. Deploying everything is an explicit opt-in — never a default — so an
34
- * ambiguous bare `deploy` can't fan a write out across environments.
35
- */
36
20
  export function selectDeployments(config, { tag, allTags }) {
37
21
  if (allTags) {
38
22
  return config.deployments;
@@ -44,11 +28,6 @@ export function selectDeployments(config, { tag, allTags }) {
44
28
  function availableTags(config) {
45
29
  return `Available tags: ${config.deployments.map((candidate) => candidate.tag).join(', ')}`;
46
30
  }
47
- /**
48
- * Project a deployment into the engine's {@link DeployTarget} — the shape the
49
- * deploy/diff verbs (`deployDefinitions`, `computeDiffEntries`, `diffEntry`)
50
- * consume — expanding its handle bindings into the alias map in the same step.
51
- */
52
31
  export function deploymentToTarget(deployment) {
53
32
  return {
54
33
  tag: deployment.tag,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Definition sharing — an explicit per-invocation opt-in, independent of
3
+ * telemetry consent. Sharing deployed workflow definitions with Sanity is
4
+ * CONTENT sharing, not telemetry: definitions carry customer-authored
5
+ * strings (names, titles, GROQ filters) that the telemetry payload policy
6
+ * bans, so the documents go to Sanity's first-party definition-feedback
7
+ * endpoint — one POST per deploy invocation — and telemetry carries only
8
+ * a content-free marker per shared definition.
9
+ *
10
+ * Consent model: flag-only, never a prompt, nothing persists. Sharing
11
+ * happens exactly when `--share-definitions-with-sanity` is on the
12
+ * invocation; without it, an interactive deploy that created new
13
+ * definition versions logs a one-line stderr hint instead. The telemetry
14
+ * environment gates (`DO_NOT_TRACK`, account consent) do not apply to
15
+ * the feedback POST: it is explicit first-party consent, not tracking.
16
+ */
17
+ import { type DeployDefinitionResult, type DeployedDefinition, type WorkflowResource } from '@sanity/workflow-engine';
18
+ import { type WorkflowDefinitionSharedData } from './telemetry.ts';
19
+ /** The client slice sharing uses — the read-back of just-deployed
20
+ * documents and the feedback POST, structurally, so a full
21
+ * `SanityClient` satisfies it and tests fake exactly these calls. */
22
+ export interface ShareClient {
23
+ fetch<T>(query: string, params: Record<string, unknown>, options: {
24
+ tag: string;
25
+ }): Promise<T>;
26
+ request<T>(opts: {
27
+ uri: string;
28
+ method: string;
29
+ body: unknown;
30
+ tag: string;
31
+ }): Promise<T>;
32
+ }
33
+ /** One successfully deployed deployment's outcome, paired with the client
34
+ * that can read the just-deployed documents back and the resource the
35
+ * entries' deployment coordinates derive from. */
36
+ export interface ShareCandidate {
37
+ client: ShareClient;
38
+ tag: string;
39
+ resource: WorkflowResource;
40
+ results: DeployDefinitionResult[];
41
+ /** The deploy call's run id — the markers carry the same id the engine's
42
+ * deploy events minted, so donated definitions group with their deploy. */
43
+ deployId: string;
44
+ }
45
+ /** Sanity's first-party definition-feedback endpoint (editorial-ai-backend,
46
+ * routed project-agnostically through the API gateway). */
47
+ export declare const SHARE_ENDPOINT_URI = "/workflow/definition-feedback";
48
+ /**
49
+ * The hint's full copy — a product contract, pinned by test: it must
50
+ * state honestly what sharing sends (the document ships VERBATIM, plus
51
+ * its deployment coordinates) and what never is sent. Logged to stderr
52
+ * after an interactive deploy that created new definition versions
53
+ * without the flag — never a prompt, and never on CI / non-TTY runs.
54
+ */
55
+ export declare const SHARE_HINT: string;
56
+ /**
57
+ * The content-free telemetry marker for one shared definition — projected
58
+ * from the engine's own deploy-event derivation ({@link definitionDeployedData})
59
+ * so `Editorial Workflows Definition Shared` and `Editorial Workflows Definition Deployed` can
60
+ * never disagree about one definition's structure. Status is dropped:
61
+ * only newly created versions are ever shared.
62
+ */
63
+ export declare function definitionShareMarker(args: {
64
+ definition: DeployedDefinition;
65
+ contentHash: string;
66
+ deployId: string;
67
+ }): WorkflowDefinitionSharedData;
68
+ /**
69
+ * The whole post-deploy sharing flow. With the flag: created versions →
70
+ * read-back → ONE feedback POST for the invocation → a content-free
71
+ * telemetry marker per shared definition. Without it: the stderr hint
72
+ * (interactive runs only), nothing sent. No newly created versions
73
+ * (unchanged re-deploys, dry runs, nothing succeeded) means no hint and
74
+ * no POST. Any sharing-path failure warns and returns: the deploy
75
+ * already succeeded, and sharing must never turn it into a failure.
76
+ *
77
+ * The optional args exist for the hint's dependencies, mirroring
78
+ * `setupCliTelemetry` — omitted, the real process surfaces apply.
79
+ */
80
+ export declare function shareDefinitionsAfterDeploy(args: {
81
+ flag: boolean;
82
+ candidates: ShareCandidate[];
83
+ warn: (message: string) => void;
84
+ interactive?: boolean;
85
+ writeStderr?: (message: string) => void;
86
+ }): Promise<void>;
@@ -0,0 +1,106 @@
1
+ import { styleText } from 'node:util';
2
+ import { isInteractive } from '@sanity/cli-core';
3
+ import { definitionDeployedData, errorMessage, } from '@sanity/workflow-engine';
4
+ import { buildDefinitionShowQuery } from "./definitions.js";
5
+ import { cliTelemetry, WorkflowDefinitionShared, } from "./telemetry.js";
6
+ export const SHARE_ENDPOINT_URI = '/workflow/definition-feedback';
7
+ const SHARE_TAG = 'definition.share';
8
+ export const SHARE_HINT = `\n${styleText('bold', 'Enjoying workflows?')} Show us how you use them: deploy with\n` +
9
+ `${styleText('cyan', '--share-definitions-with-sanity')} to share newly created definition documents\n` +
10
+ '(verbatim, with their deployment coordinates) with Sanity — never content\n' +
11
+ 'documents, workflow instances, or your Sanity auth token.\n';
12
+ export function definitionShareMarker(args) {
13
+ const { status: _status, ...structural } = definitionDeployedData(args.definition, {
14
+ contentHash: args.contentHash,
15
+ status: 'created',
16
+ deployId: args.deployId,
17
+ });
18
+ return structural;
19
+ }
20
+ async function loadShareEntry(args) {
21
+ const { candidate, result, warn } = args;
22
+ const deployed = await loadDeployedForShare(args);
23
+ if (deployed === undefined) {
24
+ return undefined;
25
+ }
26
+ if (deployed.contentHash === undefined) {
27
+ warn(`${result.name} v${result.version} carries no content hash — not shared.`);
28
+ return undefined;
29
+ }
30
+ return {
31
+ definition: deployed,
32
+ contentHash: deployed.contentHash,
33
+ name: result.name,
34
+ version: result.version,
35
+ resourceType: candidate.resource.type,
36
+ resourceId: candidate.resource.id,
37
+ };
38
+ }
39
+ async function loadDeployedForShare(args) {
40
+ const { candidate, result, warn } = args;
41
+ const { groq, params } = buildDefinitionShowQuery({
42
+ name: result.name,
43
+ tag: candidate.tag,
44
+ version: result.version,
45
+ });
46
+ try {
47
+ const deployed = await candidate.client.fetch(groq, params, {
48
+ tag: SHARE_TAG,
49
+ });
50
+ if (deployed === null) {
51
+ warn(`Could not read back ${result.name} v${result.version} — not shared.`);
52
+ return undefined;
53
+ }
54
+ return deployed;
55
+ }
56
+ catch (error) {
57
+ warn(`Could not read back ${result.name} v${result.version} — not shared. ${errorMessage(error)}`);
58
+ return undefined;
59
+ }
60
+ }
61
+ export async function shareDefinitionsAfterDeploy(args) {
62
+ const created = args.candidates.flatMap((candidate) => candidate.results
63
+ .filter((result) => result.status === 'created')
64
+ .map((result) => ({ candidate, result })));
65
+ if (created.length === 0) {
66
+ return;
67
+ }
68
+ if (!args.flag) {
69
+ const interactive = args.interactive ?? (isInteractive() && process.stderr.isTTY === true);
70
+ if (interactive) {
71
+ const writeStderr = args.writeStderr ?? ((message) => void process.stderr.write(message));
72
+ writeStderr(SHARE_HINT);
73
+ }
74
+ return;
75
+ }
76
+ const shared = [];
77
+ try {
78
+ for (const item of created) {
79
+ const entry = await loadShareEntry({ ...item, warn: args.warn });
80
+ if (entry !== undefined) {
81
+ shared.push({ entry, candidate: item.candidate });
82
+ }
83
+ }
84
+ if (shared.length === 0) {
85
+ return;
86
+ }
87
+ await shared[0].candidate.client.request({
88
+ uri: SHARE_ENDPOINT_URI,
89
+ method: 'POST',
90
+ body: { definitions: shared.map(({ entry }) => entry) },
91
+ tag: SHARE_TAG,
92
+ });
93
+ }
94
+ catch (error) {
95
+ args.warn(`Definition sharing failed — nothing shared. ${errorMessage(error)}`);
96
+ return;
97
+ }
98
+ try {
99
+ const { logger } = cliTelemetry();
100
+ for (const { entry, candidate } of shared) {
101
+ logger.log(WorkflowDefinitionShared, definitionShareMarker({ ...entry, deployId: candidate.deployId }));
102
+ }
103
+ }
104
+ catch {
105
+ }
106
+ }
package/dist/lib/stub.js CHANGED
@@ -1,15 +1,8 @@
1
1
  import { Command } from '@oclif/core';
2
2
  import boxen from 'boxen';
3
3
  import logSymbols from 'log-symbols';
4
- /**
5
- * Base class for commands that aren't wired to the engine yet. Parses
6
- * the subclass's args + flags, then prints a "not yet implemented"
7
- * banner plus the parsed inputs so the surface is testable end-to-end,
8
- * and exits non-zero — a stub must never report success to a caller.
9
- */
10
4
  export class StubCommand extends Command {
11
5
  async run() {
12
- // `this.parse()` with no args picks up the subclass's static metadata.
13
6
  const parsed = await this.parse(this.ctor);
14
7
  const body = [
15
8
  `${logSymbols.warning} not yet implemented`,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Once-per-invocation assembly of the CLI telemetry shell — the `prerun`
3
+ * hook's whole job. Discovery is deliberately soft: telemetry must never
4
+ * break (or exit) a command, so a missing/broken config, missing token,
5
+ * or dataset-less config just leaves the invocation with telemetry off;
6
+ * the command itself surfaces those problems where they matter.
7
+ */
8
+ import { processShellUserProperties, type TelemetryIntakeClient, type WorkflowResource } from '@sanity/workflow-engine';
9
+ /** The get/set slice of the shared Sanity user config (cli-core's
10
+ * `getUserConfig`) the shell persists flags into — narrow so tests fake
11
+ * exactly what's read and written. */
12
+ export interface UserConfigStore {
13
+ get: (key: string) => unknown;
14
+ set: (key: string, value: unknown) => void;
15
+ }
16
+ /**
17
+ * The one-time transparency notice, mirroring the sanity CLI's
18
+ * `telemetryDisclosure` — both CLIs feed the same pipeline. Written to
19
+ * stderr so it never garbles command output.
20
+ */
21
+ export declare function maybeShowTelemetryDisclosure(args: {
22
+ userConfig: UserConfigStore;
23
+ writeStderr: (message: string) => void;
24
+ }): void;
25
+ /**
26
+ * Assemble and install the invocation's shell: a config-provided
27
+ * `telemetry` logger replaces the built-in shell wholesale (none of its
28
+ * policy applies); otherwise the built-in Sanity-intake store is
29
+ * constructed when an auth token and a dataset-backed deployment exist —
30
+ * mirroring the standard recipe's "not logged in → nothing sent".
31
+ *
32
+ * The optional args exist for the hook's dependencies (cwd, environment,
33
+ * user config, stderr) — omitted, the real process surfaces apply.
34
+ */
35
+ export declare function setupCliTelemetry(args?: {
36
+ cwd?: string;
37
+ env?: Record<string, string | undefined>;
38
+ userConfig?: UserConfigStore;
39
+ writeStderr?: (message: string) => void;
40
+ /** The oclif config's version — the prerun hook supplies it. */
41
+ cliVersion?: string;
42
+ /** Intake client override — tests inject a fake here, like the other
43
+ * hook dependencies above; omitted, the real project client applies. */
44
+ client?: TelemetryIntakeClient;
45
+ }): Promise<void>;
46
+ /**
47
+ * The invocation's environment user properties — the sanity CLI's precedent
48
+ * set (versions, platform, runtime, project coordinates). `orgId` — which
49
+ * Studio's shell sends and the sanity CLI doesn't yet — attaches separately
50
+ * once {@link resolveOrgId} resolves. Joined to the session's events by
51
+ * sessionId downstream; user identity stays sink-side.
52
+ */
53
+ export declare function cliUserProperties(args: {
54
+ project: {
55
+ projectId: string;
56
+ resource: WorkflowResource;
57
+ };
58
+ cliVersion: string | undefined;
59
+ }): ReturnType<typeof processShellUserProperties> & {
60
+ cliVersion?: string;
61
+ projectId: string;
62
+ dataset: string;
63
+ };
64
+ /** The intake project's organization id — degrade-soft: a failed, slow, or
65
+ * org-less lookup resolves undefined, never rejects, never breaks a command. */
66
+ export declare function resolveOrgId(client: TelemetryIntakeClient, projectId: string): Promise<string | undefined>;
@@ -0,0 +1,92 @@
1
+ import { styleText } from 'node:util';
2
+ import { getUserConfig } from '@sanity/cli-core';
3
+ import { datasetResourceParts, processShellUserProperties, } from '@sanity/workflow-engine';
4
+ import boxen from 'boxen';
5
+ import { clientFor, resolveToken } from "./client.js";
6
+ import { loadWorkflowConfigIfPresent } from "./load-config.js";
7
+ import { createBuiltinTelemetry, raceWithDeadline, setCliTelemetry } from "./telemetry.js";
8
+ const DISCLOSURE_KEY = 'workflowCliTelemetryDisclosed';
9
+ const SANITY_CLI_DISCLOSURE_KEY = 'telemetryDisclosed';
10
+ export function maybeShowTelemetryDisclosure(args) {
11
+ const { userConfig, writeStderr } = args;
12
+ if (userConfig.get(DISCLOSURE_KEY) !== undefined ||
13
+ userConfig.get(SANITY_CLI_DISCLOSURE_KEY) !== undefined) {
14
+ return;
15
+ }
16
+ writeStderr(`${boxen(`The Sanity workflow CLI collects telemetry data on general usage and errors.
17
+ This helps us improve workflows and prioritize features.
18
+
19
+ To opt in/out, run ${styleText('cyan', 'npx sanity telemetry enable/disable')}.
20
+ Learn more: https://www.sanity.io/telemetry`, { borderColor: 'yellow', borderStyle: 'round', margin: 1, padding: 1 })}\n`);
21
+ userConfig.set(DISCLOSURE_KEY, Date.now());
22
+ }
23
+ function intakeProject(config) {
24
+ const resource = config.deployments
25
+ .map((deployment) => deployment.workflowResource)
26
+ .find((candidate) => candidate.type === 'dataset');
27
+ if (resource === undefined) {
28
+ return undefined;
29
+ }
30
+ return { projectId: datasetResourceParts(resource.id).projectId, resource };
31
+ }
32
+ export async function setupCliTelemetry(args) {
33
+ try {
34
+ const config = await loadWorkflowConfigIfPresent(args?.cwd);
35
+ if (config === undefined) {
36
+ return;
37
+ }
38
+ if (config.telemetry !== undefined) {
39
+ setCliTelemetry({ kind: 'provided', logger: config.telemetry });
40
+ return;
41
+ }
42
+ const token = await resolveToken().catch(() => undefined);
43
+ const project = token === undefined ? undefined : intakeProject(config);
44
+ if (token === undefined || project === undefined) {
45
+ return;
46
+ }
47
+ const client = args?.client ?? clientFor(project.resource, token);
48
+ const telemetry = createBuiltinTelemetry({
49
+ client,
50
+ projectId: project.projectId,
51
+ env: args?.env ?? process.env,
52
+ });
53
+ setCliTelemetry(telemetry);
54
+ if (!telemetry.envDenied) {
55
+ telemetry.store.logger.updateUserProperties(cliUserProperties({ project, cliVersion: args?.cliVersion }));
56
+ void resolveOrgId(client, project.projectId).then((orgId) => {
57
+ if (orgId !== undefined)
58
+ telemetry.store.logger.updateUserProperties({ orgId });
59
+ });
60
+ maybeShowTelemetryDisclosure({
61
+ userConfig: args?.userConfig ?? getUserConfig(),
62
+ writeStderr: args?.writeStderr ?? ((message) => void process.stderr.write(message)),
63
+ });
64
+ }
65
+ }
66
+ catch {
67
+ }
68
+ }
69
+ const ORG_LOOKUP_DEADLINE_MS = 2_000;
70
+ const CONTEXT_TAG = 'telemetry.context';
71
+ export function cliUserProperties(args) {
72
+ const { project, cliVersion } = args;
73
+ return {
74
+ ...processShellUserProperties('cli'),
75
+ ...(cliVersion !== undefined ? { cliVersion } : {}),
76
+ projectId: project.projectId,
77
+ dataset: datasetResourceParts(project.resource.id).dataset,
78
+ };
79
+ }
80
+ export async function resolveOrgId(client, projectId) {
81
+ try {
82
+ const project = await raceWithDeadline(client.request({
83
+ uri: `/projects/${projectId}`,
84
+ tag: CONTEXT_TAG,
85
+ timeout: ORG_LOOKUP_DEADLINE_MS,
86
+ }), ORG_LOOKUP_DEADLINE_MS);
87
+ return project?.organizationId ?? undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The CLI's telemetry shell. A standalone oclif binary is its own app
3
+ * shell — no host to ride — so this module owns the store, the standard
4
+ * Sanity-intake consent/transport recipe (shared via
5
+ * `@sanity/workflow-engine`'s `createTelemetryIntake`), and the one-time
6
+ * disclosure. The `prerun` hook assembles it once per invocation and the
7
+ * `finally` hook completes the command trace and flushes.
8
+ *
9
+ * Consent recipe: CI / `DO_NOT_TRACK` → denied; not logged in → no store
10
+ * (undetermined, nothing sent); otherwise the account-level status the
11
+ * intake recipe fetches. Consent is managed account-wide by
12
+ * `sanity telemetry enable|disable|status` — the CLI inherits it and
13
+ * ships no consent UX of its own.
14
+ *
15
+ * Provided-implementation caveat: a `telemetry` logger on the workflow
16
+ * config replaces this shell wholesale — the built-in store is never
17
+ * constructed and none of its policy (consent, environment suppression,
18
+ * transport) applies; every event flows to the provided logger
19
+ * unconditionally. Gates live in the store, not at emission sites.
20
+ *
21
+ * Payload policy: no customer strings, ever. The command trace carries
22
+ * the command id, the NAMES of declared flags used, and a success flag —
23
+ * never flag values, arguments, or error text. `Editorial Workflows
24
+ * Definition Shared` is a content-free marker (hash + structural counts); the shared
25
+ * document itself rides the first-party feedback endpoint, never
26
+ * telemetry (see `share-definitions.ts`).
27
+ */
28
+ import { type DefinedTelemetryTrace, type TelemetryStore } from '@sanity/telemetry';
29
+ import { type TelemetryIntakeClient, type WorkflowDefinitionDeployedData, type WorkflowTelemetryLogger } from '@sanity/workflow-engine';
30
+ export interface WorkflowCliCommandData {
31
+ /** The oclif command id — ours, never user free-text. */
32
+ command: string;
33
+ /** Names of DECLARED flags present on the invocation — never values,
34
+ * never undeclared tokens. */
35
+ flags: string[];
36
+ success: boolean;
37
+ }
38
+ export declare const WorkflowCliCommandExecuted: DefinedTelemetryTrace<WorkflowCliCommandData, void>;
39
+ /** {@link WorkflowDefinitionDeployedData} minus the deploy status — the marker
40
+ * is projected from the engine's own structural derivation so the two events
41
+ * can never disagree about one definition, and only newly created versions
42
+ * are ever shared, so status carries nothing. */
43
+ export type WorkflowDefinitionSharedData = Omit<WorkflowDefinitionDeployedData, 'status'>;
44
+ export declare const WorkflowDefinitionShared: import("@sanity/telemetry").DefinedTelemetryLog<WorkflowDefinitionSharedData>;
45
+ /**
46
+ * The shell for one CLI invocation. `builtin` is the Sanity-intake store;
47
+ * `provided` is a config-supplied logger that replaces the shell and its
48
+ * policy wholesale; `off` (the default) emits nothing — no config-derived
49
+ * project, no auth token, or telemetry setup failed.
50
+ */
51
+ export type CliTelemetry = {
52
+ kind: 'builtin';
53
+ logger: WorkflowTelemetryLogger;
54
+ store: TelemetryStore<unknown>;
55
+ envDenied: boolean;
56
+ } | {
57
+ kind: 'provided';
58
+ logger: WorkflowTelemetryLogger;
59
+ } | {
60
+ kind: 'off';
61
+ logger: WorkflowTelemetryLogger;
62
+ };
63
+ export declare function cliTelemetry(): CliTelemetry;
64
+ /** Install the invocation's shell and, for the built-in store, start the
65
+ * command trace (completed by {@link finishCliTelemetry}). */
66
+ export declare function setCliTelemetry(telemetry: CliTelemetry): void;
67
+ export declare function clearCliTelemetry(): void;
68
+ /** Build the built-in Sanity-intake shell over an authenticated,
69
+ * project-bound client. */
70
+ export declare function createBuiltinTelemetry(args: {
71
+ client: TelemetryIntakeClient;
72
+ projectId: string;
73
+ env: Record<string, string | undefined>;
74
+ }): Extract<CliTelemetry, {
75
+ kind: 'builtin';
76
+ }>;
77
+ /**
78
+ * Race `work` against a deadline, resolving `undefined` when the deadline
79
+ * wins. The timer is always cleared once the race settles — oclif never
80
+ * force-exits on success, so a pending timer would hold a finished process
81
+ * open. The losing promise's eventual settlement is absorbed by the race.
82
+ */
83
+ export declare function raceWithDeadline<T>(work: Promise<T>, deadlineMs: number): Promise<T | undefined>;
84
+ /**
85
+ * Complete the command trace and flush the final batch — the `finally`
86
+ * hook's whole job. On the built-in store the trace completes with the
87
+ * payload and the store flushes (deadline-capped); a provided logger gets
88
+ * the same payload through its `log` seam and owns its own delivery.
89
+ */
90
+ export declare function finishCliTelemetry(data: WorkflowCliCommandData): Promise<void>;
91
+ /**
92
+ * The names of declared flags present in `argv` — long form (`--name`,
93
+ * `--name=value`), negated booleans (`--no-name`), and short chars
94
+ * (`-c`). Matching against the command's declared flags is what keeps
95
+ * values and free-text out of the payload; tokens after the `--`
96
+ * terminator are never inspected.
97
+ */
98
+ export declare function usedFlagNames(argv: string[], flagDefs: Record<string, {
99
+ char?: string;
100
+ }> | undefined): string[];