@sanity/workflow-cli 0.9.0 → 0.11.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 (71) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/README.md +71 -19
  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 +41 -47
  12. package/dist/commands/definition/show.d.ts +35 -3
  13. package/dist/commands/definition/show.js +44 -24
  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 +60 -56
  18. package/dist/commands/fire-action.d.ts +2 -4
  19. package/dist/commands/fire-action.js +39 -62
  20. package/dist/commands/list.d.ts +18 -10
  21. package/dist/commands/list.js +77 -69
  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 -28
  28. package/dist/commands/start.js +19 -80
  29. package/dist/commands/tail.d.ts +2 -2
  30. package/dist/commands/tail.js +34 -36
  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 +8 -0
  34. package/dist/hooks/prerun/telemetry.js +9 -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.d.ts +10 -0
  42. package/dist/lib/definitions.js +11 -24
  43. package/dist/lib/diff.d.ts +5 -2
  44. package/dist/lib/diff.js +61 -27
  45. package/dist/lib/env.d.ts +4 -0
  46. package/dist/lib/env.js +4 -3
  47. package/dist/lib/fail.d.ts +19 -6
  48. package/dist/lib/fail.js +26 -26
  49. package/dist/lib/flags.d.ts +0 -7
  50. package/dist/lib/flags.js +0 -17
  51. package/dist/lib/load-config.d.ts +11 -7
  52. package/dist/lib/load-config.js +40 -20
  53. package/dist/lib/operation-args.d.ts +14 -23
  54. package/dist/lib/operation-args.js +6 -38
  55. package/dist/lib/ops-report.d.ts +18 -10
  56. package/dist/lib/ops-report.js +20 -16
  57. package/dist/lib/params.js +0 -8
  58. package/dist/lib/read-fanout.d.ts +22 -0
  59. package/dist/lib/read-fanout.js +28 -0
  60. package/dist/lib/select-deployment.js +0 -21
  61. package/dist/lib/share-definitions.d.ts +120 -0
  62. package/dist/lib/share-definitions.js +187 -0
  63. package/dist/lib/stub.js +0 -7
  64. package/dist/lib/telemetry-setup.d.ts +70 -0
  65. package/dist/lib/telemetry-setup.js +99 -0
  66. package/dist/lib/telemetry.d.ts +131 -0
  67. package/dist/lib/telemetry.js +94 -0
  68. package/dist/lib/ui.d.ts +33 -1
  69. package/dist/lib/ui.js +32 -22
  70. package/oclif.manifest.json +21 -52
  71. package/package.json +14 -8
