@sanity/workflow-cli 0.10.0 → 0.12.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 +61 -0
- package/README.md +57 -39
- package/dist/commands/definition/list.d.ts +3 -0
- package/dist/commands/definition/list.js +7 -4
- package/dist/commands/definition/show.d.ts +13 -0
- package/dist/commands/definition/show.js +25 -7
- package/dist/commands/deploy.d.ts +1 -1
- package/dist/commands/deploy.js +4 -4
- package/dist/commands/diagnose.d.ts +1 -1
- package/dist/commands/diagnose.js +23 -13
- package/dist/commands/fire-action.js +13 -2
- package/dist/commands/list.d.ts +13 -2
- package/dist/commands/list.js +43 -17
- package/dist/commands/nuke.d.ts +11 -0
- package/dist/commands/nuke.js +76 -0
- package/dist/commands/start.d.ts +17 -23
- package/dist/commands/start.js +49 -33
- package/dist/commands/tail.js +19 -15
- package/dist/hooks/prerun/telemetry.d.ts +4 -1
- package/dist/hooks/prerun/telemetry.js +6 -2
- package/dist/lib/client.d.ts +8 -0
- package/dist/lib/client.js +1 -1
- package/dist/lib/context.d.ts +6 -2
- package/dist/lib/context.js +9 -7
- package/dist/lib/definitions.d.ts +10 -0
- package/dist/lib/definitions.js +8 -2
- package/dist/lib/fail.d.ts +7 -0
- package/dist/lib/fail.js +2 -1
- package/dist/lib/nuke.d.ts +89 -0
- package/dist/lib/nuke.js +111 -0
- package/dist/lib/operation-args.d.ts +3 -1
- package/dist/lib/ops-report.d.ts +2 -1
- package/dist/lib/prompt.d.ts +10 -0
- package/dist/lib/prompt.js +4 -0
- package/dist/lib/share-definitions.d.ts +59 -25
- package/dist/lib/share-definitions.js +104 -21
- package/dist/lib/telemetry-setup.d.ts +4 -0
- package/dist/lib/telemetry-setup.js +18 -11
- package/dist/lib/telemetry.d.ts +33 -2
- package/dist/lib/telemetry.js +9 -4
- package/dist/lib/ui.js +0 -1
- package/oclif.manifest.json +66 -11
- package/package.json +4 -4
package/dist/lib/fail.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { styleText } from 'node:util';
|
|
2
|
+
import { Errors } from '@oclif/core';
|
|
2
3
|
import { ClientError } from '@sanity/client';
|
|
3
4
|
import { errorMessage } from '@sanity/workflow-engine';
|
|
4
5
|
import logSymbols from 'log-symbols';
|
|
@@ -10,7 +11,7 @@ export function fail(headline, detail) {
|
|
|
10
11
|
process.stderr.write(` ${styleText(['dim', 'red'], line)}\n`);
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
return Errors.exit(1);
|
|
14
15
|
}
|
|
15
16
|
export function failOnThrow(headline, fn) {
|
|
16
17
|
try {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type WorkflowClient, type WorkflowResource } from '@sanity/workflow-engine';
|
|
2
|
+
/** A resource a nuke may delete from, with the client that reads/writes it and
|
|
3
|
+
* whether it is the engine's own resource — the only one that holds instances
|
|
4
|
+
* and definitions (guards can co-locate with subjects in any resource). */
|
|
5
|
+
export interface NukeTarget {
|
|
6
|
+
resource: WorkflowResource;
|
|
7
|
+
client: WorkflowClient;
|
|
8
|
+
holdsEngineDocs: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** One resolved resource of a {@link NukePlan}: the ids to delete, by doc type. */
|
|
11
|
+
export interface NukeResourcePlan {
|
|
12
|
+
resource: WorkflowResource;
|
|
13
|
+
/** The `project.dataset` (or `<type>:<id>`) label the operator confirms by. */
|
|
14
|
+
label: string;
|
|
15
|
+
client: WorkflowClient;
|
|
16
|
+
holdsEngineDocs: boolean;
|
|
17
|
+
instanceIds: string[];
|
|
18
|
+
definitionIds: string[];
|
|
19
|
+
guardIds: string[];
|
|
20
|
+
}
|
|
21
|
+
/** The dry-run plan: what a `nuke --tag <tag>` would delete, per resource. */
|
|
22
|
+
export interface NukePlan {
|
|
23
|
+
tag: string;
|
|
24
|
+
resources: NukeResourcePlan[];
|
|
25
|
+
}
|
|
26
|
+
/** Per-doc-type totals across the whole plan, plus the count of resources that
|
|
27
|
+
* actually hold something to delete ({@link involvedTargets}). */
|
|
28
|
+
export interface PlanCounts {
|
|
29
|
+
instances: number;
|
|
30
|
+
definitions: number;
|
|
31
|
+
guards: number;
|
|
32
|
+
datasets: number;
|
|
33
|
+
total: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve what a tag-scoped nuke would delete, per resource — the dry-run plan
|
|
37
|
+
* printed before any deletion. Instances and definitions are read only from the
|
|
38
|
+
* engine's own resource; guards are read from every target, since a guard
|
|
39
|
+
* co-locates with the subject it locks and may live in a foreign dataset.
|
|
40
|
+
*
|
|
41
|
+
* Every read uses the raw perspective, so a draft-form or otherwise
|
|
42
|
+
* perspective-hidden copy is still surfaced (and swept).
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveNukePlan(args: {
|
|
45
|
+
tag: string;
|
|
46
|
+
targets: NukeTarget[];
|
|
47
|
+
}): Promise<NukePlan>;
|
|
48
|
+
/**
|
|
49
|
+
* Does a guard doc belong to `tag`? A guard's `sourceInstanceId` is the id of
|
|
50
|
+
* the instance that registered it (`<tag>.wf-instance.<random>`), and its own
|
|
51
|
+
* `_id` embeds that same instance id
|
|
52
|
+
* (`temp.system.guard.<tag>.wf-instance.<random>.<name>`). Matching on either —
|
|
53
|
+
* with the trailing `.` so `prod` never matches `prod-eu` — catches guards
|
|
54
|
+
* whose `sourceInstanceId` was never written as well as guards whose instance
|
|
55
|
+
* is already gone.
|
|
56
|
+
*/
|
|
57
|
+
export declare function guardMatchesTag(guard: {
|
|
58
|
+
_id: string;
|
|
59
|
+
sourceInstanceId?: string;
|
|
60
|
+
}, tag: string): boolean;
|
|
61
|
+
/** The `project.dataset` labels of resources the plan actually deletes from —
|
|
62
|
+
* the exact set the operator must type back to confirm. A resource with
|
|
63
|
+
* nothing to delete is not "involved" and is omitted. */
|
|
64
|
+
export declare function involvedTargets(plan: NukePlan): string[];
|
|
65
|
+
/** Roll the plan up into per-doc-type totals for the summary and the
|
|
66
|
+
* nothing-to-do short-circuit. */
|
|
67
|
+
export declare function planCounts(plan: NukePlan): PlanCounts;
|
|
68
|
+
/**
|
|
69
|
+
* Does the operator's typed confirmation name every involved target, and only
|
|
70
|
+
* those? Order-independent, whitespace-tolerant set equality: the operator
|
|
71
|
+
* pastes back all `project.dataset` targets the plan listed.
|
|
72
|
+
*/
|
|
73
|
+
export declare function confirmationMatches(input: string, targets: string[]): boolean;
|
|
74
|
+
/** The dry-run plan as printable lines: a heading, the content-safety
|
|
75
|
+
* invariant, the API host the deletes will hit (plan rows alone can't tell
|
|
76
|
+
* same-named projects on different environments apart), and a per-resource,
|
|
77
|
+
* per-doc-type count table. A guard-only foreign resource shows `—` for the
|
|
78
|
+
* engine-only columns, since those docs can't live there. */
|
|
79
|
+
export declare function renderNukePlan(plan: NukePlan, apiHost: string): string[];
|
|
80
|
+
/** The success line: what was deleted, and across how many datasets. */
|
|
81
|
+
export declare function formatNukeSummary(tag: string, counts: PlanCounts): string;
|
|
82
|
+
/**
|
|
83
|
+
* Delete exactly what the plan printed, in uniform waves over every resource.
|
|
84
|
+
* Wave order is referrers-first — instances, then definitions, then guards —
|
|
85
|
+
* a nicety for an interrupted run (engine docs link by plain strings, so the
|
|
86
|
+
* lake enforces no order); non-engine resources simply carry empty
|
|
87
|
+
* instance/definition lists. Idempotent: an empty plan deletes nothing.
|
|
88
|
+
*/
|
|
89
|
+
export declare function executeNuke(plan: NukePlan): Promise<void>;
|
package/dist/lib/nuke.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { GUARD_DOC_TYPE, WORKFLOW_DEFINITION_TYPE, WORKFLOW_INSTANCE_TYPE, tagScopeFilter, } from '@sanity/workflow-engine';
|
|
3
|
+
import { formatTable, resourceLabel, sectionHeader } from "./ui.js";
|
|
4
|
+
const CHUNK = 200;
|
|
5
|
+
const REQUEST_TAG = 'nuke';
|
|
6
|
+
export async function resolveNukePlan(args) {
|
|
7
|
+
const { tag, targets } = args;
|
|
8
|
+
const resources = await Promise.all(targets.map((target) => resolveResourcePlan({ tag, target })));
|
|
9
|
+
return { tag, resources };
|
|
10
|
+
}
|
|
11
|
+
async function resolveResourcePlan(args) {
|
|
12
|
+
const { tag, target } = args;
|
|
13
|
+
const { resource, client, holdsEngineDocs } = target;
|
|
14
|
+
const noIds = [];
|
|
15
|
+
const [instanceIds, definitionIds, guardIds] = await Promise.all([
|
|
16
|
+
holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_INSTANCE_TYPE, tag }) : noIds,
|
|
17
|
+
holdsEngineDocs ? engineDocIds({ client, type: WORKFLOW_DEFINITION_TYPE, tag }) : noIds,
|
|
18
|
+
tagGuardIds({ client, tag }),
|
|
19
|
+
]);
|
|
20
|
+
return {
|
|
21
|
+
resource,
|
|
22
|
+
label: resourceLabel(resource),
|
|
23
|
+
client,
|
|
24
|
+
holdsEngineDocs,
|
|
25
|
+
instanceIds,
|
|
26
|
+
definitionIds,
|
|
27
|
+
guardIds,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function engineDocIds(args) {
|
|
31
|
+
return args.client.fetch(`*[_type == $type && ${tagScopeFilter()}]._id`, { type: args.type, tag: args.tag }, { perspective: 'raw', tag: REQUEST_TAG });
|
|
32
|
+
}
|
|
33
|
+
async function tagGuardIds(args) {
|
|
34
|
+
const guards = await args.client.fetch(`*[_type == $type]{_id, sourceInstanceId}`, { type: GUARD_DOC_TYPE }, { perspective: 'raw', tag: REQUEST_TAG });
|
|
35
|
+
return guards.filter((guard) => guardMatchesTag(guard, args.tag)).map((guard) => guard._id);
|
|
36
|
+
}
|
|
37
|
+
export function guardMatchesTag(guard, tag) {
|
|
38
|
+
const instancePrefix = `${tag}.wf-instance.`;
|
|
39
|
+
const guardIdPrefix = `${GUARD_DOC_TYPE}.${instancePrefix}`;
|
|
40
|
+
return ((guard.sourceInstanceId?.startsWith(instancePrefix) ?? false) ||
|
|
41
|
+
guard._id.startsWith(guardIdPrefix));
|
|
42
|
+
}
|
|
43
|
+
export function involvedTargets(plan) {
|
|
44
|
+
return plan.resources
|
|
45
|
+
.filter((resource) => resourceTotal(resource) > 0)
|
|
46
|
+
.map((resource) => resource.label);
|
|
47
|
+
}
|
|
48
|
+
function resourceTotal(resource) {
|
|
49
|
+
return resource.instanceIds.length + resource.definitionIds.length + resource.guardIds.length;
|
|
50
|
+
}
|
|
51
|
+
export function planCounts(plan) {
|
|
52
|
+
const totals = plan.resources.reduce((acc, resource) => ({
|
|
53
|
+
instances: acc.instances + resource.instanceIds.length,
|
|
54
|
+
definitions: acc.definitions + resource.definitionIds.length,
|
|
55
|
+
guards: acc.guards + resource.guardIds.length,
|
|
56
|
+
}), { instances: 0, definitions: 0, guards: 0 });
|
|
57
|
+
return {
|
|
58
|
+
...totals,
|
|
59
|
+
datasets: involvedTargets(plan).length,
|
|
60
|
+
total: totals.instances + totals.definitions + totals.guards,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function confirmationMatches(input, targets) {
|
|
64
|
+
const typed = new Set(input.trim().split(/\s+/).filter(Boolean));
|
|
65
|
+
const expected = new Set(targets);
|
|
66
|
+
return typed.size === expected.size && [...expected].every((target) => typed.has(target));
|
|
67
|
+
}
|
|
68
|
+
export function renderNukePlan(plan, apiHost) {
|
|
69
|
+
const rows = plan.resources.map((resource) => [
|
|
70
|
+
resource.label,
|
|
71
|
+
resource.holdsEngineDocs ? String(resource.instanceIds.length) : styleText('dim', '—'),
|
|
72
|
+
resource.holdsEngineDocs ? String(resource.definitionIds.length) : styleText('dim', '—'),
|
|
73
|
+
String(resource.guardIds.length),
|
|
74
|
+
]);
|
|
75
|
+
return [
|
|
76
|
+
sectionHeader(`nuke — tag "${plan.tag}"`),
|
|
77
|
+
styleText('dim', 'engine-owned documents only — content is never touched'),
|
|
78
|
+
styleText('dim', `API host: ${apiHost}`),
|
|
79
|
+
...formatTable(['target', 'instances', 'definitions', 'guards'], rows),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
export function formatNukeSummary(tag, counts) {
|
|
83
|
+
const parts = [
|
|
84
|
+
pluralize(counts.instances, 'instance'),
|
|
85
|
+
pluralize(counts.definitions, 'definition'),
|
|
86
|
+
pluralize(counts.guards, 'guard'),
|
|
87
|
+
].join(', ');
|
|
88
|
+
return `Nuked tag "${tag}": ${parts} across ${pluralize(counts.datasets, 'dataset')}.`;
|
|
89
|
+
}
|
|
90
|
+
function pluralize(count, noun) {
|
|
91
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
92
|
+
}
|
|
93
|
+
export async function executeNuke(plan) {
|
|
94
|
+
for (const resource of plan.resources) {
|
|
95
|
+
await deleteIds(resource.client, resource.instanceIds);
|
|
96
|
+
}
|
|
97
|
+
for (const resource of plan.resources) {
|
|
98
|
+
await deleteIds(resource.client, resource.definitionIds);
|
|
99
|
+
}
|
|
100
|
+
for (const resource of plan.resources) {
|
|
101
|
+
await deleteIds(resource.client, resource.guardIds);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function deleteIds(client, ids) {
|
|
105
|
+
for (let start = 0; start < ids.length; start += CHUNK) {
|
|
106
|
+
const tx = client.transaction();
|
|
107
|
+
for (const id of ids.slice(start, start + CHUNK))
|
|
108
|
+
tx.delete(id);
|
|
109
|
+
await tx.commit({ tag: REQUEST_TAG });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -3,7 +3,9 @@ import { type EngineScopeArgs, type OperationArgs, type WorkflowTelemetryLogger
|
|
|
3
3
|
* The engine-scope a CLI verb needs: which tag partition and which resource
|
|
4
4
|
* the engine's own data lives in — {@link EngineScopeArgs} minus the client.
|
|
5
5
|
* A {@link import('@sanity/workflow-engine').WorkflowDeployment} satisfies it,
|
|
6
|
-
* so commands pass their resolved deployment straight through.
|
|
6
|
+
* so commands pass their resolved deployment straight through. The CLI passes
|
|
7
|
+
* no `resourceClients`, so its writes admit runtime-supplied refs to the
|
|
8
|
+
* workflow resource only.
|
|
7
9
|
*/
|
|
8
10
|
export type EngineScope = Pick<EngineScopeArgs, 'tag' | 'workflowResource'>;
|
|
9
11
|
/**
|
package/dist/lib/ops-report.d.ts
CHANGED
|
@@ -38,7 +38,8 @@ export declare function cascadeTail(cascaded: number): string;
|
|
|
38
38
|
* emit the report — warn and bail on a no-op (`changed: false`), otherwise
|
|
39
39
|
* succeed and print the ops block. A throw fails the spinner and exits
|
|
40
40
|
* through {@link fail}. The one spinner + report + catch shape every write
|
|
41
|
-
* command (abort, set-stage, fire-action, start, definition delete)
|
|
41
|
+
* command (abort, set-stage, fire-action, start, definition delete, nuke)
|
|
42
|
+
* shares.
|
|
42
43
|
*/
|
|
43
44
|
export declare function runWriteVerb<T>({ startLabel, failLabel, failHeadline, run, report, log, }: {
|
|
44
45
|
startLabel: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this process ask a question on stderr? A strict superset of the
|
|
3
|
+
* {@link isInteractive} gate cli-core's own prompts enforce (stdin TTY,
|
|
4
|
+
* `TERM` not dumb, no `CI` — so its `NonInteractiveError` can never escape a
|
|
5
|
+
* caller that checked here first), adding stderr-TTY because this package's
|
|
6
|
+
* prompts render on stderr: a redirected stderr must refuse rather than block
|
|
7
|
+
* on stdin with the question swallowed. The one predicate every prompt site
|
|
8
|
+
* (the deploy consent prompt, nuke's confirmation) shares.
|
|
9
|
+
*/
|
|
10
|
+
export declare function canPromptOnStderr(): boolean;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Definition sharing —
|
|
3
|
-
* telemetry consent. Sharing deployed workflow definitions with Sanity is
|
|
2
|
+
* Definition sharing — donating deployed workflow definitions to Sanity is
|
|
4
3
|
* CONTENT sharing, not telemetry: definitions carry customer-authored
|
|
5
4
|
* strings (names, titles, GROQ filters) that the telemetry payload policy
|
|
6
5
|
* bans, so the documents go to Sanity's first-party definition-feedback
|
|
7
|
-
* endpoint — one POST per deploy invocation — and telemetry carries only
|
|
8
|
-
*
|
|
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
9
|
*
|
|
10
|
-
* Consent model:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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`.
|
|
16
19
|
*/
|
|
17
20
|
import { type DeployDefinitionResult, type DeployedDefinition, type WorkflowResource } from '@sanity/workflow-engine';
|
|
21
|
+
import type { UserConfigStore } from './telemetry-setup.ts';
|
|
18
22
|
import { type WorkflowDefinitionSharedData } from './telemetry.ts';
|
|
19
23
|
/** The client slice sharing uses — the read-back of just-deployed
|
|
20
24
|
* documents and the feedback POST, structurally, so a full
|
|
@@ -46,13 +50,22 @@ export interface ShareCandidate {
|
|
|
46
50
|
* routed project-agnostically through the API gateway). */
|
|
47
51
|
export declare const SHARE_ENDPOINT_URI = "/workflow/definition-feedback";
|
|
48
52
|
/**
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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.
|
|
54
59
|
*/
|
|
55
|
-
export declare const
|
|
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;
|
|
56
69
|
/**
|
|
57
70
|
* The content-free telemetry marker for one shared definition — projected
|
|
58
71
|
* from the engine's own deploy-event derivation ({@link definitionDeployedData})
|
|
@@ -66,21 +79,42 @@ export declare function definitionShareMarker(args: {
|
|
|
66
79
|
deployId: string;
|
|
67
80
|
}): WorkflowDefinitionSharedData;
|
|
68
81
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* (
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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.
|
|
76
107
|
*
|
|
77
|
-
* The optional args exist for the
|
|
108
|
+
* The optional args exist for the flow's dependencies, mirroring
|
|
78
109
|
* `setupCliTelemetry` — omitted, the real process surfaces apply.
|
|
79
110
|
*/
|
|
80
111
|
export declare function shareDefinitionsAfterDeploy(args: {
|
|
81
|
-
flag: boolean;
|
|
112
|
+
flag: boolean | undefined;
|
|
82
113
|
candidates: ShareCandidate[];
|
|
83
114
|
warn: (message: string) => void;
|
|
84
115
|
interactive?: boolean;
|
|
116
|
+
envDenied?: boolean;
|
|
85
117
|
writeStderr?: (message: string) => void;
|
|
118
|
+
userConfig?: UserConfigStore;
|
|
119
|
+
confirmShare?: () => Promise<boolean>;
|
|
86
120
|
}): Promise<void>;
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { styleText } from 'node:util';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { getUserConfig } from '@sanity/cli-core';
|
|
3
|
+
import { confirm } from '@sanity/cli-core/ux';
|
|
4
|
+
import { assertReadableModel, definitionDeployedData, errorMessage, isTelemetryEnvDenied, } from '@sanity/workflow-engine';
|
|
4
5
|
import { buildDefinitionShowQuery } from "./definitions.js";
|
|
5
|
-
import {
|
|
6
|
+
import { canPromptOnStderr } from "./prompt.js";
|
|
7
|
+
import { cliTelemetry, WorkflowDefinitionShared, WorkflowDefinitionSharingDecided, } from "./telemetry.js";
|
|
6
8
|
export const SHARE_ENDPOINT_URI = '/workflow/definition-feedback';
|
|
7
9
|
const SHARE_TAG = 'definition.share';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
'
|
|
11
|
-
'
|
|
10
|
+
const SHARE_DECISION_KEY = 'workflowCliDefinitionSharing';
|
|
11
|
+
export const SHARE_CONSENT_PROMPT = `${styleText('bold', 'Share the workflow definitions this deploy creates with Sanity?')}\n` +
|
|
12
|
+
'They are sent verbatim (structure, names, filters, effect configuration,\n' +
|
|
13
|
+
'seeded values) with their deployment coordinates, and kept to improve\n' +
|
|
14
|
+
'Editorial Workflows — never your content documents, workflow instances, or\n' +
|
|
15
|
+
'your Sanity auth token. Your answer is remembered for future deploys on\n' +
|
|
16
|
+
`this machine; override any run with ${styleText('cyan', '--share-defs')} / ${styleText('cyan', '--no-share-defs')}.`;
|
|
17
|
+
export const SHARE_REMINDER_ON = `${styleText('bold', 'Sharing new workflow definitions with Sanity')} to improve Editorial Workflows` +
|
|
18
|
+
` — opt out with ${styleText('cyan', '--no-share-defs')}.\n`;
|
|
19
|
+
export const SHARE_REMINDER_OFF = `Not sharing workflow definitions with Sanity — share with ${styleText('cyan', '--share-defs')}.\n`;
|
|
12
20
|
export function definitionShareMarker(args) {
|
|
13
21
|
const { status: _status, ...structural } = definitionDeployedData(args.definition, {
|
|
14
22
|
contentHash: args.contentHash,
|
|
@@ -17,6 +25,18 @@ export function definitionShareMarker(args) {
|
|
|
17
25
|
});
|
|
18
26
|
return structural;
|
|
19
27
|
}
|
|
28
|
+
export function shouldForceShareTelemetry(args) {
|
|
29
|
+
const { commandId, argv } = args;
|
|
30
|
+
if (commandId !== 'deploy') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const terminator = argv.indexOf('--');
|
|
34
|
+
const tokens = terminator === -1 ? argv : argv.slice(0, terminator);
|
|
35
|
+
if (tokens.includes('--dry-run') || tokens.includes('--check')) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return tokens.lastIndexOf('--share-defs') > tokens.lastIndexOf('--no-share-defs');
|
|
39
|
+
}
|
|
20
40
|
async function loadShareEntry(args) {
|
|
21
41
|
const { candidate, result, warn } = args;
|
|
22
42
|
const deployed = await loadDeployedForShare(args);
|
|
@@ -51,6 +71,7 @@ async function loadDeployedForShare(args) {
|
|
|
51
71
|
warn(`Could not read back ${result.name} v${result.version} — not shared.`);
|
|
52
72
|
return undefined;
|
|
53
73
|
}
|
|
74
|
+
assertReadableModel(deployed);
|
|
54
75
|
return deployed;
|
|
55
76
|
}
|
|
56
77
|
catch (error) {
|
|
@@ -58,31 +79,60 @@ async function loadDeployedForShare(args) {
|
|
|
58
79
|
return undefined;
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
function collectCreated(candidates) {
|
|
83
|
+
return candidates.flatMap((candidate) => candidate.results
|
|
63
84
|
.filter((result) => result.status === 'created')
|
|
64
85
|
.map((result) => ({ candidate, result })));
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
}
|
|
87
|
+
function readShareDecision(userConfig) {
|
|
88
|
+
const value = userConfig.get(SHARE_DECISION_KEY);
|
|
89
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
90
|
+
}
|
|
91
|
+
async function promptFirstRun(args) {
|
|
92
|
+
let share;
|
|
93
|
+
try {
|
|
94
|
+
share = await args.confirmShare();
|
|
67
95
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
args.userConfig.set(SHARE_DECISION_KEY, share);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
}
|
|
104
|
+
return share;
|
|
105
|
+
}
|
|
106
|
+
async function resolveShareIntent(args) {
|
|
107
|
+
if (args.flag === true) {
|
|
108
|
+
return { share: true, decision: 'opt-in' };
|
|
109
|
+
}
|
|
110
|
+
if (args.flag === false) {
|
|
111
|
+
return { share: false, decision: 'opt-out' };
|
|
112
|
+
}
|
|
113
|
+
const unattended = args.envDenied || !args.interactive;
|
|
114
|
+
if (unattended) {
|
|
115
|
+
return { share: false, decision: 'unattended' };
|
|
75
116
|
}
|
|
117
|
+
const remembered = readShareDecision(args.userConfig);
|
|
118
|
+
if (remembered !== undefined) {
|
|
119
|
+
args.writeStderr(remembered ? SHARE_REMINDER_ON : SHARE_REMINDER_OFF);
|
|
120
|
+
return { share: remembered, decision: remembered ? 'remembered-share' : 'remembered-decline' };
|
|
121
|
+
}
|
|
122
|
+
const accepted = await promptFirstRun(args);
|
|
123
|
+
return { share: accepted, decision: accepted ? 'prompt-accepted' : 'prompt-declined' };
|
|
124
|
+
}
|
|
125
|
+
async function donate(args) {
|
|
76
126
|
const shared = [];
|
|
77
127
|
try {
|
|
78
|
-
for (const item of created) {
|
|
128
|
+
for (const item of args.created) {
|
|
79
129
|
const entry = await loadShareEntry({ ...item, warn: args.warn });
|
|
80
130
|
if (entry !== undefined) {
|
|
81
131
|
shared.push({ entry, candidate: item.candidate });
|
|
82
132
|
}
|
|
83
133
|
}
|
|
84
134
|
if (shared.length === 0) {
|
|
85
|
-
return;
|
|
135
|
+
return false;
|
|
86
136
|
}
|
|
87
137
|
await shared[0].candidate.client.request({
|
|
88
138
|
uri: SHARE_ENDPOINT_URI,
|
|
@@ -93,8 +143,12 @@ export async function shareDefinitionsAfterDeploy(args) {
|
|
|
93
143
|
}
|
|
94
144
|
catch (error) {
|
|
95
145
|
args.warn(`Definition sharing failed — nothing shared. ${errorMessage(error)}`);
|
|
96
|
-
return;
|
|
146
|
+
return false;
|
|
97
147
|
}
|
|
148
|
+
emitShareMarkers(shared);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
function emitShareMarkers(shared) {
|
|
98
152
|
try {
|
|
99
153
|
const { logger } = cliTelemetry();
|
|
100
154
|
for (const { entry, candidate } of shared) {
|
|
@@ -104,3 +158,32 @@ export async function shareDefinitionsAfterDeploy(args) {
|
|
|
104
158
|
catch {
|
|
105
159
|
}
|
|
106
160
|
}
|
|
161
|
+
function emitSharingDecided(data) {
|
|
162
|
+
try {
|
|
163
|
+
cliTelemetry().logger.log(WorkflowDefinitionSharingDecided, data);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function shareDefinitionsAfterDeploy(args) {
|
|
169
|
+
const created = collectCreated(args.candidates);
|
|
170
|
+
if (created.length === 0) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const { share, decision } = await resolveShareIntent({
|
|
175
|
+
flag: args.flag,
|
|
176
|
+
interactive: args.interactive ?? canPromptOnStderr(),
|
|
177
|
+
envDenied: args.envDenied ?? isTelemetryEnvDenied(process.env),
|
|
178
|
+
userConfig: args.userConfig ?? getUserConfig(),
|
|
179
|
+
writeStderr: args.writeStderr ?? ((message) => void process.stderr.write(message)),
|
|
180
|
+
confirmShare: args.confirmShare ??
|
|
181
|
+
(() => confirm({ message: SHARE_CONSENT_PROMPT, default: true }, { output: process.stderr })),
|
|
182
|
+
});
|
|
183
|
+
const shared = share ? await donate({ created, warn: args.warn }) : false;
|
|
184
|
+
emitSharingDecided({ decision, shared, definitionCount: created.length });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
args.warn(`Definition sharing skipped — ${errorMessage(error)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -39,6 +39,10 @@ export declare function setupCliTelemetry(args?: {
|
|
|
39
39
|
writeStderr?: (message: string) => void;
|
|
40
40
|
/** The oclif config's version — the prerun hook supplies it. */
|
|
41
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;
|
|
42
46
|
/** Intake client override — tests inject a fake here, like the other
|
|
43
47
|
* hook dependencies above; omitted, the real project client applies. */
|
|
44
48
|
client?: TelemetryIntakeClient;
|
|
@@ -49,23 +49,30 @@ export async function setupCliTelemetry(args) {
|
|
|
49
49
|
client,
|
|
50
50
|
projectId: project.projectId,
|
|
51
51
|
env: args?.env ?? process.env,
|
|
52
|
+
forceSend: args?.forceSend ?? false,
|
|
52
53
|
});
|
|
53
54
|
setCliTelemetry(telemetry);
|
|
54
|
-
|
|
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
|
-
}
|
|
55
|
+
attachBuiltinContext({ telemetry, project, client, deps: args });
|
|
65
56
|
}
|
|
66
57
|
catch {
|
|
67
58
|
}
|
|
68
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
|
+
}
|
|
69
76
|
const ORG_LOOKUP_DEADLINE_MS = 2_000;
|
|
70
77
|
const CONTEXT_TAG = 'telemetry.context';
|
|
71
78
|
export function cliUserProperties(args) {
|