@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.
- package/README.md +207 -0
- package/dist/bin.js +93 -8
- package/dist/catalog.js +29 -3
- package/dist/client.d.ts +8 -2
- package/dist/client.js +26 -11
- package/dist/first-survey-preparation.d.ts +8 -0
- package/dist/first-survey-preparation.js +29 -0
- package/dist/first-survey-source.d.ts +60 -0
- package/dist/first-survey-source.js +289 -0
- package/dist/first-survey-terminal.d.ts +5 -0
- package/dist/first-survey-terminal.js +16 -0
- package/dist/first-survey.d.ts +88 -0
- package/dist/first-survey.js +227 -0
- package/dist/help.js +6 -6
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/profile.js +1 -1
- package/dist/recommended-surveys.d.ts +30 -0
- package/dist/recommended-surveys.js +201 -0
- package/dist/setup-executor.d.ts +35 -0
- package/dist/setup-executor.js +329 -0
- package/dist/setup-files.d.ts +11 -0
- package/dist/setup-files.js +96 -0
- package/dist/setup-runner.d.ts +14 -0
- package/dist/setup-runner.js +142 -0
- package/dist/setup-source.d.ts +31 -0
- package/dist/setup-source.js +48 -0
- package/dist/setup-store.d.ts +37 -0
- package/dist/setup-store.js +256 -0
- package/dist/setup-terminal.d.ts +3 -0
- package/dist/setup-terminal.js +6 -0
- package/dist/setup-wire.d.ts +1237 -0
- package/dist/setup-wire.js +175 -0
- package/dist/setup.d.ts +1223 -0
- package/dist/setup.js +258 -0
- package/dist/starter-survey.d.ts +22 -0
- package/dist/starter-survey.js +457 -0
- package/dist/telemetry.d.ts +67 -0
- package/dist/telemetry.js +304 -0
- package/dist/workos.d.ts +1 -1
- package/dist/workos.js +4 -2
- package/package.json +3 -2
- package/skills/userreason/SKILL.md +43 -0
- package/skills/userreason/references/management.md +6 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { collectFirstSurveySource, verifyFirstSurveySource, } from './first-survey-source.js';
|
|
2
|
+
/** Local, memory-only work. No provider, account lookup, Git history or upload. */
|
|
3
|
+
export function prepareFirstSurveyLocally(root) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
// Observe failures immediately even if the user never opens the survey step.
|
|
6
|
+
const prepared = collectFirstSurveySource({ root, signal: controller.signal, recommendations: true }).then((preview) => ({ preview }), () => ({ preview: undefined }));
|
|
7
|
+
return {
|
|
8
|
+
root,
|
|
9
|
+
async take(signal) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const cached = await prepared;
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
if (cached.preview) {
|
|
14
|
+
try {
|
|
15
|
+
// SDK installation or an editor may have changed files since sign-in.
|
|
16
|
+
await verifyFirstSurveySource(root, cached.preview.evidence);
|
|
17
|
+
return cached.preview;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return collectFirstSurveySource({ root, signal, recommendations: true });
|
|
24
|
+
},
|
|
25
|
+
cancel() {
|
|
26
|
+
controller.abort();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare const sourceHash: (value: string) => string;
|
|
2
|
+
export interface LocalEvidence {
|
|
3
|
+
kind: 'local_source';
|
|
4
|
+
id: string;
|
|
5
|
+
path: string;
|
|
6
|
+
file_hash: string;
|
|
7
|
+
excerpt_hash: string;
|
|
8
|
+
snapshot_id: string;
|
|
9
|
+
start_line: number;
|
|
10
|
+
end_line: number;
|
|
11
|
+
excerpt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SourcePreview {
|
|
14
|
+
root: string;
|
|
15
|
+
snapshot_id: string;
|
|
16
|
+
evidence: LocalEvidence[];
|
|
17
|
+
omitted: {
|
|
18
|
+
path: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}[];
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
inspected: number;
|
|
23
|
+
packet_digest: string;
|
|
24
|
+
packages: {
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
}[];
|
|
28
|
+
capabilities: string[];
|
|
29
|
+
capability_status: "declared" | "unknown";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An explicit canonical absolute root is required even when cwd is an application.
|
|
33
|
+
* Callers creating temporary directories must resolve platform parent aliases
|
|
34
|
+
* with realpath first (e.g. macOS /var -> /private/var). Within the approved root,
|
|
35
|
+
* symlinks are never followed; verification rejects subsequent symlink changes.
|
|
36
|
+
*/
|
|
37
|
+
export declare function collectFirstSurveySource(input: {
|
|
38
|
+
root: string;
|
|
39
|
+
selected?: string[];
|
|
40
|
+
maxFiles?: number;
|
|
41
|
+
maxBytes?: number;
|
|
42
|
+
maxPacketBytes?: number;
|
|
43
|
+
snapshotId?: string;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
recommendations?: boolean;
|
|
46
|
+
}): Promise<SourcePreview>;
|
|
47
|
+
/** Recheck every approved full-file hash before upload, edits or draft mutations. */
|
|
48
|
+
export declare function verifyFirstSurveySource(root: string, sources: Pick<LocalEvidence, 'path' | 'file_hash'>[]): Promise<void>;
|
|
49
|
+
export interface LocalHistory {
|
|
50
|
+
kind: 'local_history';
|
|
51
|
+
id: string;
|
|
52
|
+
commit: string;
|
|
53
|
+
date: string;
|
|
54
|
+
summary: string;
|
|
55
|
+
}
|
|
56
|
+
/** Separate opt-in; no fetch, diff, author identity, or deployment inference. */
|
|
57
|
+
export declare function collectFirstSurveyHistory(root: string, consent: boolean): Promise<{
|
|
58
|
+
evidence: LocalHistory[];
|
|
59
|
+
warning?: string;
|
|
60
|
+
}>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/** Node-only, bounded source inspection. Never imported by the browser SDK. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat, realpath, readdir, open, readFile } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import ignore from 'ignore';
|
|
7
|
+
export const sourceHash = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const forbidden = /(^|\/)(?:\.git|\.env(?:\..*)?|node_modules|vendor|dist|build|coverage|\.next|\.nuxt|\.cache|\.output|public|uploads?|datasets?|fixtures?|__fixtures__|backups?|credentials?|secrets?)(\/|$)|(?:\.min\.[cm]?js|\.map|\.lock|\.(?:pem|key|p12|pfx|sqlite|db|csv|parquet|log|snap))$|(^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.npmrc|\.netrc|id_rsa|id_ed25519)$/i;
|
|
9
|
+
const sourceFile = /(?:\.(?:[cm]?[jt]sx?|vue|svelte|json|md))$/i;
|
|
10
|
+
const secret = /-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16})\b|(?:api[_-]?key|secret|password|access[_-]?token|authorization)\s*[:=]\s*["'`][^"'`\n]{8,}["'`]/i;
|
|
11
|
+
function inside(root, file) {
|
|
12
|
+
const path = relative(root, file);
|
|
13
|
+
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
14
|
+
}
|
|
15
|
+
function safeLimits(value, fallback, max) {
|
|
16
|
+
const n = value ?? fallback;
|
|
17
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > max)
|
|
18
|
+
throw new Error('Invalid source budget');
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An explicit canonical absolute root is required even when cwd is an application.
|
|
23
|
+
* Callers creating temporary directories must resolve platform parent aliases
|
|
24
|
+
* with realpath first (e.g. macOS /var -> /private/var). Within the approved root,
|
|
25
|
+
* symlinks are never followed; verification rejects subsequent symlink changes.
|
|
26
|
+
*/
|
|
27
|
+
export async function collectFirstSurveySource(input) {
|
|
28
|
+
if (!isAbsolute(input.root) || resolve(input.root) !== input.root)
|
|
29
|
+
throw new Error('Use an explicit canonical absolute application root');
|
|
30
|
+
const root = await realpath(input.root);
|
|
31
|
+
if (root !== input.root || !(await lstat(root)).isDirectory())
|
|
32
|
+
throw new Error('Application root must be a canonical directory, not a symlink');
|
|
33
|
+
const maxFiles = safeLimits(input.maxFiles, 200, 200);
|
|
34
|
+
const maxBytes = safeLimits(input.maxBytes, input.recommendations ? 2 * 1024 * 1024 : 500 * 1024, input.recommendations ? 2 * 1024 * 1024 : 500 * 1024);
|
|
35
|
+
// Conservative UTF-8 byte budget: at most 12,000 tokens even for non-English source.
|
|
36
|
+
const maxPacketBytes = safeLimits(input.maxPacketBytes, 10000, 10000);
|
|
37
|
+
const snapshot_id = input.snapshotId ?? randomUUID();
|
|
38
|
+
const omitted = [], evidence = [];
|
|
39
|
+
let inspected = 0, bytes = 0, packetBytes = 0, truncated = false, entries = 0;
|
|
40
|
+
const candidates = [];
|
|
41
|
+
const rootIgnore = ignore();
|
|
42
|
+
for (const name of ['.gitignore', '.ignore', '.userreasonignore']) {
|
|
43
|
+
try {
|
|
44
|
+
const file = resolve(root, name), stat = await lstat(file);
|
|
45
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 16384)
|
|
46
|
+
rootIgnore.add(await readFile(file, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== 'ENOENT')
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const selected = input.selected?.map(path => {
|
|
54
|
+
const absolute = resolve(root, path);
|
|
55
|
+
if (!inside(root, absolute) || path.includes('\\') || path.split('/').some(p => p === '..' || p === '.'))
|
|
56
|
+
throw new Error('Selected source must stay inside the application root');
|
|
57
|
+
return relative(root, absolute).split(sep).join('/');
|
|
58
|
+
});
|
|
59
|
+
const check = () => input.signal?.throwIfAborted();
|
|
60
|
+
async function walk(directory, inherited) {
|
|
61
|
+
check();
|
|
62
|
+
if (entries >= 2000 || inspected >= maxFiles || bytes >= maxBytes) {
|
|
63
|
+
truncated = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const local = [...inherited];
|
|
67
|
+
if (directory !== root) {
|
|
68
|
+
try {
|
|
69
|
+
const file = resolve(directory, '.gitignore'), stat = await lstat(file);
|
|
70
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 16384)
|
|
71
|
+
local.push({ base: directory, rules: ignore().add(await readFile(file, 'utf8')) });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'ENOENT')
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const names = (await readdir(directory)).sort((a, b) => {
|
|
79
|
+
const rank = (s) => /^(package\.json|README\.md)$/.test(s) ? 0 : /init|route|page|screen|state|event|app|main/i.test(s) ? 1 : 2;
|
|
80
|
+
return rank(a) - rank(b) || a.localeCompare(b);
|
|
81
|
+
});
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
check();
|
|
84
|
+
if (++entries > 2000 || inspected >= maxFiles || bytes >= maxBytes) {
|
|
85
|
+
truncated = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const absolute = resolve(directory, name), path = relative(root, absolute).split(sep).join('/');
|
|
89
|
+
if (forbidden.test(path))
|
|
90
|
+
continue;
|
|
91
|
+
if (input.recommendations && /^(?:\.agents|\.cursor|\.codex)(?:\/|$)/.test(path))
|
|
92
|
+
continue;
|
|
93
|
+
const stat = await lstat(absolute);
|
|
94
|
+
if (stat.isSymbolicLink()) {
|
|
95
|
+
omitted.push({ path, reason: 'symlink' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!stat.isFile() && !stat.isDirectory()) {
|
|
99
|
+
omitted.push({ path, reason: 'non-regular' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (local.some(({ base, rules }) => rules.ignores(relative(base, absolute).split(sep).join('/') + (stat.isDirectory() ? '/' : ''))))
|
|
103
|
+
continue;
|
|
104
|
+
if (selected && !selected.some(p => p === path || p.startsWith(path + '/') || path.startsWith(p + '/')))
|
|
105
|
+
continue;
|
|
106
|
+
if (stat.isDirectory()) {
|
|
107
|
+
await walk(absolute, local);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (input.recommendations && (!/\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(path) || /(?:\.d\.ts|[.-](?:test|spec)\.[^.]+)$/.test(path) || /(?:^|\/)(?:vite|eslint|next|tailwind|postcss)\.config\./.test(path)))
|
|
111
|
+
continue;
|
|
112
|
+
if (!sourceFile.test(path))
|
|
113
|
+
continue;
|
|
114
|
+
inspected++;
|
|
115
|
+
const fileBudget = input.recommendations ? 256000 : 32768;
|
|
116
|
+
if (stat.size > fileBudget || stat.size + bytes > maxBytes) {
|
|
117
|
+
omitted.push({ path, reason: 'size budget' });
|
|
118
|
+
truncated = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Open without following a last-component symlink; verify inode and canonical ancestry again.
|
|
122
|
+
const { constants } = await import('node:fs');
|
|
123
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
124
|
+
let data;
|
|
125
|
+
try {
|
|
126
|
+
const opened = await handle.stat();
|
|
127
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev || !inside(root, await realpath(absolute)))
|
|
128
|
+
throw new Error('Source changed during inspection');
|
|
129
|
+
data = Buffer.alloc(fileBudget + 1);
|
|
130
|
+
const read = await handle.read(data, 0, data.length, 0);
|
|
131
|
+
data = data.subarray(0, read.bytesRead);
|
|
132
|
+
if (data.length > fileBudget || data.length !== opened.size)
|
|
133
|
+
throw new Error('Source changed during inspection');
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
bytes += data.length;
|
|
139
|
+
let text;
|
|
140
|
+
try {
|
|
141
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
omitted.push({ path, reason: 'binary' });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (text.includes('\0')) {
|
|
148
|
+
omitted.push({ path, reason: 'binary' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (secret.test(text)) {
|
|
152
|
+
omitted.push({ path, reason: 'possible secret; entire file omitted' });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (input.recommendations) {
|
|
156
|
+
candidates.push({ path, text });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const lines = text.split('\n');
|
|
160
|
+
const accepted = [];
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
if (Buffer.byteLength([...accepted, line].join('\n')) > Math.min(12000, maxPacketBytes - packetBytes - Buffer.byteLength(path) - 512)) {
|
|
163
|
+
truncated = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
accepted.push(line);
|
|
167
|
+
}
|
|
168
|
+
if (!accepted.length) {
|
|
169
|
+
truncated = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const excerpt = accepted.join('\n');
|
|
173
|
+
packetBytes += Buffer.byteLength(excerpt) + Buffer.byteLength(path) + 512;
|
|
174
|
+
evidence.push({ kind: 'local_source', id: `source_${evidence.length + 1}`, path, file_hash: sourceHash(text), excerpt_hash: sourceHash(excerpt), snapshot_id, start_line: 1, end_line: accepted.length, excerpt });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await walk(root, [{ base: root, rules: rootIgnore }]);
|
|
178
|
+
if (input.recommendations) {
|
|
179
|
+
// Fairly cover product areas before taking more detail. Generated guidance,
|
|
180
|
+
// manifests and boilerplate cannot spend the product-evidence budget.
|
|
181
|
+
const rank = (p) => /\/(?:pages|screens|views)\//i.test('/' + p) ? 0 : /\/(?:app|routes?)\.[^.]+$/i.test(p) ? 1 : /components\//.test(p) ? 2 : 3;
|
|
182
|
+
const interactions = (f) => (f.text.match(/onClick|onSubmit|onChange|const handle[A-Z]|function handle[A-Z]/g) ?? []).length;
|
|
183
|
+
candidates.sort((a, b) => rank(a.path) - rank(b.path) || a.path.split('/').length - b.path.split('/').length || interactions(b) - interactions(a) || a.path.localeCompare(b.path));
|
|
184
|
+
const areas = new Map();
|
|
185
|
+
for (const file of candidates) {
|
|
186
|
+
const key = file.path.split('/').slice(0, -1).join('/');
|
|
187
|
+
areas.set(key, [...(areas.get(key) ?? []), file]);
|
|
188
|
+
}
|
|
189
|
+
const ordered = [];
|
|
190
|
+
for (let i = 0; ordered.length < candidates.length; i++)
|
|
191
|
+
for (const files of areas.values())
|
|
192
|
+
if (files[i])
|
|
193
|
+
ordered.push(files[i]);
|
|
194
|
+
for (const { path, text } of ordered.slice(0, 12)) {
|
|
195
|
+
const lines = text.split('\n');
|
|
196
|
+
// Start at the component/handler rather than spending the excerpt on imports.
|
|
197
|
+
const behaviors = lines.map((line, index) => ({ line, index })).filter(({ line }) => /(?:const|function)\s+(?:handle|send|upload|download|submit|complete|create|pay|save)\w*\s*(?:=|\()/i.test(line));
|
|
198
|
+
behaviors.sort((a, b) => (/send|upload|download|submit|create|pay|save/i.test(b.line) ? 1 : 0) - (/send|upload|download|submit|create|pay|save/i.test(a.line) ? 1 : 0));
|
|
199
|
+
const behavior = behaviors[0]?.index ?? -1;
|
|
200
|
+
const rendered = lines.findIndex(line => /^\s*return\s*\(?\s*$/.test(line));
|
|
201
|
+
const start = Math.max(0, behavior >= 0 ? behavior : rendered >= 0 ? rendered : lines.findIndex(line => /(?:export (?:default )?(?:function|const|class)|function [A-Z]|const [A-Z]\w*\s*=)/.test(line)));
|
|
202
|
+
const accepted = [];
|
|
203
|
+
const allowance = Math.min(850, maxPacketBytes - packetBytes - 512 - Buffer.byteLength(path));
|
|
204
|
+
for (const line of lines.slice(start)) {
|
|
205
|
+
if (Buffer.byteLength([...accepted, line].join('\n')) > allowance)
|
|
206
|
+
break;
|
|
207
|
+
accepted.push(line);
|
|
208
|
+
}
|
|
209
|
+
if (!accepted.length)
|
|
210
|
+
continue;
|
|
211
|
+
const excerpt = accepted.join('\n');
|
|
212
|
+
packetBytes += Buffer.byteLength(excerpt) + Buffer.byteLength(path) + 512;
|
|
213
|
+
evidence.push({ kind: 'local_source', id: `source_${evidence.length + 1}`, path, file_hash: sourceHash(text), excerpt_hash: sourceHash(excerpt), snapshot_id, start_line: start + 1, end_line: start + accepted.length, excerpt });
|
|
214
|
+
}
|
|
215
|
+
truncated = ordered.length > evidence.length;
|
|
216
|
+
}
|
|
217
|
+
const packages = [];
|
|
218
|
+
const capabilities = [];
|
|
219
|
+
// Report only bounded installed SDK metadata inside this app; never follow workspace links.
|
|
220
|
+
const metadata = resolve(root, 'node_modules/@userreason/js/package.json');
|
|
221
|
+
try {
|
|
222
|
+
if (await realpath(metadata) === metadata) {
|
|
223
|
+
const stat = await lstat(metadata);
|
|
224
|
+
if (stat.isFile() && stat.size <= 16384) {
|
|
225
|
+
const manifest = JSON.parse(await readFile(metadata, 'utf8'));
|
|
226
|
+
if (manifest.name === '@userreason/js' && typeof manifest.version === 'string' && manifest.version.length <= 100) {
|
|
227
|
+
packages.push({ name: manifest.name, version: manifest.version });
|
|
228
|
+
// Only parse bounded declarations/metadata; never import the installed application or SDK.
|
|
229
|
+
if (typeof manifest.types === 'string') {
|
|
230
|
+
const declaration = resolve(metadata, '..', manifest.types);
|
|
231
|
+
if (inside(resolve(metadata, '..'), declaration) && await realpath(declaration) === declaration) {
|
|
232
|
+
const stat = await lstat(declaration);
|
|
233
|
+
if (stat.isFile() && stat.size <= 262144) {
|
|
234
|
+
const text = await readFile(declaration, 'utf8');
|
|
235
|
+
for (const name of ['setContext', 'setAvailable', 'event'])
|
|
236
|
+
if (new RegExp('\\b' + name + '\\s*\\(').test(text))
|
|
237
|
+
capabilities.push('declared:' + name);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch { /* Missing/linked SDK is an explicit unknown, not a reason to expand scope. */ }
|
|
246
|
+
return { root, snapshot_id, evidence, omitted, truncated, inspected, packages, capabilities, capability_status: capabilities.length ? 'declared' : 'unknown', packet_digest: sourceHash(JSON.stringify(evidence)) };
|
|
247
|
+
}
|
|
248
|
+
/** Recheck every approved full-file hash before upload, edits or draft mutations. */
|
|
249
|
+
export async function verifyFirstSurveySource(root, sources) {
|
|
250
|
+
if (await realpath(root) !== root)
|
|
251
|
+
throw new Error('Application root changed');
|
|
252
|
+
for (const source of sources) {
|
|
253
|
+
const absolute = resolve(root, source.path);
|
|
254
|
+
if (!inside(root, absolute) || source.path.includes('\\') || source.path.split('/').some(p => !p || p === '.' || p === '..') || forbidden.test(source.path))
|
|
255
|
+
throw new Error('Source boundary changed');
|
|
256
|
+
let cursor = root;
|
|
257
|
+
for (const part of source.path.split('/')) {
|
|
258
|
+
cursor = resolve(cursor, part);
|
|
259
|
+
if ((await lstat(cursor)).isSymbolicLink())
|
|
260
|
+
throw new Error('Source became a symlink');
|
|
261
|
+
}
|
|
262
|
+
const stat = await lstat(absolute);
|
|
263
|
+
if (!stat.isFile() || stat.size > 256000 || sourceHash(await readFile(absolute, 'utf8')) !== source.file_hash)
|
|
264
|
+
throw new Error('Source changed; review a new snapshot');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Separate opt-in; no fetch, diff, author identity, or deployment inference. */
|
|
268
|
+
export async function collectFirstSurveyHistory(root, consent) {
|
|
269
|
+
if (!consent)
|
|
270
|
+
return { evidence: [] };
|
|
271
|
+
if (await realpath(root) !== root)
|
|
272
|
+
throw new Error('Canonical root required');
|
|
273
|
+
// Do not invoke repository-configured pagers, hooks or external diff tools.
|
|
274
|
+
const args = ['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', 'log', '--no-show-signature', '-20', '--since=90 days ago', '--format=%H%x09%cI%x09%s', '--', '.'];
|
|
275
|
+
try {
|
|
276
|
+
const output = await new Promise((ok, fail) => execFile('git', args, { cwd: root, timeout: 2000, maxBuffer: 16384, env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0' } }, (err, out) => err ? fail(err) : ok(out)));
|
|
277
|
+
const evidence = [];
|
|
278
|
+
for (const line of output.trim().split('\n').filter(Boolean)) {
|
|
279
|
+
const [commit, date, ...subject] = line.split('\t'), summary = subject.join(' ').slice(0, 1200);
|
|
280
|
+
if (!/^[a-f0-9]{40,64}$/.test(commit) || !Number.isFinite(Date.parse(date)) || !summary || secret.test(summary))
|
|
281
|
+
continue;
|
|
282
|
+
evidence.push({ kind: 'local_history', id: `history_${evidence.length + 1}`, commit: commit, date: new Date(date).toISOString(), summary });
|
|
283
|
+
}
|
|
284
|
+
return { evidence, ...(!evidence.length ? { warning: 'No bounded local history available; source is not deployment evidence.' } : {}) };
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return { evidence: [], warning: 'Local history unavailable; continuing without it.' };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Interface } from 'node:readline/promises';
|
|
2
|
+
import type { Writable } from 'node:stream';
|
|
3
|
+
import type { FirstSurveyUI } from './first-survey.js';
|
|
4
|
+
/** Sanitize only terminal presentation, never evidence, plans or answers. */
|
|
5
|
+
export declare function firstSurveyTerminalUI(rl: Pick<Interface, 'question'>, output: Pick<Writable, 'write'>, signal?: AbortSignal): FirstSurveyUI;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { printable } from './docs.js';
|
|
2
|
+
/** Sanitize only terminal presentation, never evidence, plans or answers. */
|
|
3
|
+
export function firstSurveyTerminalUI(rl, output, signal) {
|
|
4
|
+
const show = (message) => { output.write(printable(message + '\n')); };
|
|
5
|
+
const ask = (message) => rl.question(printable(message), { signal });
|
|
6
|
+
return {
|
|
7
|
+
signal,
|
|
8
|
+
show,
|
|
9
|
+
confirm: async (message) => (await ask(message + ' [yes/no] ')) === 'yes',
|
|
10
|
+
choose: async (message, options) => {
|
|
11
|
+
show(message + '\n' + options.map(option => option.value + ': ' + option.label).join('\n'));
|
|
12
|
+
return ask('Selection: ');
|
|
13
|
+
},
|
|
14
|
+
answer: message => ask(message + ' '),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { UserReasonClient } from './client.js';
|
|
2
|
+
import { type LocalEvidence } from './first-survey-source.js';
|
|
3
|
+
export interface FirstSurveyUI {
|
|
4
|
+
show(message: string): void;
|
|
5
|
+
confirm(message: string): Promise<boolean>;
|
|
6
|
+
choose(message: string, options: {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}[], initialValue?: string): Promise<string>;
|
|
10
|
+
answer(message: string): Promise<string>;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
chooseMany?(message: string, options: {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
}[], initialValues: string[]): Promise<string[]>;
|
|
17
|
+
loading?<T>(message: string, work: () => Promise<T>): Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
type Candidate = {
|
|
20
|
+
decision?: string;
|
|
21
|
+
objective?: string;
|
|
22
|
+
audience?: {
|
|
23
|
+
claim: string;
|
|
24
|
+
};
|
|
25
|
+
exclusions?: {
|
|
26
|
+
claim: string;
|
|
27
|
+
};
|
|
28
|
+
opportunity?: {
|
|
29
|
+
claim: string;
|
|
30
|
+
};
|
|
31
|
+
availability?: string;
|
|
32
|
+
frequency?: string;
|
|
33
|
+
readiness?: string;
|
|
34
|
+
warnings?: string[];
|
|
35
|
+
id: string;
|
|
36
|
+
question: string;
|
|
37
|
+
definition: Record<string, unknown>;
|
|
38
|
+
instrumentation: unknown[];
|
|
39
|
+
missing: string[];
|
|
40
|
+
};
|
|
41
|
+
type Plan = {
|
|
42
|
+
outcome: string;
|
|
43
|
+
candidates: Candidate[];
|
|
44
|
+
clarifications: {
|
|
45
|
+
id: string;
|
|
46
|
+
question: string;
|
|
47
|
+
reason: string;
|
|
48
|
+
}[];
|
|
49
|
+
};
|
|
50
|
+
/** Show all respondent copy and branching before the author approves a draft. */
|
|
51
|
+
export declare function renderFirstSurveyQuestions(definition: Record<string, unknown>): string[];
|
|
52
|
+
export declare function renderFirstSurveyCandidate(c: Candidate): string;
|
|
53
|
+
type Session = {
|
|
54
|
+
version: 1;
|
|
55
|
+
root: string;
|
|
56
|
+
api: string;
|
|
57
|
+
workspace: string;
|
|
58
|
+
site: string;
|
|
59
|
+
id: string;
|
|
60
|
+
expires: number;
|
|
61
|
+
state: string;
|
|
62
|
+
sources: Pick<LocalEvidence, 'path' | 'file_hash'>[];
|
|
63
|
+
plan?: Plan;
|
|
64
|
+
digest?: string;
|
|
65
|
+
saves: Record<string, {
|
|
66
|
+
prompt: string;
|
|
67
|
+
request: string;
|
|
68
|
+
definition: Record<string, unknown>;
|
|
69
|
+
result?: unknown;
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
export declare function firstSurveyHandoff(root: string, api: string, workspace: string, site: string, id?: string): string;
|
|
73
|
+
/** Separate from installation state. Owner-only, seven days, no source excerpts or credentials. */
|
|
74
|
+
export declare function firstSurvey(client: UserReasonClient, root: string, workspace: string, site: string, ui: FirstSurveyUI, mode?: string): Promise<Session | {
|
|
75
|
+
state: string;
|
|
76
|
+
session_id?: undefined;
|
|
77
|
+
saves?: undefined;
|
|
78
|
+
} | {
|
|
79
|
+
session_id: string;
|
|
80
|
+
state: string;
|
|
81
|
+
saves: Record<string, {
|
|
82
|
+
prompt: string;
|
|
83
|
+
request: string;
|
|
84
|
+
definition: Record<string, unknown>;
|
|
85
|
+
result?: unknown;
|
|
86
|
+
}>;
|
|
87
|
+
}>;
|
|
88
|
+
export {};
|