@@ -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,120 @@
1
+ /**
2
+ * Definition sharing — donating deployed workflow definitions to Sanity is
3
+ * CONTENT sharing, not telemetry: definitions carry customer-authored
4
+ * strings (names, titles, GROQ filters) that the telemetry payload policy
5
+ * bans, so the documents go to Sanity's first-party definition-feedback
6
+ * endpoint — one POST per deploy invocation — and telemetry carries only a
7
+ * content-free marker per shared definition plus a per-invocation decision
8
+ * event.
9
+ *
10
+ * Consent model: opt-OUT. A deploy that creates new definition versions
11
+ * donates by default. The choice is skipped by an explicit flag:
12
+ * `--share-defs` (donate, no prompt) or `--no-share-defs` (don't). Without a
13
+ * flag, an interactive terminal takes a ONE-TIME acknowledgement (persisted
14
+ * in the shared Sanity user config, so it is asked once ever) before the
15
+ * first donation — notice must precede the send, since we cannot disclose
16
+ * taking content after taking it. Unattended runs (CI / `DO_NOT_TRACK` / a
17
+ * non-TTY pipe) can't prompt, so they donate nothing — the standard Sanity-CLI
18
+ * "silent in CI / `DO_NOT_TRACK`" rule; opt in per run with `--share-defs`.
19
+ */
20
+ import { type DeployDefinitionResult, type DeployedDefinition, type WorkflowResource } from '@sanity/workflow-engine';
21
+ import type { UserConfigStore } from './telemetry-setup.ts';
22
+ import { type WorkflowDefinitionSharedData } from './telemetry.ts';
23
+ /** The client slice sharing uses — the read-back of just-deployed
24
+ * documents and the feedback POST, structurally, so a full
25
+ * `SanityClient` satisfies it and tests fake exactly these calls. */
26
+ export interface ShareClient {
27
+ fetch<T>(query: string, params: Record<string, unknown>, options: {
28
+ tag: string;
29
+ }): Promise<T>;
30
+ request<T>(opts: {
31
+ uri: string;
32
+ method: string;
33
+ body: unknown;
34
+ tag: string;
35
+ }): Promise<T>;
36
+ }
37
+ /** One successfully deployed deployment's outcome, paired with the client
38
+ * that can read the just-deployed documents back and the resource the
39
+ * entries' deployment coordinates derive from. */
40
+ export interface ShareCandidate {
41
+ client: ShareClient;
42
+ tag: string;
43
+ resource: WorkflowResource;
44
+ results: DeployDefinitionResult[];
45
+ /** The deploy call's run id — the markers carry the same id the engine's
46
+ * deploy events minted, so donated definitions group with their deploy. */
47
+ deployId: string;
48
+ }
49
+ /** Sanity's first-party definition-feedback endpoint (editorial-ai-backend,
50
+ * routed project-agnostically through the API gateway). */
51
+ export declare const SHARE_ENDPOINT_URI = "/workflow/definition-feedback";
52
+ /**
53
+ * The first-run consent prompt — a product contract, pinned by test. It must
54
+ * name the recipient (Sanity), state that the document ships VERBATIM with its
55
+ * deployment coordinates and is kept, name what is never shared, and disclose
56
+ * that the answer is remembered machine-wide (not just for this deploy) with
57
+ * the per-run override. Shown once (persisted) before the first donation on an
58
+ * interactive terminal; the answer decides and is remembered.
59
+ */
60
+ export declare const SHARE_CONSENT_PROMPT: string;
61
+ /**
62
+ * The one-line reminders printed on every flagless interactive deploy after the
63
+ * first — product contracts, pinned by test. They keep an ongoing donation (or
64
+ * a remembered decline) visible every run, never silent, and name the per-run
65
+ * override. `_ON` shows when the remembered answer shares, `_OFF` when it does not.
66
+ */
67
+ export declare const SHARE_REMINDER_ON: string;
68
+ export declare const SHARE_REMINDER_OFF: string;
69
+ /**
70
+ * The content-free telemetry marker for one shared definition — projected
71
+ * from the engine's own deploy-event derivation ({@link definitionDeployedData})
72
+ * so `Editorial Workflows Definition Shared` and `Editorial Workflows Definition Deployed` can
73
+ * never disagree about one definition's structure. Status is dropped:
74
+ * only newly created versions are ever shared.
75
+ */
76
+ export declare function definitionShareMarker(args: {
77
+ definition: DeployedDefinition;
78
+ contentHash: string;
79
+ deployId: string;
80
+ }): WorkflowDefinitionSharedData;
81
+ /**
82
+ * Whether the invocation's telemetry should send despite the environment
83
+ * denial (CI / `DO_NOT_TRACK`). Only an explicit `--share-defs` that actually
84
+ * donates forces it — a command-line arg beats env vars, and a `--share-defs`
85
+ * donation must never be recorded-less. Account-level consent is never
86
+ * overridden (the intake still reads `/intake/telemetry-status`).
87
+ *
88
+ * Computed from raw argv because the prerun hook builds the store before oclif
89
+ * parses flags, so it must land on the same value oclif will: repeated
90
+ * `allowNo` booleans are **last-token-wins**, and tokens after `--` aren't
91
+ * flags. `--dry-run` / `--check` never create versions, so they never donate —
92
+ * no telemetry to force. The `share-defs` flag has no char/alias/env source,
93
+ * so `--share-defs` / `--no-share-defs` are its only tokens (add one and this
94
+ * match must learn it too).
95
+ */
96
+ export declare function shouldForceShareTelemetry(args: {
97
+ commandId: string | undefined;
98
+ argv: string[];
99
+ }): boolean;
100
+ /**
101
+ * The whole post-deploy sharing flow. Resolves the opt-out decision (explicit
102
+ * flag, one-time acknowledgement, or standing policy), donates when it lands
103
+ * on share, and always records the content-free per-invocation decision so
104
+ * adoption dashboards can measure opt-out rate. No newly created versions
105
+ * (unchanged re-deploys, dry runs, nothing succeeded) means no decision, no
106
+ * prompt, and no POST.
107
+ *
108
+ * The optional args exist for the flow's dependencies, mirroring
109
+ * `setupCliTelemetry` — omitted, the real process surfaces apply.
110
+ */
111
+ export declare function shareDefinitionsAfterDeploy(args: {
112
+ flag: boolean | undefined;
113
+ candidates: ShareCandidate[];
114
+ warn: (message: string) => void;
115
+ interactive?: boolean;
116
+ envDenied?: boolean;
117
+ writeStderr?: (message: string) => void;
118
+ userConfig?: UserConfigStore;
119
+ confirmShare?: () => Promise<boolean>;
120
+ }): Promise<void>;
@@ -0,0 +1,187 @@
1
+ import { styleText } from 'node:util';
2
+ import { getUserConfig, isInteractive } from '@sanity/cli-core';
3
+ import { confirm } from '@sanity/cli-core/ux';
4
+ import { definitionDeployedData, errorMessage, isTelemetryEnvDenied, } from '@sanity/workflow-engine';
5
+ import { buildDefinitionShowQuery } from "./definitions.js";
6
+ import { cliTelemetry, WorkflowDefinitionShared, WorkflowDefinitionSharingDecided, } from "./telemetry.js";
7
+ export const SHARE_ENDPOINT_URI = '/workflow/definition-feedback';
8
+ const SHARE_TAG = 'definition.share';
9
+ const SHARE_DECISION_KEY = 'workflowCliDefinitionSharing';
10
+ export const SHARE_CONSENT_PROMPT = `${styleText('bold', 'Share the workflow definitions this deploy creates with Sanity?')}\n` +
11
+ 'They are sent verbatim (structure, names, filters, effect configuration,\n' +
12
+ 'seeded values) with their deployment coordinates, and kept to improve\n' +
13
+ 'Editorial Workflows — never your content documents, workflow instances, or\n' +
14
+ 'your Sanity auth token. Your answer is remembered for future deploys on\n' +
15
+ `this machine; override any run with ${styleText('cyan', '--share-defs')} / ${styleText('cyan', '--no-share-defs')}.`;
16
+ export const SHARE_REMINDER_ON = `${styleText('bold', 'Sharing new workflow definitions with Sanity')} to improve Editorial Workflows` +
17
+ ` — opt out with ${styleText('cyan', '--no-share-defs')}.\n`;
18
+ export const SHARE_REMINDER_OFF = `Not sharing workflow definitions with Sanity — share with ${styleText('cyan', '--share-defs')}.\n`;
19
+ export function definitionShareMarker(args) {
20
+ const { status: _status, ...structural } = definitionDeployedData(args.definition, {
21
+ contentHash: args.contentHash,
22
+ status: 'created',
23
+ deployId: args.deployId,
24
+ });
25
+ return structural;
26
+ }
27
+ export function shouldForceShareTelemetry(args) {
28
+ const { commandId, argv } = args;
29
+ if (commandId !== 'deploy') {
30
+ return false;
31
+ }
32
+ const terminator = argv.indexOf('--');
33
+ const tokens = terminator === -1 ? argv : argv.slice(0, terminator);
34
+ if (tokens.includes('--dry-run') || tokens.includes('--check')) {
35
+ return false;
36
+ }
37
+ return tokens.lastIndexOf('--share-defs') > tokens.lastIndexOf('--no-share-defs');
38
+ }
39
+ async function loadShareEntry(args) {
40
+ const { candidate, result, warn } = args;
41
+ const deployed = await loadDeployedForShare(args);
42
+ if (deployed === undefined) {
43
+ return undefined;
44
+ }
45
+ if (deployed.contentHash === undefined) {
46
+ warn(`${result.name} v${result.version} carries no content hash — not shared.`);
47
+ return undefined;
48
+ }
49
+ return {
50
+ definition: deployed,
51
+ contentHash: deployed.contentHash,
52
+ name: result.name,
53
+ version: result.version,
54
+ resourceType: candidate.resource.type,
55
+ resourceId: candidate.resource.id,
56
+ };
57
+ }
58
+ async function loadDeployedForShare(args) {
59
+ const { candidate, result, warn } = args;
60
+ const { groq, params } = buildDefinitionShowQuery({
61
+ name: result.name,
62
+ tag: candidate.tag,
63
+ version: result.version,
64
+ });
65
+ try {
66
+ const deployed = await candidate.client.fetch(groq, params, {
67
+ tag: SHARE_TAG,
68
+ });
69
+ if (deployed === null) {
70
+ warn(`Could not read back ${result.name} v${result.version} — not shared.`);
71
+ return undefined;
72
+ }
73
+ return deployed;
74
+ }
75
+ catch (error) {
76
+ warn(`Could not read back ${result.name} v${result.version} — not shared. ${errorMessage(error)}`);
77
+ return undefined;
78
+ }
79
+ }
80
+ function collectCreated(candidates) {
81
+ return candidates.flatMap((candidate) => candidate.results
82
+ .filter((result) => result.status === 'created')
83
+ .map((result) => ({ candidate, result })));
84
+ }
85
+ function readShareDecision(userConfig) {
86
+ const value = userConfig.get(SHARE_DECISION_KEY);
87
+ return typeof value === 'boolean' ? value : undefined;
88
+ }
89
+ async function promptFirstRun(args) {
90
+ let share;
91
+ try {
92
+ share = await args.confirmShare();
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ try {
98
+ args.userConfig.set(SHARE_DECISION_KEY, share);
99
+ }
100
+ catch {
101
+ }
102
+ return share;
103
+ }
104
+ async function resolveShareIntent(args) {
105
+ if (args.flag === true) {
106
+ return { share: true, decision: 'opt-in' };
107
+ }
108
+ if (args.flag === false) {
109
+ return { share: false, decision: 'opt-out' };
110
+ }
111
+ const unattended = args.envDenied || !args.interactive;
112
+ if (unattended) {
113
+ return { share: false, decision: 'unattended' };
114
+ }
115
+ const remembered = readShareDecision(args.userConfig);
116
+ if (remembered !== undefined) {
117
+ args.writeStderr(remembered ? SHARE_REMINDER_ON : SHARE_REMINDER_OFF);
118
+ return { share: remembered, decision: remembered ? 'remembered-share' : 'remembered-decline' };
119
+ }
120
+ const accepted = await promptFirstRun(args);
121
+ return { share: accepted, decision: accepted ? 'prompt-accepted' : 'prompt-declined' };
122
+ }
123
+ async function donate(args) {
124
+ const shared = [];
125
+ try {
126
+ for (const item of args.created) {
127
+ const entry = await loadShareEntry({ ...item, warn: args.warn });
128
+ if (entry !== undefined) {
129
+ shared.push({ entry, candidate: item.candidate });
130
+ }
131
+ }
132
+ if (shared.length === 0) {
133
+ return false;
134
+ }
135
+ await shared[0].candidate.client.request({
136
+ uri: SHARE_ENDPOINT_URI,
137
+ method: 'POST',
138
+ body: { definitions: shared.map(({ entry }) => entry) },
139
+ tag: SHARE_TAG,
140
+ });
141
+ }
142
+ catch (error) {
143
+ args.warn(`Definition sharing failed — nothing shared. ${errorMessage(error)}`);
144
+ return false;
145
+ }
146
+ emitShareMarkers(shared);
147
+ return true;
148
+ }
149
+ function emitShareMarkers(shared) {
150
+ try {
151
+ const { logger } = cliTelemetry();
152
+ for (const { entry, candidate } of shared) {
153
+ logger.log(WorkflowDefinitionShared, definitionShareMarker({ ...entry, deployId: candidate.deployId }));
154
+ }
155
+ }
156
+ catch {
157
+ }
158
+ }
159
+ function emitSharingDecided(data) {
160
+ try {
161
+ cliTelemetry().logger.log(WorkflowDefinitionSharingDecided, data);
162
+ }
163
+ catch {
164
+ }
165
+ }
166
+ export async function shareDefinitionsAfterDeploy(args) {
167
+ const created = collectCreated(args.candidates);
168
+ if (created.length === 0) {
169
+ return;
170
+ }
171
+ try {
172
+ const { share, decision } = await resolveShareIntent({
173
+ flag: args.flag,
174
+ interactive: args.interactive ?? (isInteractive() && process.stderr.isTTY === true),
175
+ envDenied: args.envDenied ?? isTelemetryEnvDenied(process.env),
176
+ userConfig: args.userConfig ?? getUserConfig(),
177
+ writeStderr: args.writeStderr ?? ((message) => void process.stderr.write(message)),
178
+ confirmShare: args.confirmShare ??
179
+ (() => confirm({ message: SHARE_CONSENT_PROMPT, default: true }, { output: process.stderr })),
180
+ });
181
+ const shared = share ? await donate({ created, warn: args.warn }) : false;
182
+ emitSharingDecided({ decision, shared, definitionCount: created.length });
183
+ }
184
+ catch (error) {
185
+ args.warn(`Definition sharing skipped — ${errorMessage(error)}`);
186
+ }
187
+ }
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,70 @@
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
+ /** Override the environment denial (CI / `DO_NOT_TRACK`) — the prerun hook
43
+ * sets this for a deploy that explicitly opts into definition sharing with
44
+ * `--share-defs`. */
45
+ forceSend?: boolean;
46
+ /** Intake client override — tests inject a fake here, like the other
47
+ * hook dependencies above; omitted, the real project client applies. */
48
+ client?: TelemetryIntakeClient;
49
+ }): Promise<void>;
50
+ /**
51
+ * The invocation's environment user properties — the sanity CLI's precedent
52
+ * set (versions, platform, runtime, project coordinates). `orgId` — which
53
+ * Studio's shell sends and the sanity CLI doesn't yet — attaches separately
54
+ * once {@link resolveOrgId} resolves. Joined to the session's events by
55
+ * sessionId downstream; user identity stays sink-side.
56
+ */
57
+ export declare function cliUserProperties(args: {
58
+ project: {
59
+ projectId: string;
60
+ resource: WorkflowResource;
61
+ };
62
+ cliVersion: string | undefined;
63
+ }): ReturnType<typeof processShellUserProperties> & {
64
+ cliVersion?: string;
65
+ projectId: string;
66
+ dataset: string;
67
+ };
68
+ /** The intake project's organization id — degrade-soft: a failed, slow, or
69
+ * org-less lookup resolves undefined, never rejects, never breaks a command. */
70
+ export declare function resolveOrgId(client: TelemetryIntakeClient, projectId: string): Promise<string | undefined>;
@@ -0,0 +1,99 @@
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
+ forceSend: args?.forceSend ?? false,
53
+ });
54
+ setCliTelemetry(telemetry);
55
+ attachBuiltinContext({ telemetry, project, client, deps: args });
56
+ }
57
+ catch {
58
+ }
59
+ }
60
+ function attachBuiltinContext(args) {
61
+ const { telemetry, project, client, deps } = args;
62
+ if (!telemetry.envDenied || telemetry.forceSend) {
63
+ telemetry.store.logger.updateUserProperties(cliUserProperties({ project, cliVersion: deps?.cliVersion }));
64
+ void resolveOrgId(client, project.projectId).then((orgId) => {
65
+ if (orgId !== undefined)
66
+ telemetry.store.logger.updateUserProperties({ orgId });
67
+ });
68
+ }
69
+ if (!telemetry.envDenied) {
70
+ maybeShowTelemetryDisclosure({
71
+ userConfig: deps?.userConfig ?? getUserConfig(),
72
+ writeStderr: deps?.writeStderr ?? ((message) => void process.stderr.write(message)),
73
+ });
74
+ }
75
+ }
76
+ const ORG_LOOKUP_DEADLINE_MS = 2_000;
77
+ const CONTEXT_TAG = 'telemetry.context';
78
+ export function cliUserProperties(args) {
79
+ const { project, cliVersion } = args;
80
+ return {
81
+ ...processShellUserProperties('cli'),
82
+ ...(cliVersion !== undefined ? { cliVersion } : {}),
83
+ projectId: project.projectId,
84
+ dataset: datasetResourceParts(project.resource.id).dataset,
85
+ };
86
+ }
87
+ export async function resolveOrgId(client, projectId) {
88
+ try {
89
+ const project = await raceWithDeadline(client.request({
90
+ uri: `/projects/${projectId}`,
91
+ tag: CONTEXT_TAG,
92
+ timeout: ORG_LOOKUP_DEADLINE_MS,
93
+ }), ORG_LOOKUP_DEADLINE_MS);
94
+ return project?.organizationId ?? undefined;
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }