@userreason/cli 0.1.0 → 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 (70) hide show
  1. package/README.md +301 -41
  2. package/dist/authorize.d.ts +29 -0
  3. package/dist/authorize.js +23 -0
  4. package/dist/bin.js +565 -50
  5. package/dist/capture.d.ts +18 -0
  6. package/dist/capture.js +256 -0
  7. package/dist/catalog.d.ts +16 -0
  8. package/dist/catalog.js +206 -0
  9. package/dist/client.d.ts +79 -2
  10. package/dist/client.js +140 -17
  11. package/dist/config.d.ts +4 -0
  12. package/dist/config.js +35 -12
  13. package/dist/contracts.d.ts +13 -3
  14. package/dist/contracts.js +4 -4
  15. package/dist/credentials.d.ts +12 -2
  16. package/dist/credentials.js +110 -26
  17. package/dist/docs.d.ts +51 -0
  18. package/dist/docs.js +143 -0
  19. package/dist/errors.d.ts +11 -0
  20. package/dist/errors.js +38 -0
  21. package/dist/event-policy.d.ts +2 -0
  22. package/dist/event-policy.js +9 -0
  23. package/dist/first-survey-preparation.d.ts +8 -0
  24. package/dist/first-survey-preparation.js +29 -0
  25. package/dist/first-survey-source.d.ts +60 -0
  26. package/dist/first-survey-source.js +289 -0
  27. package/dist/first-survey-terminal.d.ts +5 -0
  28. package/dist/first-survey-terminal.js +16 -0
  29. package/dist/first-survey.d.ts +88 -0
  30. package/dist/first-survey.js +227 -0
  31. package/dist/help.d.ts +13 -0
  32. package/dist/help.js +74 -0
  33. package/dist/index.d.ts +23 -0
  34. package/dist/index.js +23 -0
  35. package/dist/lock.js +19 -5
  36. package/dist/profile.d.ts +20 -0
  37. package/dist/profile.js +57 -0
  38. package/dist/project.d.ts +9 -0
  39. package/dist/project.js +87 -0
  40. package/dist/recommended-surveys.d.ts +30 -0
  41. package/dist/recommended-surveys.js +201 -0
  42. package/dist/setup-executor.d.ts +35 -0
  43. package/dist/setup-executor.js +329 -0
  44. package/dist/setup-files.d.ts +11 -0
  45. package/dist/setup-files.js +96 -0
  46. package/dist/setup-runner.d.ts +14 -0
  47. package/dist/setup-runner.js +142 -0
  48. package/dist/setup-source.d.ts +31 -0
  49. package/dist/setup-source.js +48 -0
  50. package/dist/setup-store.d.ts +37 -0
  51. package/dist/setup-store.js +256 -0
  52. package/dist/setup-terminal.d.ts +3 -0
  53. package/dist/setup-terminal.js +6 -0
  54. package/dist/setup-wire.d.ts +1237 -0
  55. package/dist/setup-wire.js +175 -0
  56. package/dist/setup.d.ts +1223 -0
  57. package/dist/setup.js +258 -0
  58. package/dist/starter-survey.d.ts +22 -0
  59. package/dist/starter-survey.js +457 -0
  60. package/dist/telemetry.d.ts +67 -0
  61. package/dist/telemetry.js +304 -0
  62. package/dist/ui.d.ts +75 -0
  63. package/dist/ui.js +107 -0
  64. package/dist/views.d.ts +51 -0
  65. package/dist/views.js +93 -0
  66. package/dist/workos.d.ts +1 -1
  67. package/dist/workos.js +9 -4
  68. package/package.json +29 -12
  69. package/skills/userreason/SKILL.md +88 -8
  70. package/skills/userreason/references/management.md +67 -0
