@userreason/cli 0.1.1 → 0.2.1

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 (50) hide show
  1. package/README.md +217 -0
  2. package/dist/bin.js +93 -8
  3. package/dist/catalog.js +29 -3
  4. package/dist/client.d.ts +8 -2
  5. package/dist/client.js +26 -11
  6. package/dist/first-survey-preparation.d.ts +8 -0
  7. package/dist/first-survey-preparation.js +29 -0
  8. package/dist/first-survey-source.d.ts +61 -0
  9. package/dist/first-survey-source.js +298 -0
  10. package/dist/first-survey-terminal.d.ts +5 -0
  11. package/dist/first-survey-terminal.js +16 -0
  12. package/dist/first-survey.d.ts +88 -0
  13. package/dist/first-survey.js +227 -0
  14. package/dist/help.js +6 -6
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.js +15 -0
  17. package/dist/profile.js +1 -1
  18. package/dist/recommendation-source.d.ts +13 -0
  19. package/dist/recommendation-source.js +211 -0
  20. package/dist/recommended-surveys.d.ts +30 -0
  21. package/dist/recommended-surveys.js +201 -0
  22. package/dist/setup-diagnostics.d.ts +2 -0
  23. package/dist/setup-diagnostics.js +26 -0
  24. package/dist/setup-executor.d.ts +42 -0
  25. package/dist/setup-executor.js +350 -0
  26. package/dist/setup-files.d.ts +11 -0
  27. package/dist/setup-files.js +96 -0
  28. package/dist/setup-heartbeat.d.ts +268 -0
  29. package/dist/setup-heartbeat.js +120 -0
  30. package/dist/setup-runner.d.ts +16 -0
  31. package/dist/setup-runner.js +191 -0
  32. package/dist/setup-source.d.ts +32 -0
  33. package/dist/setup-source.js +48 -0
  34. package/dist/setup-store.d.ts +37 -0
  35. package/dist/setup-store.js +266 -0
  36. package/dist/setup-terminal.d.ts +3 -0
  37. package/dist/setup-terminal.js +6 -0
  38. package/dist/setup-wire.d.ts +1367 -0
  39. package/dist/setup-wire.js +190 -0
  40. package/dist/setup.d.ts +1423 -0
  41. package/dist/setup.js +264 -0
  42. package/dist/starter-survey.d.ts +22 -0
  43. package/dist/starter-survey.js +457 -0
  44. package/dist/telemetry.d.ts +67 -0
  45. package/dist/telemetry.js +304 -0
  46. package/dist/workos.d.ts +1 -1
  47. package/dist/workos.js +4 -2
  48. package/package.json +3 -2
  49. package/skills/userreason/SKILL.md +43 -0
  50. package/skills/userreason/references/management.md +6 -0
