@userreason/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +207 -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 +60 -0
  9. package/dist/first-survey-source.js +289 -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 +14 -0
  16. package/dist/index.js +14 -0
  17. package/dist/profile.js +1 -1
  18. package/dist/recommended-surveys.d.ts +30 -0
  19. package/dist/recommended-surveys.js +201 -0
  20. package/dist/setup-executor.d.ts +35 -0
  21. package/dist/setup-executor.js +329 -0
  22. package/dist/setup-files.d.ts +11 -0
  23. package/dist/setup-files.js +96 -0
  24. package/dist/setup-runner.d.ts +14 -0
  25. package/dist/setup-runner.js +142 -0
  26. package/dist/setup-source.d.ts +31 -0
  27. package/dist/setup-source.js +48 -0
  28. package/dist/setup-store.d.ts +37 -0
  29. package/dist/setup-store.js +256 -0
  30. package/dist/setup-terminal.d.ts +3 -0
  31. package/dist/setup-terminal.js +6 -0
  32. package/dist/setup-wire.d.ts +1237 -0
  33. package/dist/setup-wire.js +175 -0
  34. package/dist/setup.d.ts +1223 -0
  35. package/dist/setup.js +258 -0
  36. package/dist/starter-survey.d.ts +22 -0
  37. package/dist/starter-survey.js +457 -0
  38. package/dist/telemetry.d.ts +67 -0
  39. package/dist/telemetry.js +304 -0
  40. package/dist/workos.d.ts +1 -1
  41. package/dist/workos.js +4 -2
  42. package/package.json +3 -2
  43. package/skills/userreason/SKILL.md +43 -0
  44. package/skills/userreason/references/management.md +6 -0
@@ -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) {
8
+ signal?.throwIfAborted();
9
+ const preview = await collectFirstSurveySource({ root: directory, snapshotId: preparationId, recommendations: true, signal });
10
+ await verifyFirstSurveySource(directory, preview.evidence);
11
+ const evidence = preview.evidence, packages = preview.packages;
12
+ const prepared = {
13
+ preparation_id: preparationId, evidence, packages,
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) {
30
+ const prepared = await prepareSetupSource(directory, approved.preparation_id, signal);
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);
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
+ /** Explicit recovery only after a crashed process is proven gone; never steals a live lock. */
35
+ export declare function recoverSetupProjectLock(directory: string): Promise<boolean>;
36
+ /** Fail closed on an existing lock. A stale process requires reconciliation, not a timed takeover. */
37
+ export declare function withSetupProjectLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,256 @@
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
+ /** Explicit recovery 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', 'The project lock owner is still running.');
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
+ /** Fail closed on an existing lock. A stale process requires reconciliation, not a timed takeover. */
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_project_locked', 'Another setup owns this project. Reconcile its local results before retrying; an expired server lease does not release this lock.');
240
+ throw new SetupError('setup_lock_failed', 'Unable to lock this project for setup. Check local configuration-directory access.');
241
+ }
242
+ const lockInfo = await file.stat();
243
+ try {
244
+ await file.writeFile(JSON.stringify({ owner, pid: process.pid, created_at: new Date().toISOString() }));
245
+ await file.sync();
246
+ return await operation();
247
+ }
248
+ finally {
249
+ await file.close();
250
+ try {
251
+ if (JSON.parse(await readFile(path, 'utf8')).owner === owner)
252
+ await removeSameRecord(path, lockInfo);
253
+ }
254
+ catch { /* Never delete a replacement lock or mask the operation's result. */ }
255
+ }
256
+ }
@@ -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
+ }