@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,191 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { BrowserSetupUnavailable } from './setup.js';
4
+ import { runSetupExecutor } from './setup-executor.js';
5
+ import { prepareSetupSource, restoreSetupSource, verifySetupSource } from './setup-source.js';
6
+ import { SetupError, withSetupProjectLock } from './setup-store.js';
7
+ import { SetupHeartbeat, observeSetupClient, setupAbortable } from './setup-heartbeat.js';
8
+ const terminal = new Set(['complete', 'cancelled', 'expired']);
9
+ const localPhases = new Set(['configuring', 'awaiting_local_approval', 'applying']);
10
+ function pause(milliseconds, options) {
11
+ options.signal?.throwIfAborted();
12
+ if (options.sleep)
13
+ return options.sleep(milliseconds).then(() => options.signal?.throwIfAborted());
14
+ return new Promise((resolve, reject) => {
15
+ const timer = setTimeout(() => { options.signal?.removeEventListener('abort', abort); resolve(); }, milliseconds);
16
+ function abort() { clearTimeout(timer); reject(options.signal?.reason); }
17
+ options.signal?.addEventListener('abort', abort, { once: true });
18
+ });
19
+ }
20
+ /** Start repository analysis as soon as the authenticated setup is bound. It
21
+ * intentionally does not hold the installation lock while a provider works. */
22
+ async function prepareEarly(client, directory, options) {
23
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
24
+ try {
25
+ let session = await client.poll();
26
+ if (session.source || !(await client.discovery()).browser_setup.source_analysis)
27
+ return;
28
+ const packet = options.preparedSource ? await setupAbortable(options.preparedSource, options.signal) ?? await prepareSetupSource(directory, randomUUID(), options.signal, true) : await prepareSetupSource(directory, randomUUID(), options.signal, true);
29
+ let conflicts = 0;
30
+ while (true) {
31
+ session = await client.poll();
32
+ if (session.source || terminal.has(session.phase))
33
+ return;
34
+ try {
35
+ session = await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision, manifest: packet.manifest, packages: packet.packages });
36
+ break;
37
+ }
38
+ catch (error) {
39
+ if (!(error instanceof SetupError) || error.code !== 'revision_conflict' || ++conflicts >= 5)
40
+ throw error;
41
+ }
42
+ }
43
+ if (session.source?.status !== 'approved' || session.source.preparation_id !== packet.preparation_id)
44
+ return;
45
+ await verifySetupSource(directory, packet, options.signal);
46
+ options.onSession?.(session);
47
+ await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id, project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence });
48
+ }
49
+ catch (error) {
50
+ options.signal?.throwIfAborted();
51
+ options.onSourceIssue?.(error instanceof SetupError ? error.code : 'source_unavailable');
52
+ }
53
+ }
54
+ /** Browser controls consent and drafts. This runner holds only the setup capability,
55
+ * executes reviewed local effects, and uploads the separately approved source packet. */
56
+ export async function runBrowserSetup(client, directory, runtime, options = {}) {
57
+ // Canonicalize platform parent aliases before the executor verifies the
58
+ // approved fingerprint. Source descendants still reject every symlink.
59
+ directory = await realpath(directory);
60
+ const lifetime = new AbortController();
61
+ const runSignal = AbortSignal.any([lifetime.signal, ...(options.signal ? [options.signal] : [])]);
62
+ const base = client.withSignal?.(runSignal) ?? client;
63
+ const heartbeat = new SetupHeartbeat(base, { ...options, signal: runSignal });
64
+ client = observeSetupClient(base.withSignal?.(heartbeat.signal) ?? base, heartbeat, heartbeat.signal);
65
+ const suppliedSignal = options.signal;
66
+ options = { ...options, signal: heartbeat.signal };
67
+ let polls = 0;
68
+ const early = options.automaticSetup ? prepareEarly(client, directory, options) : Promise.resolve();
69
+ void early.catch(() => { }); // Cleanup awaits it; never leave an unhandled background rejection.
70
+ try {
71
+ while (true) {
72
+ let session;
73
+ session = await runSetupExecutor(client, directory, runtime, options, heartbeat);
74
+ await early;
75
+ if (options.automaticSetup)
76
+ session = await client.poll();
77
+ if (terminal.has(session.phase) || localPhases.has(session.phase))
78
+ return session;
79
+ const result = await withSetupProjectLock(directory, async () => {
80
+ let packet, observedPreparation;
81
+ let preparationAttempted = false, failedPreparationRevision, notifiedSample = false;
82
+ const uploadAttempts = new Set(), issues = new Set();
83
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
84
+ const accept = (next) => { if (BigInt(next.revision) >= BigInt(session.revision))
85
+ session = next; };
86
+ const issue = (code) => { if (!issues.has(code)) {
87
+ issues.add(code);
88
+ options.onSourceIssue?.(code);
89
+ } };
90
+ let sourceAvailable = false;
91
+ try {
92
+ sourceAvailable = (await client.discovery()).browser_setup.source_analysis && session.capabilities.source_review;
93
+ }
94
+ catch (error) {
95
+ if (!(error instanceof BrowserSetupUnavailable))
96
+ throw error;
97
+ }
98
+ try {
99
+ while (true) {
100
+ heartbeat.check();
101
+ session = heartbeat.latest(session);
102
+ options.onSession?.(session);
103
+ if (terminal.has(session.phase) || localPhases.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls)
104
+ return session;
105
+ if (session.phase === 'agent_test' && session.sample.status === 'waiting' && !notifiedSample) {
106
+ notifiedSample = true;
107
+ options.onSample?.(session);
108
+ }
109
+ const source = session.source;
110
+ // A browser retry with no preparation (for example provider config
111
+ // unavailable) changes revision. Heartbeat never changes revision.
112
+ if (!source && failedPreparationRevision && session.revision !== failedPreparationRevision) {
113
+ preparationAttempted = false;
114
+ failedPreparationRevision = undefined;
115
+ issues.clear();
116
+ }
117
+ if (source?.preparation_id !== observedPreparation) {
118
+ // Retry is a new browser decision, never an automatic second spend.
119
+ if (observedPreparation && !source)
120
+ preparationAttempted = false;
121
+ packet = undefined;
122
+ observedPreparation = source?.preparation_id;
123
+ issues.clear();
124
+ }
125
+ if (session.phase === 'ready_for_recommendations' && !source && sourceAvailable && !preparationAttempted) {
126
+ preparationAttempted = true;
127
+ try {
128
+ packet = await prepareSetupSource(directory, randomUUID(), options.signal, options.automaticSetup);
129
+ options.onSourcePrepared?.(packet);
130
+ accept(await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision,
131
+ manifest: packet.manifest, packages: packet.packages }));
132
+ observedPreparation = session.source?.preparation_id;
133
+ }
134
+ catch (error) {
135
+ options.signal?.throwIfAborted();
136
+ failedPreparationRevision = session.revision;
137
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
138
+ }
139
+ }
140
+ else if (source?.status === 'approved' && sourceAvailable && !uploadAttempts.has(source.preparation_id)) {
141
+ uploadAttempts.add(source.preparation_id);
142
+ try {
143
+ const info = await client.sourceInfo();
144
+ accept(info.session);
145
+ const approved = info.preparation, current = session.source;
146
+ if (!approved || current?.status !== 'approved' || approved.preparation_id !== source.preparation_id ||
147
+ approved.manifest.packet_digest !== current.packet_digest || approved.configuration.configuration_fingerprint !== current.provider_fingerprint)
148
+ throw new SetupError('setup_source_changed', 'Review a new source packet in the browser.');
149
+ if (Date.parse(current.expires_at) <= Date.now())
150
+ throw new SetupError('planning_consent_expired', 'Review source consent again in the browser.');
151
+ // Resume never trusts a stale in-memory packet or stores raw source.
152
+ packet = packet?.preparation_id === approved.preparation_id ? packet : await restoreSetupSource(directory, approved, options.signal, options.automaticSetup);
153
+ options.onSourcePrepared?.(packet);
154
+ await verifySetupSource(directory, packet, options.signal);
155
+ heartbeat.check();
156
+ if (terminal.has(session.phase) || session.source?.preparation_id !== approved.preparation_id || session.source.status !== 'approved')
157
+ throw new SetupError('setup_source_changed', 'Source approval changed before upload.');
158
+ accept(await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id,
159
+ project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence }));
160
+ packet = undefined;
161
+ }
162
+ catch (error) {
163
+ options.signal?.throwIfAborted();
164
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
165
+ // A dropped reply can hide an accepted upload. Poll instead of
166
+ // resending or collecting a different packet under old consent.
167
+ }
168
+ }
169
+ await pause(options.pollMs ?? 2_000, options);
170
+ accept(await client.poll());
171
+ }
172
+ }
173
+ finally {
174
+ packet = undefined;
175
+ }
176
+ });
177
+ if (!localPhases.has(result.phase) || options.maxPolls !== undefined)
178
+ return result;
179
+ }
180
+ }
181
+ catch (error) {
182
+ const current = heartbeat.current;
183
+ if (!suppliedSignal?.aborted && current && terminal.has(current.phase))
184
+ return current;
185
+ throw error;
186
+ }
187
+ finally {
188
+ lifetime.abort(new Error('Setup run ended'));
189
+ await Promise.allSettled([early, heartbeat.stop()]);
190
+ }
191
+ }
@@ -0,0 +1,32 @@
1
+ import { type LocalEvidence } from './first-survey-source.js';
2
+ /** One memory-only packet. The wire manifest contains hashes/ranges, never source text. */
3
+ export interface PreparedSetupSource {
4
+ expanded?: boolean;
5
+ preparation_id: string;
6
+ manifest: {
7
+ packet_digest: string;
8
+ snapshot_id: string;
9
+ files: {
10
+ path: string;
11
+ file_hash: string;
12
+ excerpt_hash: string;
13
+ bytes: number;
14
+ start_line: number;
15
+ end_line: number;
16
+ }[];
17
+ omitted_files: number;
18
+ truncated: boolean;
19
+ git_included: false;
20
+ };
21
+ packages: {
22
+ name: string;
23
+ version: string;
24
+ }[];
25
+ evidence: LocalEvidence[];
26
+ }
27
+ /** Reuse the bounded application collector without Git, provider calls or local packet persistence. */
28
+ export declare function prepareSetupSource(directory: string, preparationId?: string, signal?: AbortSignal, expanded?: boolean): Promise<PreparedSetupSource>;
29
+ /** An approved preparation is rebuilt with its original snapshot ID; no raw source needs a receipt. */
30
+ export declare function restoreSetupSource(directory: string, approved: Pick<PreparedSetupSource, 'preparation_id' | 'manifest' | 'packages'>, signal?: AbortSignal, expanded?: boolean): Promise<PreparedSetupSource>;
31
+ /** Revalidate immediately before upload; only the immutable reviewed memory packet may leave the process. */
32
+ export declare function verifySetupSource(directory: string, packet: PreparedSetupSource, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { collectFirstSurveySource, verifyFirstSurveySource } from './first-survey-source.js';
3
+ import { setupDigest } from './setup-files.js';
4
+ import { SetupError } from './setup-store.js';
5
+ import { parseSetupSourceManifest, parseSetupSourceUploadInput } from './setup-wire.js';
6
+ /** Reuse the bounded application collector without Git, provider calls or local packet persistence. */
7
+ export async function prepareSetupSource(directory, preparationId = randomUUID(), signal, expanded = false) {
8
+ signal?.throwIfAborted();
9
+ const preview = await collectFirstSurveySource({ root: directory, snapshotId: preparationId, recommendations: true, benchmark: expanded, signal });
10
+ await verifyFirstSurveySource(directory, preview.evidence);
11
+ const evidence = preview.evidence, packages = preview.packages;
12
+ const prepared = {
13
+ preparation_id: preparationId, evidence, packages, expanded,
14
+ manifest: {
15
+ packet_digest: setupDigest({ evidence, packages }), snapshot_id: preparationId,
16
+ files: evidence.map(item => ({ path: item.path, file_hash: item.file_hash, excerpt_hash: item.excerpt_hash,
17
+ bytes: Buffer.byteLength(item.excerpt), start_line: item.start_line, end_line: item.end_line })),
18
+ omitted_files: preview.omitted.length, truncated: preview.truncated, git_included: false,
19
+ },
20
+ };
21
+ // The shared public decoder enforces the actual UTF-8 wire budget, including
22
+ // JSON escaping; the collector's estimate is deliberately not authorization.
23
+ parseSetupSourceManifest(prepared.manifest);
24
+ parseSetupSourceUploadInput({ schema_version: 1, setup_id: preparationId, preparation_id: preparationId,
25
+ project_fingerprint: '0'.repeat(64), evidence });
26
+ return prepared;
27
+ }
28
+ /** An approved preparation is rebuilt with its original snapshot ID; no raw source needs a receipt. */
29
+ export async function restoreSetupSource(directory, approved, signal, expanded) {
30
+ const prepared = await prepareSetupSource(directory, approved.preparation_id, signal, expanded ?? approved.manifest.files.reduce((n, file) => n + file.bytes, 0) > 10000);
31
+ if (setupDigest(prepared.manifest) !== setupDigest(approved.manifest) || setupDigest(prepared.packages) !== setupDigest(approved.packages))
32
+ throw new SetupError('setup_source_changed', 'Application source or installed package metadata changed. Review a new source packet in the browser before analysis.');
33
+ return prepared;
34
+ }
35
+ /** Revalidate immediately before upload; only the immutable reviewed memory packet may leave the process. */
36
+ export async function verifySetupSource(directory, packet, signal) {
37
+ signal?.throwIfAborted();
38
+ if (packet.preparation_id !== packet.manifest.snapshot_id || setupDigest({ evidence: packet.evidence, packages: packet.packages }) !== packet.manifest.packet_digest)
39
+ throw new SetupError('setup_source_changed', 'The prepared source packet changed. Review a new packet before analysis.');
40
+ try {
41
+ await verifyFirstSurveySource(directory, packet.evidence);
42
+ }
43
+ catch {
44
+ throw new SetupError('setup_source_changed', 'Application files changed. Review a new source packet before analysis.');
45
+ }
46
+ await restoreSetupSource(directory, packet, signal, packet.expanded);
47
+ signal?.throwIfAborted();
48
+ }
@@ -0,0 +1,37 @@
1
+ export declare class SetupError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string, message: string);
4
+ }
5
+ export type StoredSetup = {
6
+ schema_version: 1;
7
+ setup_id: string;
8
+ api_url: string;
9
+ project_fingerprint: string;
10
+ created_at: string;
11
+ expires_at: string;
12
+ } & ({
13
+ state: 'pending';
14
+ ticket: string;
15
+ verifier: string;
16
+ } | {
17
+ state: 'connected';
18
+ access_token: string;
19
+ });
20
+ /** A root identity, not uploaded source or a pathname in the remote setup record. */
21
+ export declare function setupProjectFingerprint(directory: string): Promise<string>;
22
+ export declare function parseStoredSetup(value: unknown): StoredSetup;
23
+ /** Lazy, bounded retention cleanup; unrelated files, links and locks are left alone. */
24
+ export declare function pruneSetupSessions(now?: number): Promise<{
25
+ scanned: number;
26
+ removed: number;
27
+ }>;
28
+ /** Setup capabilities never enter the general CLI credential store or project source. */
29
+ export declare function saveSetupSession(value: StoredSetup): Promise<void>;
30
+ export declare function loadSetupSession(origin: string, setupId: string, directory: string, now?: number): Promise<StoredSetup>;
31
+ export declare function clearSetupSession(origin: string, setupId: string): Promise<void>;
32
+ /** Create one private level at a time; never traverse a replaced setup ancestor. */
33
+ export declare function setupPrivateDirectory(...parts: string[]): Promise<string>;
34
+ /** Recover only after a crashed process is proven gone; never steals a live lock. */
35
+ export declare function recoverSetupProjectLock(directory: string): Promise<boolean>;
36
+ /** Recover a proven-dead owner, then acquire exclusively. Live/unknown owners stay protected. */
37
+ export declare function withSetupProjectLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,266 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, opendir, readFile, realpath, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { configDirectory } from './config.js';
6
+ import { apiUrl, object, uuid } from './contracts.js';
7
+ export class SetupError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ }
13
+ }
14
+ /** A root identity, not uploaded source or a pathname in the remote setup record. */
15
+ export async function setupProjectFingerprint(directory) {
16
+ return createHash('sha256').update(await realpath(directory)).digest('hex');
17
+ }
18
+ export function parseStoredSetup(value) {
19
+ const data = object(value, 'stored setup');
20
+ const common = ['schema_version', 'setup_id', 'api_url', 'project_fingerprint', 'created_at', 'expires_at', 'state'];
21
+ const fields = data.state === 'pending' ? ['ticket', 'verifier'] : data.state === 'connected' ? ['access_token'] : [];
22
+ if (!fields.length || Object.keys(data).some(key => ![...common, ...fields].includes(key)) ||
23
+ [...common, ...fields].some(key => !(key in data)) || data.schema_version !== 1 ||
24
+ typeof data.api_url !== 'string' || apiUrl(data.api_url) !== data.api_url ||
25
+ typeof data.setup_id !== 'string' || uuid(data.setup_id) !== data.setup_id ||
26
+ typeof data.project_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(data.project_fingerprint) ||
27
+ typeof data.created_at !== 'string' || typeof data.expires_at !== 'string' ||
28
+ !Number.isFinite(Date.parse(data.created_at)) || !Number.isFinite(Date.parse(data.expires_at)) ||
29
+ Date.parse(data.expires_at) <= Date.parse(data.created_at) ||
30
+ Date.parse(data.expires_at) - Date.parse(data.created_at) > (data.state === 'pending' ? 901_000 : 7_201_000) ||
31
+ fields.some(key => typeof data[key] !== 'string' || !data[key].length || data[key].length > 16_384) ||
32
+ (data.state === 'pending' && !/^[a-f0-9]{64}$/.test(data.verifier)) ||
33
+ (data.state === 'connected' && !/^ur-setup\.v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(data.access_token))) {
34
+ throw new SetupError('invalid_setup_session', 'Invalid local setup session. Start a new setup; ordinary CLI credentials are unchanged.');
35
+ }
36
+ return data;
37
+ }
38
+ function storePath(origin, setupId) {
39
+ const key = createHash('sha256').update(`${apiUrl(origin)}\n${uuid(setupId)}`).digest('hex');
40
+ return join(configDirectory(), 'setup-v1', `${key}.json`);
41
+ }
42
+ const retentionMs = 7 * 24 * 60 * 60 * 1000;
43
+ const pruneBatch = 128;
44
+ /** Read through one bounded file handle, never through a symbolic link. */
45
+ async function readSetupRecord(path) {
46
+ const before = await lstat(path);
47
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 65_536)
48
+ throw new Error('invalid setup file');
49
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
50
+ try {
51
+ const info = await file.stat();
52
+ if (!info.isFile() || info.ino !== before.ino || info.dev !== before.dev || info.size > 65_536)
53
+ throw new Error('changed setup file');
54
+ const buffer = Buffer.alloc(65_537);
55
+ let size = 0;
56
+ while (size < buffer.length) {
57
+ const part = await file.read(buffer, size, buffer.length - size, size);
58
+ if (!part.bytesRead)
59
+ break;
60
+ size += part.bytesRead;
61
+ }
62
+ if (size > 65_536)
63
+ throw new Error('oversized setup file');
64
+ return { data: parseStoredSetup(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)))), info };
65
+ }
66
+ finally {
67
+ await file.close();
68
+ }
69
+ }
70
+ async function removeSameRecord(path, info) {
71
+ try {
72
+ const current = await lstat(path);
73
+ if (!current.isFile() || current.isSymbolicLink() || current.ino !== info.ino || current.dev !== info.dev)
74
+ return false;
75
+ await rm(path);
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /** Lazy, bounded retention cleanup; unrelated files, links and locks are left alone. */
83
+ export async function pruneSetupSessions(now = Date.now()) {
84
+ if (!Number.isFinite(now))
85
+ return { scanned: 0, removed: 0 };
86
+ let directory;
87
+ try {
88
+ const path = join(configDirectory(), 'setup-v1'), info = await lstat(path);
89
+ if (!info.isDirectory() || info.isSymbolicLink())
90
+ return { scanned: 0, removed: 0 };
91
+ directory = await opendir(path);
92
+ }
93
+ catch {
94
+ return { scanned: 0, removed: 0 };
95
+ }
96
+ let scanned = 0, removed = 0;
97
+ try {
98
+ for await (const entry of directory) {
99
+ if (++scanned > pruneBatch) {
100
+ scanned = pruneBatch;
101
+ break;
102
+ }
103
+ if (!entry.isFile() || !/^[a-f0-9]{64}\.json$/.test(entry.name))
104
+ continue;
105
+ const path = join(configDirectory(), 'setup-v1', entry.name);
106
+ try {
107
+ const { data, info } = await readSetupRecord(path);
108
+ if (storePath(data.api_url, data.setup_id) !== path || Date.parse(data.created_at) > now - retentionMs)
109
+ continue;
110
+ if (await removeSameRecord(path, info))
111
+ removed++;
112
+ }
113
+ catch { /* Unknown/corrupt files are not ours to erase. */ }
114
+ }
115
+ }
116
+ catch { /* Cleanup is best effort and never prevents a new setup. */ }
117
+ return { scanned, removed };
118
+ }
119
+ /** Setup capabilities never enter the general CLI credential store or project source. */
120
+ export async function saveSetupSession(value) {
121
+ const data = parseStoredSetup(value), path = storePath(data.api_url, data.setup_id);
122
+ const temporary = `${path}.${randomUUID()}.tmp`;
123
+ try {
124
+ const directory = join(configDirectory(), 'setup-v1');
125
+ await mkdir(directory, { recursive: true, mode: 0o700 });
126
+ const info = await lstat(directory);
127
+ if (!info.isDirectory() || info.isSymbolicLink())
128
+ throw new SetupError('setup_store_failed', 'The setup store must be a regular private directory, not a symbolic link.');
129
+ await pruneSetupSessions();
130
+ const file = await open(temporary, 'wx', 0o600);
131
+ try {
132
+ await file.writeFile(JSON.stringify(data) + '\n');
133
+ await file.sync();
134
+ }
135
+ finally {
136
+ await file.close();
137
+ }
138
+ await rename(temporary, path);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof SetupError)
142
+ throw error;
143
+ throw new SetupError('setup_store_failed', 'Unable to save the private setup session outside the project. Check local configuration-directory access.');
144
+ }
145
+ finally {
146
+ await rm(temporary, { force: true }).catch(() => { });
147
+ }
148
+ }
149
+ export async function loadSetupSession(origin, setupId, directory, now = Date.now()) {
150
+ let data;
151
+ let info;
152
+ const path = storePath(origin, setupId);
153
+ try {
154
+ const parent = await lstat(join(configDirectory(), 'setup-v1'));
155
+ if (!parent.isDirectory() || parent.isSymbolicLink())
156
+ throw new Error('invalid setup directory');
157
+ ({ data, info } = await readSetupRecord(path));
158
+ }
159
+ catch (error) {
160
+ if (error instanceof SetupError)
161
+ throw error;
162
+ throw new SetupError('setup_session_unavailable', 'No valid local setup session for this API and ID. Resume from the original project or start a new setup.');
163
+ }
164
+ if (data.api_url !== apiUrl(origin) || data.setup_id !== setupId || data.project_fingerprint !== await setupProjectFingerprint(directory))
165
+ throw new SetupError('setup_binding_mismatch', 'The setup session belongs to another API or project. Use the original project and API.');
166
+ if (Date.parse(data.expires_at) <= now) {
167
+ await removeSameRecord(path, info);
168
+ throw new SetupError('setup_expired', 'This setup session expired. Start a new setup; saved drafts and completed local work are retained.');
169
+ }
170
+ return data;
171
+ }
172
+ export async function clearSetupSession(origin, setupId) {
173
+ await rm(storePath(origin, setupId), { force: true });
174
+ }
175
+ /** Create one private level at a time; never traverse a replaced setup ancestor. */
176
+ export async function setupPrivateDirectory(...parts) {
177
+ let path = configDirectory();
178
+ await mkdir(path, { recursive: true, mode: 0o700 });
179
+ for (const part of ['', 'setup-v1', ...parts]) {
180
+ if (part) {
181
+ path = join(path, part);
182
+ await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
183
+ throw error; });
184
+ }
185
+ const info = await lstat(path);
186
+ if (!info.isDirectory() || info.isSymbolicLink())
187
+ throw new SetupError('setup_store_failed', 'A private setup directory was replaced by a link.');
188
+ }
189
+ return path;
190
+ }
191
+ /** Recover only after a crashed process is proven gone; never steals a live lock. */
192
+ export async function recoverSetupProjectLock(directory) {
193
+ const locks = await setupPrivateDirectory('project-locks');
194
+ const path = join(locks, `${await setupProjectFingerprint(directory)}.lock`);
195
+ let info;
196
+ try {
197
+ info = await lstat(path);
198
+ }
199
+ catch (error) {
200
+ if (error.code === 'ENOENT')
201
+ return false;
202
+ throw error;
203
+ }
204
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 1024)
205
+ throw new SetupError('setup_lock_failed', 'The project lock needs manual review.');
206
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
207
+ try {
208
+ const now = await file.stat();
209
+ if (now.ino !== info.ino || now.dev !== info.dev)
210
+ throw new Error('changed lock');
211
+ const value = JSON.parse(await file.readFile('utf8'));
212
+ if (!Number.isInteger(value.pid) || value.pid < 1 || typeof value.owner !== 'string')
213
+ throw new Error('invalid lock');
214
+ try {
215
+ process.kill(value.pid, 0);
216
+ throw new SetupError('setup_project_locked', `Another wizard is still running for this project (PID ${value.pid}). Return to its terminal and finish or stop that run before retrying.`);
217
+ }
218
+ catch (error) {
219
+ if (error.code !== 'ESRCH')
220
+ throw error;
221
+ }
222
+ return removeSameRecord(path, info);
223
+ }
224
+ finally {
225
+ await file.close();
226
+ }
227
+ }
228
+ /** Recover a proven-dead owner, then acquire exclusively. Live/unknown owners stay protected. */
229
+ export async function withSetupProjectLock(directory, operation) {
230
+ const fingerprint = await setupProjectFingerprint(directory);
231
+ const locks = await setupPrivateDirectory('project-locks');
232
+ const path = join(locks, `${fingerprint}.lock`), owner = randomUUID();
233
+ let file;
234
+ try {
235
+ file = await open(path, 'wx', 0o600);
236
+ }
237
+ catch (error) {
238
+ if (error.code !== 'EEXIST')
239
+ throw new SetupError('setup_lock_failed', 'Unable to lock this project for setup. Check local configuration-directory access.');
240
+ try {
241
+ await recoverSetupProjectLock(directory);
242
+ file = await open(path, 'wx', 0o600);
243
+ }
244
+ catch (recoveryError) {
245
+ if (recoveryError instanceof SetupError)
246
+ throw recoveryError;
247
+ if (recoveryError.code === 'EEXIST')
248
+ throw new SetupError('setup_project_locked', 'Another wizard acquired this project. Return to its terminal and finish or stop that run before retrying.');
249
+ throw new SetupError('setup_lock_failed', 'The existing project lock could not be verified safely. No lock or installation receipt was removed.');
250
+ }
251
+ }
252
+ const lockInfo = await file.stat();
253
+ try {
254
+ await file.writeFile(JSON.stringify({ owner, pid: process.pid, created_at: new Date().toISOString() }));
255
+ await file.sync();
256
+ return await operation();
257
+ }
258
+ finally {
259
+ await file.close();
260
+ try {
261
+ if (JSON.parse(await readFile(path, 'utf8')).owner === owner)
262
+ await removeSameRecord(path, lockInfo);
263
+ }
264
+ catch { /* Never delete a replacement lock or mask the operation's result. */ }
265
+ }
266
+ }
@@ -0,0 +1,3 @@
1
+ import type { SetupCapabilities } from './setup-wire.js';
2
+ /** Fixed labels only. No process names, environment values, paths or shell are uploaded. */
3
+ export declare function detectSetupTerminal(platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv): NonNullable<SetupCapabilities['terminal']>;
@@ -0,0 +1,6 @@
1
+ /** Fixed labels only. No process names, environment values, paths or shell are uploaded. */
2
+ export function detectSetupTerminal(platform = process.platform, environment = process.env) {
3
+ const detectedPlatform = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : platform === 'linux' ? 'linux' : 'other';
4
+ const known = { Apple_Terminal: 'apple_terminal', iTerm: 'iterm2', 'iTerm.app': 'iterm2', vscode: 'vscode', cursor: 'cursor', WarpTerminal: 'warp', ghostty: 'ghostty', WezTerm: 'wezterm', Alacritty: 'alacritty', kitty: 'kitty' };
5
+ return { platform: detectedPlatform, app: known[environment.TERM_PROGRAM ?? ''] ?? (platform === 'win32' && environment.WT_SESSION ? 'windows_terminal' : 'generic') };
6
+ }