@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.
- package/CHANGELOG.md +485 -0
- package/README.md +62 -18
- package/bin/run.js +0 -4
- package/dist/commands/abort.d.ts +4 -6
- package/dist/commands/abort.js +13 -24
- package/dist/commands/definition/delete.d.ts +2 -2
- package/dist/commands/definition/delete.js +14 -27
- package/dist/commands/definition/diff.d.ts +2 -2
- package/dist/commands/definition/diff.js +3 -12
- package/dist/commands/definition/list.d.ts +3 -4
- package/dist/commands/definition/list.js +40 -46
- package/dist/commands/definition/show.d.ts +22 -3
- package/dist/commands/definition/show.js +23 -21
- package/dist/commands/deploy.d.ts +11 -5
- package/dist/commands/deploy.js +54 -57
- package/dist/commands/diagnose.d.ts +22 -4
- package/dist/commands/diagnose.js +39 -45
- package/dist/commands/fire-action.d.ts +2 -4
- package/dist/commands/fire-action.js +27 -61
- package/dist/commands/list.d.ts +7 -8
- package/dist/commands/list.js +43 -56
- package/dist/commands/reset-activity.js +0 -1
- package/dist/commands/set-stage.d.ts +2 -2
- package/dist/commands/set-stage.js +14 -30
- package/dist/commands/show.d.ts +2 -2
- package/dist/commands/show.js +12 -24
- package/dist/commands/start.d.ts +4 -7
- package/dist/commands/start.js +18 -54
- package/dist/commands/tail.d.ts +2 -2
- package/dist/commands/tail.js +20 -25
- package/dist/hooks/finally/telemetry.d.ts +8 -0
- package/dist/hooks/finally/telemetry.js +13 -0
- package/dist/hooks/prerun/telemetry.d.ts +5 -0
- package/dist/hooks/prerun/telemetry.js +5 -0
- package/dist/index.js +0 -3
- package/dist/lib/base-command.d.ts +14 -0
- package/dist/lib/base-command.js +10 -0
- package/dist/lib/client.js +13 -30
- package/dist/lib/context.d.ts +49 -13
- package/dist/lib/context.js +49 -48
- package/dist/lib/definitions.js +5 -24
- package/dist/lib/diff.d.ts +5 -2
- package/dist/lib/diff.js +61 -27
- package/dist/lib/env.d.ts +4 -0
- package/dist/lib/env.js +4 -3
- package/dist/lib/fail.d.ts +12 -6
- package/dist/lib/fail.js +24 -25
- package/dist/lib/flags.d.ts +0 -7
- package/dist/lib/flags.js +0 -17
- package/dist/lib/load-config.d.ts +11 -7
- package/dist/lib/load-config.js +40 -20
- package/dist/lib/operation-args.d.ts +14 -23
- package/dist/lib/operation-args.js +6 -38
- package/dist/lib/ops-report.d.ts +18 -10
- package/dist/lib/ops-report.js +20 -16
- package/dist/lib/params.js +0 -8
- package/dist/lib/read-fanout.d.ts +22 -0
- package/dist/lib/read-fanout.js +28 -0
- package/dist/lib/select-deployment.js +0 -21
- package/dist/lib/share-definitions.d.ts +86 -0
- package/dist/lib/share-definitions.js +106 -0
- package/dist/lib/stub.js +0 -7
- package/dist/lib/telemetry-setup.d.ts +66 -0
- package/dist/lib/telemetry-setup.js +92 -0
- package/dist/lib/telemetry.d.ts +100 -0
- package/dist/lib/telemetry.js +89 -0
- package/dist/lib/ui.d.ts +33 -1
- package/dist/lib/ui.js +32 -21
- package/oclif.manifest.json +8 -47
- package/package.json +14 -8
package/dist/lib/ops-report.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { styleText } from 'node:util';
|
|
2
2
|
import logSymbols from 'log-symbols';
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
}
|
package/dist/lib/params.js
CHANGED
|
@@ -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[];
|