package/dist/docs.js ADDED
@@ -0,0 +1,143 @@
1
+ import { apiUrl, object } from "./contracts.js";
2
+ import { DEVELOPMENT_DOCS } from "./profile.js";
3
+ import { bundledCliMarkdown, searchCommands } from './catalog.js';
4
+ import { NetworkError, networkError } from './errors.js';
5
+ export const DOCS_URL = "https://docs.userreason.com";
6
+ export class DocsError extends Error {
7
+ code;
8
+ status;
9
+ constructor(code, message, status) {
10
+ super(message);
11
+ this.code = code;
12
+ this.status = status;
13
+ }
14
+ }
15
+ export const docsUrl = (value) => apiUrl(value, "Docs URL");
16
+ const invalid = (status) => new DocsError("invalid_response", `Invalid UserReason docs response (${status})`, status);
17
+ /** The docs are public: requests carry no credentials, never follow redirects and read a bounded body. */
18
+ async function read(url, accept, limit, fetcher) {
19
+ try {
20
+ const response = await fetcher(url.href, { headers: { Accept: accept }, redirect: "error", signal: AbortSignal.timeout(20_000) });
21
+ const chunks = [];
22
+ let bytes = 0;
23
+ if (response.body)
24
+ for await (const chunk of response.body) {
25
+ bytes += chunk.byteLength;
26
+ if (bytes > limit)
27
+ throw new DocsError("response_too_large", "UserReason docs response is too large", response.status);
28
+ chunks.push(chunk);
29
+ }
30
+ try {
31
+ return { response, text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)) };
32
+ }
33
+ catch {
34
+ throw invalid(response.status);
35
+ }
36
+ }
37
+ catch (error) {
38
+ if (error instanceof DocsError)
39
+ throw error;
40
+ const failure = networkError(error, url.href, 'UserReason documentation request');
41
+ throw failure.code === 'network_unreachable' ? new DocsError("docs_unreachable", `${failure.message} Use cli search or docs search --offline for the bundled CLI reference, or set --docs-url or UR_DOCS_URL.`) : failure;
42
+ }
43
+ }
44
+ function searchResult(value) {
45
+ const data = object(value, "docs search result");
46
+ if (["title", "url", "markdown_url", "passage"].some(key => typeof data[key] !== "string") || (data.heading !== null && typeof data.heading !== "string") || ["package", "version", "since"].some(key => data[key] !== undefined && typeof data[key] !== "string"))
47
+ throw new Error("Invalid docs search result");
48
+ return data;
49
+ }
50
+ export async function searchDocs(input) {
51
+ const query = input.query.trim();
52
+ if (!query || query.length > 500)
53
+ throw new Error("Docs search needs a question of 1–500 characters");
54
+ if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 20))
55
+ throw new Error("Docs search limit must be an integer from 1 to 20");
56
+ const offline = () => ({ query, source: 'bundled', results: searchCommands(query, input.limit).map(item => ({
57
+ title: 'CLI command reference', heading: item.command, url: docsUrl(input.origin ?? DOCS_URL) + '/cli/', markdown_url: docsUrl(input.origin ?? DOCS_URL) + '/cli/', passage: item.summary + '\n' + item.usage, package: '@userreason/cli',
58
+ })) });
59
+ if (input.offline)
60
+ return offline();
61
+ const url = new URL("/search", docsUrl(input.origin ?? DOCS_URL));
62
+ url.searchParams.set("q", query);
63
+ if (input.limit !== undefined)
64
+ url.searchParams.set("limit", String(input.limit));
65
+ let response, text;
66
+ try {
67
+ ({ response, text } = await read(url, "application/json", 262_144, input.fetcher ?? fetch));
68
+ }
69
+ catch (error) {
70
+ if (input.live === false && (error instanceof NetworkError || error instanceof DocsError && error.code === 'docs_unreachable'))
71
+ return { ...offline(), warning: { code: error.code, message: error.message } };
72
+ throw error;
73
+ }
74
+ if (!response.ok) {
75
+ let code = "request_failed";
76
+ try {
77
+ const detail = object(object(JSON.parse(text), "error").error, "error");
78
+ if (typeof detail.code === "string" && /^[a-z0-9_:-]{1,100}$/i.test(detail.code))
79
+ code = detail.code;
80
+ }
81
+ catch { /* Not a docs error body. */ }
82
+ throw new DocsError(code, `UserReason docs search failed (${response.status}, ${code})`, response.status);
83
+ }
84
+ try {
85
+ const data = object(JSON.parse(text), "docs search");
86
+ if (typeof data.query !== "string" || !Array.isArray(data.results))
87
+ throw new Error("Invalid docs search");
88
+ const selectedOrigin = docsUrl(input.origin ?? DOCS_URL);
89
+ const address = (value) => selectedOrigin === DEVELOPMENT_DOCS && value.startsWith(DOCS_URL + '/') ? DEVELOPMENT_DOCS + value.slice(DOCS_URL.length) : value;
90
+ return { ...data, query: data.query, results: data.results.map(value => {
91
+ const result = searchResult(value);
92
+ return { ...result, url: address(result.url), markdown_url: address(result.markdown_url) };
93
+ }) };
94
+ }
95
+ catch {
96
+ throw invalid(response.status);
97
+ }
98
+ }
99
+ /** Accepts a page address or path. Search results name the public site, so its addresses are read from the selected origin. */
100
+ export function docsPageUrl(target, origin = DOCS_URL) {
101
+ const base = docsUrl(origin);
102
+ if (!target.trim())
103
+ throw new Error("Supply a docs page URL or path, such as /sdk/events/");
104
+ let url;
105
+ try {
106
+ url = new URL(target.trim(), base);
107
+ }
108
+ catch {
109
+ throw new Error("Invalid docs page URL or path");
110
+ }
111
+ if (url.username || url.password || (url.origin !== base && url.origin !== DOCS_URL && url.origin !== DEVELOPMENT_DOCS))
112
+ throw new Error(`docs get reads pages from ${base} only`);
113
+ const path = url.pathname;
114
+ return base + (path.endsWith("/") || path.slice(path.lastIndexOf("/") + 1).includes(".") ? path : `${path}/`);
115
+ }
116
+ export async function getDocsPage(input) {
117
+ const url = new URL(docsPageUrl(input.target, input.origin));
118
+ if (!input.live && ['/cli/', '/cli/index.md'].includes(url.pathname))
119
+ return { url: url.href, markdown: bundledCliMarkdown(), source: 'bundled' };
120
+ if (input.offline)
121
+ throw new DocsError('not_bundled', 'Only the CLI reference (/cli/) is bundled. SDK documentation needs network access; use --live to read it.');
122
+ const { response, text } = await read(url, "text/markdown", 2_097_152, input.fetcher ?? fetch);
123
+ if (response.status === 404)
124
+ throw new DocsError("not_found", `No docs page at ${url.href}. Find pages with userreason docs search, or read ${url.origin}/llms.txt.`, 404);
125
+ if (!response.ok)
126
+ throw new DocsError("request_failed", `UserReason docs request failed (${response.status})`, response.status);
127
+ const type = response.headers.get("content-type")?.toLowerCase() ?? "";
128
+ if (!type.startsWith("text/markdown") && !type.startsWith("text/plain"))
129
+ throw new DocsError("not_markdown", `${url.href} did not return Markdown`, response.status);
130
+ const tokens = Number(response.headers.get("x-markdown-tokens"));
131
+ return { url: url.href, markdown: text, ...(Number.isInteger(tokens) && tokens > 0 ? { tokens } : {}) };
132
+ }
133
+ /** Terminal output keeps line breaks and tabs and drops other control characters. */
134
+ export const printable = (text) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
135
+ export function formatDocsSearch(found) {
136
+ const note = found.source === 'bundled' ? 'Bundled CLI reference only. SDK documentation requires network access.\n' : '';
137
+ if (!found.results.length)
138
+ return printable(`${note}No docs sections match "${found.query}".\n`);
139
+ return printable(note + found.results.map((result, index) => {
140
+ const notes = [[result.package, result.version].filter(Boolean).join(" "), result.since ? `added in ${result.since}` : ""].filter(Boolean).join(", ");
141
+ return `${index + 1}. ${result.title}${result.heading ? ` › ${result.heading}` : ""}${notes ? ` (${notes})` : ""}\n ${result.url}\n ${result.passage}\n`;
142
+ }).join("\n") + "\nRead a page as Markdown: userreason docs get URL\n");
143
+ }
@@ -0,0 +1,11 @@
1
+ /** Local transport failures are different from an API denying workspace access. */
2
+ export declare class NetworkError extends Error {
3
+ code: 'network_blocked' | 'network_timeout' | 'network_unreachable';
4
+ constructor(code: 'network_blocked' | 'network_timeout' | 'network_unreachable', message: string);
5
+ }
6
+ export declare class ConfigurationError extends Error {
7
+ code: 'config_read_failed' | 'config_write_failed' | 'invalid_config';
8
+ constructor(code: 'config_read_failed' | 'config_write_failed' | 'invalid_config', message: string);
9
+ }
10
+ export declare function networkError(error: unknown, url: string, operation: string): NetworkError;
11
+ export declare function fetchWithErrors(fetcher: typeof fetch, url: string, init: RequestInit, operation: string): Promise<Response>;
package/dist/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ /** Local transport failures are different from an API denying workspace access. */
2
+ export class NetworkError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ }
9
+ export class ConfigurationError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ }
15
+ }
16
+ export function networkError(error, url, operation) {
17
+ const codes = [];
18
+ let current = error;
19
+ for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) {
20
+ const item = current;
21
+ codes.push(String(item.code ?? ''), String(item.name ?? ''));
22
+ current = item.cause;
23
+ }
24
+ const host = new URL(url).origin;
25
+ if (codes.some(code => ['EACCES', 'EPERM', 'ERR_ACCESS_DENIED'].includes(code)))
26
+ return new NetworkError('network_blocked', `${operation}: local permissions blocked network access to ${host}. Allow this command to reach that origin; changing workspace grants will not resolve it.`);
27
+ if (codes.some(code => ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'TimeoutError', 'AbortError'].includes(code)))
28
+ return new NetworkError('network_timeout', `${operation}: the request to ${host} timed out. Check connectivity and retry; this is not a workspace permission denial.`);
29
+ return new NetworkError('network_unreachable', `${operation}: unable to reach ${host}. Check the connection and the agent's network permissions; this is not a workspace permission denial.`);
30
+ }
31
+ export async function fetchWithErrors(fetcher, url, init, operation) {
32
+ try {
33
+ return await fetcher(url, init);
34
+ }
35
+ catch (error) {
36
+ throw networkError(error, url, operation);
37
+ }
38
+ }
@@ -0,0 +1,2 @@
1
+ /** Explicit file policies win; the one-step registration preset is a product choice, not name inference. */
2
+ export declare function eventCollection(file: Record<string, unknown>, purpose?: string): unknown;
@@ -0,0 +1,9 @@
1
+ /** Explicit file policies win; the one-step registration preset is a product choice, not name inference. */
2
+ export function eventCollection(file, purpose) {
3
+ const selected = purpose ?? file.purpose ?? 'business';
4
+ if (selected !== 'business' && selected !== 'activity')
5
+ throw new Error('Purpose must be business or activity');
6
+ return file.collection ?? (selected === 'activity'
7
+ ? { memory: true, local: false, server: false, raw_retention_days: 0, history_retention_days: null, purpose: 'activity' }
8
+ : { memory: true, local: false, server: true, raw_retention_days: 30, history_retention_days: 90, purpose: 'business' });
9
+ }
@@ -0,0 +1,8 @@
1
+ import { type SourcePreview } from './first-survey-source.js';
2
+ export interface FirstSurveyPreparation {
3
+ readonly root: string;
4
+ take(signal?: AbortSignal): Promise<SourcePreview>;
5
+ cancel(): void;
6
+ }
7
+ /** Local, memory-only work. No provider, account lookup, Git history or upload. */
8
+ export declare function prepareFirstSurveyLocally(root: string): FirstSurveyPreparation;
@@ -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
+ }