@@ -0,0 +1,201 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { configDirectory } from "./config.js";
6
+ import { sourceHash, verifyFirstSurveySource, } from "./first-survey-source.js";
7
+ import { prepareFirstSurveyLocally, } from "./first-survey-preparation.js";
8
+ const record = (v) => v && typeof v === "object" && !Array.isArray(v)
9
+ ? v
10
+ : {};
11
+ /** One optional selection; all drafts stay unpublished and match no audience. */
12
+ export async function recommendedSurveys(client, root, workspace, site, ui, options = {}) {
13
+ if (!options.sourceConsent)
14
+ return { state: "skipped" };
15
+ if (!ui.chooseMany)
16
+ throw Error("Survey selection requires a checkbox interface");
17
+ const scope = {
18
+ api: client.config.apiUrl,
19
+ workspace_id: workspace,
20
+ site_id: site,
21
+ };
22
+ const scopeKey = sourceHash(JSON.stringify([root, scope]));
23
+ const dir = join(configDirectory(), "recommended-surveys");
24
+ await mkdir(dir, { recursive: true, mode: 0o700 });
25
+ const path = join(dir, scopeKey + ".json");
26
+ const lock = await open(path + ".lock", "wx", 0o600);
27
+ const preparation = options.preparation ?? prepareFirstSurveyLocally(root);
28
+ let state = {
29
+ version: 1,
30
+ scope: scopeKey,
31
+ expires: Date.now() + 7 * 86400000,
32
+ candidates: [],
33
+ sources: [],
34
+ receipts: {},
35
+ };
36
+ const save = async () => {
37
+ const tmp = path + "." + randomUUID() + ".tmp";
38
+ await writeFile(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
39
+ await rename(tmp, path);
40
+ };
41
+ const check = () => ui.signal?.throwIfAborted();
42
+ try {
43
+ try {
44
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ try {
46
+ if ((await file.stat()).size > 128000)
47
+ throw Error("Invalid recommendation cache");
48
+ const old = JSON.parse(await file.readFile("utf8"));
49
+ if (old.version !== 1 || old.scope !== scopeKey)
50
+ throw Error("Recommendation scope changed");
51
+ if (old.expires > Date.now())
52
+ state = old;
53
+ }
54
+ finally {
55
+ await file.close();
56
+ }
57
+ }
58
+ catch (e) {
59
+ if (e.code !== "ENOENT")
60
+ throw e;
61
+ }
62
+ check();
63
+ // A previous selected save can be retried without changing its definitions or IDs.
64
+ if (!state.selected) {
65
+ if (state.candidates.length) {
66
+ try {
67
+ await verifyFirstSurveySource(root, state.sources);
68
+ }
69
+ catch {
70
+ state.candidates = [];
71
+ state.sources = [];
72
+ }
73
+ }
74
+ if (!state.candidates.length) {
75
+ const generate = async () => {
76
+ const preview = await preparation.take(ui.signal);
77
+ if (!preview.evidence.length)
78
+ throw Error("No product source available");
79
+ const metadata = record(await client.request("/onboarding/v1/planning/describe", { schema_version: 1, session_id: randomUUID(), scope }, ui.signal));
80
+ if (!record(metadata.recommendations).version)
81
+ throw Error("Development recommendation pipeline is unavailable");
82
+ await verifyFirstSurveySource(root, preview.evidence);
83
+ const response = record(await client.request("/onboarding/v1/planning/analyze", {
84
+ schema_version: 1,
85
+ session_id: randomUUID(),
86
+ request_id: randomUUID(),
87
+ scope,
88
+ packages: preview.packages,
89
+ capabilities: [
90
+ ...preview.capabilities,
91
+ "recommended_surveys:v1",
92
+ ],
93
+ evidence: preview.evidence,
94
+ previous_plan_digest: null,
95
+ limits: {
96
+ input_tokens: 12000,
97
+ output_tokens: 4000,
98
+ deadline_ms: 40000,
99
+ },
100
+ consent: {
101
+ packet_digest: sourceHash(JSON.stringify(preview.evidence)),
102
+ destination: metadata.destination,
103
+ configuration_fingerprint: metadata.configuration_fingerprint,
104
+ policy_version: "first-survey-v1",
105
+ authorized_at: new Date().toISOString(),
106
+ git_authorized: false,
107
+ },
108
+ }, ui.signal));
109
+ await verifyFirstSurveySource(root, preview.evidence);
110
+ const candidates = record(response.result).candidates;
111
+ if (!Array.isArray(candidates))
112
+ throw Error("Invalid recommendations");
113
+ state.candidates = candidates.slice(0, 3);
114
+ state.sources = preview.evidence.map(({ path, file_hash }) => ({
115
+ path,
116
+ file_hash,
117
+ }));
118
+ await save();
119
+ };
120
+ try {
121
+ if (ui.loading)
122
+ await ui.loading("Finding recommended surveys", generate);
123
+ else
124
+ await generate();
125
+ }
126
+ catch (error) {
127
+ check();
128
+ ui.show("Recommendations are unavailable right now. Setup is complete; rerun the wizard to try again.");
129
+ return {
130
+ state: "unavailable",
131
+ error: error instanceof Error ? error.message : "unavailable",
132
+ };
133
+ }
134
+ }
135
+ if (!state.candidates.length) {
136
+ ui.show("No supported survey recommendations yet. Setup is complete.");
137
+ return { state: "empty" };
138
+ }
139
+ ui.show("Select surveys to get started. We’ll create drafts in your workspace. Nothing will be published.");
140
+ const selected = await ui.chooseMany("Recommended surveys", state.candidates.map((c) => ({
141
+ value: c.id,
142
+ label: `${String(c.definition.name)} — ${c.question}`,
143
+ hint: `${c.opportunity.claim} Delivery can be configured later.`,
144
+ })), []);
145
+ check();
146
+ if (!selected.length)
147
+ return { state: "skipped" };
148
+ if (new Set(selected).size !== selected.length ||
149
+ selected.some((id) => !state.candidates.some((c) => c.id === id)))
150
+ throw Error("Invalid survey selection");
151
+ await verifyFirstSurveySource(root, state.sources);
152
+ // Validate all selected drafts before writing any of them.
153
+ for (const id of selected) {
154
+ const c = state.candidates.find((c) => c.id === id);
155
+ const validation = record(await client.read("prompts.validate", {
156
+ workspaceId: workspace,
157
+ siteId: site,
158
+ definition: c.definition,
159
+ }));
160
+ if (validation.valid !== true)
161
+ throw Error("A recommended draft did not validate");
162
+ }
163
+ state.selected = selected;
164
+ for (const id of selected)
165
+ state.receipts[id] = {
166
+ prompt: randomUUID(),
167
+ request: randomUUID(),
168
+ definition: state.candidates.find((c) => c.id === id).definition,
169
+ };
170
+ await save();
171
+ }
172
+ let count = 0;
173
+ for (const id of state.selected) {
174
+ check();
175
+ const receipt = state.receipts[id];
176
+ if (!receipt)
177
+ throw Error("Missing draft receipt");
178
+ if (!receipt.result) {
179
+ receipt.result = await client.promptCommand({
180
+ workspaceId: workspace,
181
+ siteId: site,
182
+ action: "create",
183
+ promptId: receipt.prompt,
184
+ requestId: receipt.request,
185
+ definition: receipt.definition,
186
+ });
187
+ await save();
188
+ }
189
+ const saved = record(receipt.result), link = record(saved.links).edit;
190
+ ui.show(`Draft saved: ${String(receipt.definition.name)}${typeof link === "string" ? `\n${link}` : ""}`);
191
+ count++;
192
+ }
193
+ ui.show(`${count} survey draft${count === 1 ? "" : "s"} saved. Nothing was published.`);
194
+ return { state: "saved", count, receipts: state.receipts };
195
+ }
196
+ finally {
197
+ preparation.cancel();
198
+ await lock.close();
199
+ await unlink(path + ".lock");
200
+ }
201
+ }
@@ -0,0 +1,2 @@
1
+ /** Bounded, local-only diagnostics. Never include these in setup API requests. */
2
+ export declare function sanitizeSetupDiagnostic(value: string, secrets?: string[]): string;
@@ -0,0 +1,26 @@
1
+ /** Bounded, local-only diagnostics. Never include these in setup API requests. */
2
+ export function sanitizeSetupDiagnostic(value, secrets = []) {
3
+ let text = value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
4
+ const environmentSecrets = Object.entries(process.env)
5
+ .filter(([key, value]) => /token|secret|password|api[_-]?key|authorization/i.test(key) && value && value.length >= 4)
6
+ .map(([, value]) => value);
7
+ for (const secret of [...secrets, ...environmentSecrets].filter(value => value.length >= 4).sort((a, b) => b.length - a.length))
8
+ text = text.split(secret).join('[redacted]');
9
+ text = text.replace(/\b(Bearer|Basic)\s+[^\s]+/gi, '$1 [redacted]');
10
+ text = text.replace(/((?:_authToken|_password|password|access_token|refresh_token|api_key|authorization)\s*[:=]\s*)[^\s]+/gi, '$1[redacted]');
11
+ text = text.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|npm_[A-Za-z0-9]+|sk-[A-Za-z0-9_-]{16,})\b/g, '[redacted]');
12
+ text = text.replace(/https?:\/\/[^\s<>"']+/g, value => {
13
+ try {
14
+ const url = new URL(value);
15
+ url.username = '';
16
+ url.password = '';
17
+ url.search = '';
18
+ url.hash = '';
19
+ return url.toString();
20
+ }
21
+ catch {
22
+ return '[invalid URL]';
23
+ }
24
+ });
25
+ return text.trim().slice(0, 8000);
26
+ }
@@ -0,0 +1,42 @@
1
+ import { type BrowserSetupClient } from './setup.js';
2
+ import { type SetupLocalPlan, type SetupEffect, type SetupSessionView } from './setup-wire.js';
3
+ import { SetupHeartbeat } from './setup-heartbeat.js';
4
+ import { type SetupLocalFile } from './setup-files.js';
5
+ export interface PreparedSetupOperation {
6
+ effect: SetupEffect;
7
+ files: SetupLocalFile[];
8
+ guards?: {
9
+ path: string;
10
+ before_hash: string | null;
11
+ }[];
12
+ }
13
+ export interface PreparedSetupPlan {
14
+ wire: SetupLocalPlan;
15
+ operations: PreparedSetupOperation[];
16
+ }
17
+ export interface SetupExecutorRuntime {
18
+ prepare(session: SetupSessionView): Promise<PreparedSetupPlan>;
19
+ /** Only a locally prepared allowlisted operation is passed here, never a server script. */
20
+ apply(operation: PreparedSetupOperation, signal: AbortSignal): Promise<'verified' | 'already_present'>;
21
+ /** Installed package versions require a runtime check in addition to file hashes. */
22
+ verify?(operation: PreparedSetupOperation): Promise<boolean>;
23
+ }
24
+ export interface SetupExecutorOptions {
25
+ signal?: AbortSignal;
26
+ pollMs?: number;
27
+ heartbeatMs?: number;
28
+ maxPolls?: number;
29
+ sleep?: (ms: number) => Promise<void>;
30
+ onSession?: (session: SetupSessionView) => void;
31
+ /** Sanitized local detail, retained in the private journal, never sent to the API. */
32
+ onOperationError?: (failure: {
33
+ kind: SetupEffect['kind'];
34
+ message: string;
35
+ receiptPath: string;
36
+ }) => void;
37
+ }
38
+ export type SetupExecutorClient = Pick<BrowserSetupClient, 'identity' | 'poll' | 'plan' | 'claim' | 'startCommand' | 'report' | 'heartbeat' | 'reconcile'>;
39
+ /** Bounded local cleanup never restores authority or follows an unknown file/link. */
40
+ export declare function pruneSetupExecutionReceipts(now?: number): Promise<number>;
41
+ /** Runs only after browser approval. Durable intents precede effects and durable outcomes precede reports. */
42
+ export declare function runSetupExecutor(client: SetupExecutorClient, directory: string, runtime: SetupExecutorRuntime, options?: SetupExecutorOptions, sharedHeartbeat?: SetupHeartbeat): Promise<SetupSessionView>;
@@ -0,0 +1,350 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, opendir, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { SetupError, setupPrivateDirectory, setupProjectFingerprint, withSetupProjectLock } from './setup-store.js';
6
+ import { parseSetupCommandResultInput, parseSetupLocalPlan } from './setup-wire.js';
7
+ import { SetupHeartbeat, observeSetupClient, setupAbortable } from './setup-heartbeat.js';
8
+ import { readSetupFile, setupDigest, setupHash, verifySetupFiles } from './setup-files.js';
9
+ import { sanitizeSetupDiagnostic } from './setup-diagnostics.js';
10
+ const terminal = new Set(['complete', 'cancelled', 'expired']);
11
+ const stopped = new Set(['ready_for_recommendations', 'analyzing', 'selecting_drafts', 'creating_drafts', 'agent_test']);
12
+ const limit = 8_000_000;
13
+ const retention = 7 * 86_400_000;
14
+ function validatePrepared(value) {
15
+ if (!value || Object.keys(value).some(key => !['wire', 'operations'].includes(key)))
16
+ throw new SetupError('setup_plan_invalid', 'Invalid local plan.');
17
+ const wire = parseSetupLocalPlan(value.wire);
18
+ if (!Array.isArray(value.operations) || value.operations.length !== wire.effects.length)
19
+ throw new SetupError('setup_plan_invalid', 'The local plan is incomplete.');
20
+ for (const effect of wire.effects) {
21
+ const operation = value.operations.find(item => item.effect.effect_id === effect.effect_id);
22
+ if (!operation || setupDigest(operation.effect) !== setupDigest(effect) || operation.files.length !== effect.files.length)
23
+ throw new SetupError('setup_plan_invalid', 'The local operation differs from its reviewed metadata.');
24
+ if (Object.keys(operation).some(key => !['effect', 'files', 'guards'].includes(key)))
25
+ throw new SetupError('setup_plan_invalid', 'Unknown local operation metadata.');
26
+ for (const file of operation.files) {
27
+ const metadata = effect.files.find(item => item.path === file.path);
28
+ if (Object.keys(file).some(key => !['path', 'before', 'after'].includes(key)) || !metadata || typeof file.after !== 'string' || file.before !== null && typeof file.before !== 'string' ||
29
+ metadata.before_hash !== (file.before === null ? null : setupHash(file.before)) || metadata.after_hash !== setupHash(file.after))
30
+ throw new SetupError('setup_plan_invalid', 'The reviewed file hashes do not match the local plan.');
31
+ }
32
+ if (new Set(operation.files.map(file => file.path)).size !== operation.files.length)
33
+ throw new SetupError('setup_plan_invalid', 'The local plan contains duplicate paths.');
34
+ if (operation.guards && (!Array.isArray(operation.guards) || operation.guards.length > 8 || new Set(operation.guards.map(item => item.path)).size !== operation.guards.length || operation.guards.some(item => Object.keys(item).some(key => !['path', 'before_hash'].includes(key)) || !['.npmrc', '.yarnrc', '.yarnrc.yml'].includes(item.path) || item.before_hash !== null && !/^[a-f0-9]{64}$/.test(item.before_hash))))
35
+ throw new SetupError('setup_plan_invalid', 'Invalid local package configuration guards.');
36
+ }
37
+ return { wire, operations: value.operations };
38
+ }
39
+ async function journalPath(client) {
40
+ const directory = await setupPrivateDirectory('receipts');
41
+ await pruneSetupExecutionReceipts();
42
+ return join(directory, `${setupHash(`${client.identity.api_url}\n${client.identity.setup_id}`)}.json`);
43
+ }
44
+ /** Bounded local cleanup never restores authority or follows an unknown file/link. */
45
+ export async function pruneSetupExecutionReceipts(now = Date.now()) {
46
+ const directory = await setupPrivateDirectory('receipts');
47
+ let removed = 0, scanned = 0, bytes = 0;
48
+ for await (const entry of await opendir(directory)) {
49
+ if (++scanned > 128)
50
+ break;
51
+ if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name))
52
+ continue;
53
+ const path = join(directory, entry.name);
54
+ let handle;
55
+ try {
56
+ const info = await lstat(path);
57
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > limit)
58
+ continue;
59
+ bytes += info.size;
60
+ if (bytes > 16_000_000)
61
+ break;
62
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
63
+ const opened = await handle.stat();
64
+ if (opened.ino !== info.ino || opened.dev !== info.dev)
65
+ continue;
66
+ const data = JSON.parse(await handle.readFile('utf8'));
67
+ validateJournal(data, data, false);
68
+ if (data.version !== 1 || typeof data.api_url !== 'string' || typeof data.setup_id !== 'string' || entry.name !== `${setupHash(`${data.api_url}\n${data.setup_id}`)}.json` ||
69
+ !Number.isFinite(Date.parse(data.created_at)) || Date.parse(data.retain_until) > Date.parse(data.created_at) + retention || !(Date.parse(data.retain_until) < now))
70
+ continue;
71
+ const current = await lstat(path);
72
+ if (current.ino === info.ino && current.dev === info.dev && current.isFile() && !current.isSymbolicLink()) {
73
+ await rm(path);
74
+ removed++;
75
+ }
76
+ }
77
+ catch { /* Unknown or damaged files stay for explicit local review. */ }
78
+ finally {
79
+ await handle?.close();
80
+ }
81
+ }
82
+ return removed;
83
+ }
84
+ function validateJournal(data, identity, active = true) {
85
+ const uuid = (value) => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value);
86
+ if (Object.keys(data).some(key => !['version', 'setup_id', 'api_url', 'project_fingerprint', 'created_at', 'retain_until', 'plan', 'target_digest', 'plan_request_id', 'claim_request_id', 'receipts'].includes(key)) ||
87
+ data.version !== 1 || !uuid(data.setup_id) || typeof data.api_url !== 'string' || !/^[a-f0-9]{64}$/.test(data.project_fingerprint) || data.setup_id !== identity.setup_id || data.api_url !== identity.api_url || data.project_fingerprint !== identity.project_fingerprint ||
88
+ !Number.isFinite(Date.parse(data.created_at)) || !Number.isFinite(Date.parse(data.retain_until)) || Date.parse(data.retain_until) <= Date.parse(data.created_at) || Date.parse(data.retain_until) > Date.parse(data.created_at) + retention ||
89
+ !data.receipts || typeof data.receipts !== 'object' || Array.isArray(data.receipts) || Object.keys(data.receipts).length > 32 ||
90
+ !uuid(data.plan_request_id) || !uuid(data.claim_request_id) || data.target_digest !== undefined && !/^[a-f0-9]{64}$/.test(data.target_digest))
91
+ throw new Error('invalid receipt');
92
+ if (active && Date.parse(data.retain_until) <= Date.now())
93
+ throw new Error('expired local receipt');
94
+ if (data.plan)
95
+ data.plan = validatePrepared(data.plan);
96
+ for (const [operationId, receipt] of Object.entries(data.receipts)) {
97
+ if (!uuid(operationId) || !receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
98
+ Object.keys(receipt).some(key => !['input_digest', 'stage', 'outcome', 'diagnostic', 'outputs', 'request_id'].includes(key)) ||
99
+ (receipt.diagnostic !== undefined && (typeof receipt.diagnostic !== 'string' || receipt.diagnostic.length > 8000)) ||
100
+ !/^[a-f0-9]{64}$/.test(receipt.input_digest) || !['claimed', 'running', 'settled'].includes(receipt.stage) || !uuid(receipt.request_id) ||
101
+ (receipt.stage === 'settled') !== Boolean(receipt.outcome))
102
+ throw new Error('invalid operation receipt');
103
+ const operation = data.plan?.operations.find(item => setupDigest(item.effect) === receipt.input_digest);
104
+ if (!operation)
105
+ throw new Error('receipt has no prepared operation');
106
+ if (receipt.outcome) {
107
+ const parsed = parseSetupCommandResultInput({ schema_version: 1, setup_id: data.setup_id, operation_id: operationId, executor_generation: 1, lease_generation: 1, ...receipt.outcome });
108
+ if (Object.keys(receipt.outcome).some(key => !['status', 'result'].includes(key)))
109
+ throw new Error('invalid outcome');
110
+ receipt.outcome = { status: parsed.status, result: parsed.result };
111
+ }
112
+ if (receipt.outputs !== undefined) {
113
+ const allowed = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs?.map(item => item.path) ?? [] : [];
114
+ if (!receipt.outputs || typeof receipt.outputs !== 'object' || Array.isArray(receipt.outputs) || Object.entries(receipt.outputs).some(([key, value]) => !allowed.includes(key) || value !== null && (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value))))
115
+ throw new Error('invalid managed outputs');
116
+ }
117
+ }
118
+ return data;
119
+ }
120
+ async function loadJournal(path, client) {
121
+ let handle;
122
+ try {
123
+ const info = await lstat(path);
124
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > limit)
125
+ throw new Error('unsafe receipt');
126
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
127
+ const current = await handle.stat();
128
+ if (info.ino !== current.ino || info.dev !== current.dev || current.size > limit)
129
+ throw new Error('changed receipt');
130
+ const bytes = await handle.readFile();
131
+ if (bytes.length > limit)
132
+ throw new Error('large receipt');
133
+ const data = JSON.parse(bytes.toString());
134
+ return validateJournal(data, client.identity);
135
+ }
136
+ catch (error) {
137
+ if (error.code === 'ENOENT') {
138
+ const now = Date.now();
139
+ return { version: 1, setup_id: client.identity.setup_id, api_url: client.identity.api_url, project_fingerprint: client.identity.project_fingerprint, created_at: new Date(now).toISOString(), retain_until: new Date(now + retention).toISOString(), plan_request_id: randomUUID(), claim_request_id: randomUUID(), receipts: {} };
140
+ }
141
+ throw new SetupError('setup_receipt_invalid', 'The local receipt needs review. No local effects were repeated.');
142
+ }
143
+ finally {
144
+ await handle?.close();
145
+ }
146
+ }
147
+ async function saveJournal(path, journal) {
148
+ const contents = JSON.stringify(journal);
149
+ if (Buffer.byteLength(contents) > limit)
150
+ throw new SetupError('setup_plan_too_large', 'The prepared local plan exceeds the private receipt limit.');
151
+ const temporary = `${path}.${randomUUID()}.tmp`;
152
+ try {
153
+ const handle = await open(temporary, 'wx', 0o600);
154
+ try {
155
+ await handle.writeFile(contents);
156
+ await handle.sync();
157
+ }
158
+ finally {
159
+ await handle.close();
160
+ }
161
+ await rename(temporary, path);
162
+ }
163
+ finally {
164
+ await rm(temporary, { force: true }).catch(() => { });
165
+ }
166
+ }
167
+ async function outputs(directory, operation) {
168
+ const entries = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs ?? [] : [];
169
+ const result = {};
170
+ for (const file of entries) {
171
+ const content = await readSetupFile(directory, file.path);
172
+ result[file.path] = content === null ? null : setupHash(content);
173
+ }
174
+ return result;
175
+ }
176
+ async function verified(directory, operation, runtime, receipt) {
177
+ if (!await verifySetupFiles(directory, operation.files, 'after'))
178
+ return false;
179
+ const managed = operation.effect.kind === 'install_packages' ? operation.effect.managed_outputs ?? [] : [];
180
+ if (managed.length && (!receipt?.outputs || setupDigest(await outputs(directory, operation)) !== setupDigest(receipt.outputs)))
181
+ return false;
182
+ return runtime.verify ? runtime.verify(operation) : operation.effect.kind !== 'install_packages';
183
+ }
184
+ /** Runs only after browser approval. Durable intents precede effects and durable outcomes precede reports. */
185
+ export async function runSetupExecutor(client, directory, runtime, options = {}, sharedHeartbeat) {
186
+ if (await setupProjectFingerprint(directory) !== client.identity.project_fingerprint)
187
+ throw new SetupError('setup_binding_mismatch', 'This executor belongs to another project.');
188
+ const heartbeat = sharedHeartbeat ?? new SetupHeartbeat(client, options);
189
+ client = observeSetupClient(client, heartbeat, options.signal);
190
+ try {
191
+ return await withSetupProjectLock(directory, async () => {
192
+ const path = await journalPath(client), journal = await loadJournal(path, client);
193
+ const shownErrors = new Set();
194
+ const showError = (id, receipt) => {
195
+ if (!receipt.diagnostic || shownErrors.has(id))
196
+ return;
197
+ const operation = journal.plan?.operations.find(item => setupDigest(item.effect) === receipt.input_digest);
198
+ if (!operation)
199
+ return;
200
+ shownErrors.add(id);
201
+ try {
202
+ options.onOperationError?.({ kind: operation.effect.kind, message: sanitizeSetupDiagnostic(receipt.diagnostic), receiptPath: path });
203
+ }
204
+ catch { /* A display callback cannot prevent the durable result report. */ }
205
+ };
206
+ for (const [id, receipt] of Object.entries(journal.receipts))
207
+ showError(id, receipt);
208
+ let session = await client.poll(), polls = 0;
209
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
210
+ const pause = async () => { if (options.sleep)
211
+ await options.sleep(options.pollMs ?? 2000);
212
+ else
213
+ await new Promise((resolve, reject) => { const timer = setTimeout(done, options.pollMs ?? 2000); function done() { options.signal?.removeEventListener('abort', abort); resolve(); } function abort() { clearTimeout(timer); reject(options.signal?.reason); } options.signal?.addEventListener('abort', abort, { once: true }); }); };
214
+ while (true) {
215
+ heartbeat.check();
216
+ session = heartbeat.latest(session);
217
+ options.onSession?.(session);
218
+ if (terminal.has(session.phase) || stopped.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls) {
219
+ if (session.phase === 'cancelled') {
220
+ const known = session.commands.find(command => command.status === 'running' && journal.receipts[command.operation_id]?.outcome);
221
+ if (known)
222
+ session = await client.report({ ...scope, operation_id: known.operation_id, executor_generation: known.executor_generation, lease_generation: known.lease_generation, ...journal.receipts[known.operation_id].outcome });
223
+ }
224
+ return session;
225
+ }
226
+ if (session.phase === 'configuring') {
227
+ if (!session.site_id || !session.workspace_id) {
228
+ await heartbeat.pulse();
229
+ heartbeat.check();
230
+ session = heartbeat.latest(session);
231
+ await pause();
232
+ session = await client.poll();
233
+ continue;
234
+ }
235
+ const targetDigest = setupDigest({ workspace_id: session.workspace_id, site_id: session.site_id, api_url: client.identity.api_url });
236
+ if (!journal.plan || journal.plan.wire.choices_digest !== setupDigest(session.choices) || journal.target_digest !== targetDigest) {
237
+ if (Object.keys(journal.receipts).length)
238
+ throw new SetupError('setup_plan_changed', 'A completed local plan cannot be replaced without a new setup.');
239
+ journal.plan = validatePrepared(await setupAbortable(runtime.prepare(session), heartbeat.signal));
240
+ journal.target_digest = targetDigest;
241
+ journal.plan_request_id = randomUUID();
242
+ await saveJournal(path, journal);
243
+ }
244
+ try {
245
+ session = await client.plan({ ...scope, request_id: journal.plan_request_id, expected_revision: session.revision, plan: journal.plan.wire });
246
+ }
247
+ catch (error) {
248
+ if (!(error instanceof SetupError) || error.code !== 'revision_conflict')
249
+ throw error;
250
+ session = await client.poll();
251
+ }
252
+ continue;
253
+ }
254
+ if (!journal.plan || !session.local_plan || setupDigest(session.local_plan) !== setupDigest(journal.plan.wire))
255
+ throw new SetupError('setup_plan_missing', 'The browser-approved local plan is unavailable on this computer. No effects were applied.');
256
+ if (session.phase !== 'applying') {
257
+ await heartbeat.pulse();
258
+ heartbeat.check();
259
+ session = heartbeat.latest(session);
260
+ await pause();
261
+ session = await client.poll();
262
+ continue;
263
+ }
264
+ const unknown = session.commands.find(command => command.status === 'outcome_unknown' || command.status === 'running' || command.status === 'leased' && journal.receipts[command.operation_id]);
265
+ if (unknown) {
266
+ const operation = journal.plan.operations.find(item => setupDigest(item.effect) === unknown.input_digest);
267
+ if (!operation)
268
+ throw new SetupError('setup_command_mismatch', 'The command does not match the local reviewed plan.');
269
+ const receipt = journal.receipts[unknown.operation_id];
270
+ // A running command belongs to an interrupted process. Wait for the server
271
+ // to close its lease; never issue the side effect a second time.
272
+ if (unknown.status !== 'outcome_unknown') {
273
+ await pause();
274
+ session = await client.poll();
275
+ continue;
276
+ }
277
+ const stillVerified = await verified(directory, operation, runtime, receipt);
278
+ const outcome = receipt?.outcome && receipt.outcome.status !== 'succeeded' ? receipt.outcome : stillVerified
279
+ ? { status: 'succeeded', result: { code: 'already_present', verified: true } }
280
+ : { status: 'manual', result: { code: receipt?.outcome?.status === 'succeeded' ? 'review_required' : 'outcome_unknown' } };
281
+ const requestId = receipt?.request_id ?? randomUUID();
282
+ journal.receipts[unknown.operation_id] = { input_digest: unknown.input_digest, stage: 'settled', outcome, diagnostic: receipt?.diagnostic, outputs: receipt?.outputs, request_id: requestId };
283
+ await saveJournal(path, journal);
284
+ session = await client.reconcile({ ...scope, request_id: requestId, project_fingerprint: client.identity.project_fingerprint, executor_generation: session.executor_generation, operation_id: unknown.operation_id, input_digest: unknown.input_digest, ...outcome });
285
+ continue;
286
+ }
287
+ const claimed = await client.claim({ ...scope, request_id: journal.claim_request_id, executor_generation: session.executor_generation });
288
+ session = claimed.session;
289
+ const command = claimed.command;
290
+ if (!command) {
291
+ await pause();
292
+ session = await client.poll();
293
+ continue;
294
+ }
295
+ if (command.status !== 'leased')
296
+ continue;
297
+ const operation = journal.plan.operations.find(item => item.effect.effect_id === command.input.effect_id);
298
+ if (!operation || setupDigest(operation.effect) !== command.input_digest || setupDigest(operation.effect) !== setupDigest(command.input))
299
+ throw new SetupError('setup_command_mismatch', 'The command differs from the approved local plan.');
300
+ const lease = { ...scope, operation_id: command.operation_id, executor_generation: command.executor_generation, lease_generation: command.lease_generation };
301
+ if (journal.receipts[command.operation_id])
302
+ throw new SetupError('setup_replay_refused', 'A local receipt already exists. Reconcile before attempting this command again.');
303
+ const receipt = { input_digest: command.input_digest, stage: 'claimed', request_id: randomUUID() };
304
+ journal.receipts[command.operation_id] = receipt;
305
+ await saveJournal(path, journal);
306
+ heartbeat.setLease(lease);
307
+ session = await client.startCommand(lease);
308
+ receipt.stage = 'running';
309
+ await saveJournal(path, journal);
310
+ const operationSignal = heartbeat.signal;
311
+ try {
312
+ operationSignal.throwIfAborted();
313
+ if (!await verifySetupFiles(directory, operation.files, 'before'))
314
+ throw new SetupError('setup_files_changed', 'Files changed after browser approval.');
315
+ for (const guard of operation.guards ?? []) {
316
+ const content = await readSetupFile(directory, guard.path);
317
+ if ((content === null ? null : setupHash(content)) !== guard.before_hash)
318
+ throw new SetupError('setup_files_changed', 'A package configuration changed after review.');
319
+ }
320
+ if (operation.effect.kind === 'install_packages')
321
+ for (const file of operation.effect.managed_outputs ?? []) {
322
+ const content = await readSetupFile(directory, file.path);
323
+ if ((content === null ? null : setupHash(content)) !== file.before_hash)
324
+ throw new SetupError('setup_files_changed', 'Package-manager outputs changed after approval.');
325
+ }
326
+ const code = await runtime.apply(operation, operationSignal);
327
+ operationSignal.throwIfAborted();
328
+ receipt.outputs = await outputs(directory, operation);
329
+ if (!await verified(directory, operation, runtime, receipt))
330
+ throw new SetupError('setup_verification_failed', 'The completed effect did not match the reviewed files.');
331
+ receipt.outcome = { status: 'succeeded', result: { code, verified: true } };
332
+ }
333
+ catch (error) {
334
+ receipt.outcome = { status: error instanceof SetupError && error.code === 'setup_files_changed' ? 'manual' : 'failed', result: { code: operationSignal.aborted ? 'outcome_unknown' : error instanceof SetupError && error.code === 'setup_files_changed' ? 'review_required' : 'operation_failed' } };
335
+ receipt.diagnostic = sanitizeSetupDiagnostic(error instanceof Error ? error.message : 'Local operation failed without an error message.');
336
+ }
337
+ receipt.stage = 'settled';
338
+ journal.claim_request_id = randomUUID();
339
+ await saveJournal(path, journal);
340
+ showError(command.operation_id, receipt);
341
+ // A lost report is retried from its saved receipt on resume; the effect is never rerun.
342
+ session = await heartbeat.duringReport(() => client.report({ ...lease, ...receipt.outcome }));
343
+ }
344
+ });
345
+ }
346
+ finally {
347
+ if (!sharedHeartbeat)
348
+ await heartbeat.stop();
349
+ }
350
+ }
@@ -0,0 +1,11 @@
1
+ export declare const setupHash: (value: string | Buffer) => string;
2
+ export declare function setupCanonical(value: unknown): string;
3
+ export declare const setupDigest: (value: unknown) => string;
4
+ export interface SetupLocalFile {
5
+ path: string;
6
+ before: string | null;
7
+ after: string;
8
+ }
9
+ export declare function readSetupFile(directory: string, path: string): Promise<string | null>;
10
+ export declare function writeSetupFile(directory: string, file: SetupLocalFile): Promise<void>;
11
+ export declare function verifySetupFiles(directory: string, files: SetupLocalFile[], state: 'before' | 'after'): Promise<boolean>;