@userreason/cli 0.1.0 → 0.1.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.
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
+ }
package/dist/help.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { UiKit } from './ui.js';
2
+ export declare const overviewNouns: string[];
3
+ export interface HelpOptions {
4
+ version?: string;
5
+ development?: boolean;
6
+ trademark?: boolean;
7
+ }
8
+ /** Four groups of nouns, one line each. Plain (piped) output is the same text without colour or rules. */
9
+ export declare function overviewHelp(kit: UiKit, { version, development, trademark }?: HelpOptions): string;
10
+ /** One noun's actions: exact usage from the catalog, without the options every command shares. */
11
+ export declare function nounHelp(kit: UiKit, noun: string): string | undefined;
12
+ /** The closest command name within two edits, for "Did you mean". */
13
+ export declare function suggestCommand(word: string): string | undefined;
package/dist/help.js ADDED
@@ -0,0 +1,74 @@
1
+ import { commandCatalog, commandSummary } from './catalog.js';
2
+ /** The overview groups every command noun. A test keeps this list in step with the catalog. */
3
+ const GROUPS = [
4
+ ['Get started', [['login', 'Sign in in the browser'], ['access', 'Review or renew what this CLI may do'], ['whoami', "Account, grants and this project's site"], ['logout', 'Remove local credentials']]],
5
+ ['Feedback', [['prompt', 'Create, validate, publish and read surveys'], ['preview', 'Capture a site for the development preview lab'], ['audience', 'Choose who sees a survey'], ['results', 'Summaries and breakdowns'], ['responses', 'Read answers'], ['user', 'People and their eligibility'], ['event', 'Product events and collection']]],
6
+ ['Workspaces and sites', [['workspace', 'list · create · delete'], ['site', 'list · create · activity · events · frequency · delete'], ['member', 'list'], ['grant', 'Request and check access'], ['deletion', 'Check a deletion request']]],
7
+ ['Reference', [['docs', 'Search and read the documentation'], ['cli', "Search this CLI's commands"], ['query', 'Raw management API for agents'], ['command', 'Send a management command file']]],
8
+ ];
9
+ export const overviewNouns = GROUPS.flatMap(([, items]) => items.map(([name]) => name));
10
+ /** Commands with no action of their own: they never get a per-command help page. */
11
+ const NO_ACTION = new Set(['login', 'access', 'logout', 'whoami', 'command', 'query']);
12
+ const READ_SCOPES = new Set(['workspace:read', 'site:read']);
13
+ /** Four groups of nouns, one line each. Plain (piped) output is the same text without colour or rules. */
14
+ export function overviewHelp(kit, { version, development = false, trademark = false } = {}) {
15
+ const { c, S } = kit;
16
+ const lines = [];
17
+ const environment = development ? `(development API)` : '';
18
+ const meta = [version ? `v${version}` : '', environment].filter(Boolean).join(' ');
19
+ if (kit.level > 0)
20
+ lines.push(`${c.bold(c.accent(trademark ? 'UserReason™ CLI' : 'userreason'))}${meta ? ` ${c.muted(meta)}` : ''}`, c.faint(S.rule.repeat(54)));
21
+ else
22
+ lines.push(`userreason${meta ? ` ${meta}` : ''}`);
23
+ lines.push(`${c.muted('Usage')} userreason <command> [options]`, '');
24
+ const first = Math.max(...overviewNouns.map(name => name.length));
25
+ for (const [group, items] of GROUPS) {
26
+ lines.push(c.bold(group), ...items.map(([name, text]) => ` ${c.code(name)}${' '.repeat(first - name.length + 2)}${c.muted(text)}`), '');
27
+ }
28
+ lines.push(`${c.muted('Options')} --json --workspace UUID --site UUID --dev --no-color -h`, `${c.muted('Target')} this project's SDK site; override with --workspace and --site`, `${c.muted('More')} ${c.code('userreason <command> --help')} ${c.code('userreason --help --all')} ${c.code('userreason cli search "<task>"')}`);
29
+ return lines.join('\n') + '\n';
30
+ }
31
+ /** One noun's actions: exact usage from the catalog, without the options every command shares. */
32
+ export function nounHelp(kit, noun) {
33
+ if (NO_ACTION.has(noun))
34
+ return undefined;
35
+ const items = commandCatalog.filter(item => item.command === noun || item.command.startsWith(`${noun} `));
36
+ if (!items.length)
37
+ return undefined;
38
+ const { c, S } = kit;
39
+ const short = (usage) => usage.replace(/^userreason /, '').replace(/ --workspace UUID/g, '').replace(/ --site UUID/g, '').replace(`${noun} `, '');
40
+ const lines = [];
41
+ const summary = commandSummary(noun);
42
+ if (kit.level > 0)
43
+ lines.push(`${c.bold(c.accent(`userreason ${noun}`))}${summary ? ` ${c.muted(summary)}` : ''}`, c.faint(S.rule.repeat(54)));
44
+ else
45
+ lines.push(`userreason ${noun}${summary ? ` ${S.dot} ${summary}` : ''}`);
46
+ for (const [title, read] of [['Read', true], ['Write', false]]) {
47
+ const rows = items.filter(item => item.scopes.every(scope => READ_SCOPES.has(scope)) === read);
48
+ if (!rows.length)
49
+ continue;
50
+ lines.push(c.bold(title));
51
+ for (const item of rows) {
52
+ lines.push(` ${c.code(short(item.usage))}`);
53
+ const specific = commandSummary(item.command);
54
+ if (specific && item.command !== noun)
55
+ lines.push(` ${c.muted(specific)}`);
56
+ }
57
+ }
58
+ lines.push(`${c.muted('Options')} --workspace UUID --site UUID ${c.muted("(default: this project's site)")} --json`);
59
+ return lines.join('\n') + '\n';
60
+ }
61
+ /** The closest command name within two edits, for "Did you mean". */
62
+ export function suggestCommand(word) {
63
+ const distance = (a, b) => {
64
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
65
+ for (let j = 1; j <= b.length; j++)
66
+ d[0][j] = j;
67
+ for (let i = 1; i <= a.length; i++)
68
+ for (let j = 1; j <= b.length; j++)
69
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
70
+ return d[a.length][b.length];
71
+ };
72
+ const [best] = overviewNouns.map(name => [name, distance(word.toLowerCase(), name)]).sort((a, b) => a[1] - b[1]);
73
+ return best && best[1] <= 2 ? best[0] : undefined;
74
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,13 @@ export * from "./client.js";
2
2
  export * from "./config.js";
3
3
  export * from "./contracts.js";
4
4
  export * from "./credentials.js";
5
+ export * from "./docs.js";
6
+ export * from "./profile.js";
5
7
  export * from "./workos.js";
8
+ export * from "./authorize.js";
9
+ export * from "./project.js";
10
+ export * from "./errors.js";
11
+ export * from "./catalog.js";
12
+ export * from "./ui.js";
13
+ export * from "./help.js";
14
+ export * from "./views.js";
package/dist/index.js CHANGED
@@ -2,4 +2,13 @@ export * from "./client.js";
2
2
  export * from "./config.js";
3
3
  export * from "./contracts.js";
4
4
  export * from "./credentials.js";
5
+ export * from "./docs.js";
6
+ export * from "./profile.js";
5
7
  export * from "./workos.js";
8
+ export * from "./authorize.js";
9
+ export * from "./project.js";
10
+ export * from "./errors.js";
11
+ export * from "./catalog.js";
12
+ export * from "./ui.js";
13
+ export * from "./help.js";
14
+ export * from "./views.js";
package/dist/lock.js CHANGED
@@ -2,22 +2,36 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { configDirectory } from "./config.js";
5
+ import { CredentialStoreError } from './credentials.js';
5
6
  // Serialize rotating refresh tokens across CLI/wizard processes. No credentials enter this file.
6
7
  export async function withSessionLock(config, operation) {
7
8
  const directory = join(configDirectory(), "locks");
8
- await mkdir(directory, { recursive: true, mode: 0o700 });
9
+ try {
10
+ await mkdir(directory, { recursive: true, mode: 0o700 });
11
+ }
12
+ catch {
13
+ throw new CredentialStoreError('lock_failed', `Refreshing the UserReason session requires local write access to ${directory}. Allow that access and retry; workspace grants are unrelated.`);
14
+ }
9
15
  const path = join(directory, createHash("sha256").update(`${config.apiUrl}\n${config.clientId}`).digest("hex") + ".lock");
10
16
  const id = randomUUID(), deadline = Date.now() + 35_000;
11
17
  while (true) {
12
18
  try {
13
19
  const file = await open(path, "wx", 0o600);
14
- await file.writeFile(JSON.stringify({ id, createdAt: Date.now() }));
15
- await file.close();
20
+ try {
21
+ await file.writeFile(JSON.stringify({ id, createdAt: Date.now() }));
22
+ }
23
+ catch (error) {
24
+ await unlink(path).catch(() => { });
25
+ throw error;
26
+ }
27
+ finally {
28
+ await file.close();
29
+ }
16
30
  break;
17
31
  }
18
32
  catch (error) {
19
33
  if (error.code !== "EEXIST")
20
- throw new Error("Unable to lock the UserReason credential store");
34
+ throw new CredentialStoreError('lock_failed', `Unable to lock UserReason credentials for refresh at ${path}. Allow local write access and retry.`);
21
35
  try {
22
36
  const old = JSON.parse(await readFile(path, "utf8"));
23
37
  if (typeof old.createdAt === "number" && Date.now() - old.createdAt > 120_000) {
@@ -27,7 +41,7 @@ export async function withSessionLock(config, operation) {
27
41
  }
28
42
  catch { /* Another process may be writing or releasing its lock. */ }
29
43
  if (Date.now() >= deadline)
30
- throw new Error("Another UserReason process is refreshing credentials; try again shortly");
44
+ throw new CredentialStoreError('lock_timeout', "Another UserReason process is refreshing credentials; try again shortly");
31
45
  await new Promise(resolve => setTimeout(resolve, 150));
32
46
  }
33
47
  }
@@ -0,0 +1,20 @@
1
+ export interface PackageProfile {
2
+ schemaVersion: 1;
3
+ kind: "production" | "local-development";
4
+ apiEnvironment: "production" | "development";
5
+ docsUrl: string;
6
+ sdkVersion: string;
7
+ cliVersion: string;
8
+ wizardVersion?: string;
9
+ buildId?: string;
10
+ registry?: string;
11
+ }
12
+ export declare const PRODUCTION_DOCS = "https://docs.userreason.com";
13
+ export declare const DEVELOPMENT_DOCS = "https://docs-dev.userreason.com";
14
+ export declare const productionProfile: PackageProfile;
15
+ export declare function parsePackageProfile(value: unknown): PackageProfile;
16
+ /** The same optional file travels beside the CLI modules and the wizard's bundled modules. */
17
+ export declare function packageProfile(): PackageProfile;
18
+ export declare function docsOrigin(development?: boolean): string;
19
+ /** Ask the foreground builder for a complete current set, including changes still being compiled. */
20
+ export declare function currentPackageProfile(profile?: PackageProfile, fetcher?: typeof fetch): Promise<PackageProfile>;
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { apiUrl, object } from "./contracts.js";
3
+ export const PRODUCTION_DOCS = "https://docs.userreason.com";
4
+ export const DEVELOPMENT_DOCS = "https://docs-dev.userreason.com";
5
+ export const productionProfile = {
6
+ schemaVersion: 1, kind: "production", apiEnvironment: "production",
7
+ docsUrl: PRODUCTION_DOCS, sdkVersion: "^0.4.0", cliVersion: "^0.1.1",
8
+ };
9
+ export function parsePackageProfile(value) {
10
+ const data = object(value, "package profile");
11
+ if (data.schemaVersion !== 1 || data.kind !== "local-development" || data.apiEnvironment !== "development" ||
12
+ data.docsUrl !== DEVELOPMENT_DOCS || typeof data.buildId !== "string" || !/^[a-f0-9]{64}$/.test(data.buildId) ||
13
+ [data.sdkVersion, data.cliVersion, data.wizardVersion].some(version => typeof version !== "string" || !/^\d+\.\d+\.\d+-dev\.[0-9]+$/.test(version)) ||
14
+ typeof data.registry !== "string")
15
+ throw new Error("Invalid local UserReason package profile");
16
+ const registry = apiUrl(data.registry), url = new URL(registry);
17
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1")
18
+ throw new Error("Local UserReason packages require a loopback registry");
19
+ return { schemaVersion: 1, kind: "local-development", apiEnvironment: "development", docsUrl: DEVELOPMENT_DOCS,
20
+ sdkVersion: data.sdkVersion, cliVersion: data.cliVersion, wizardVersion: data.wizardVersion,
21
+ buildId: data.buildId, registry };
22
+ }
23
+ /** The same optional file travels beside the CLI modules and the wizard's bundled modules. */
24
+ export function packageProfile() {
25
+ try {
26
+ return parsePackageProfile(JSON.parse(readFileSync(new URL("./package-profile.json", import.meta.url), "utf8")));
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return { ...productionProfile };
31
+ throw error;
32
+ }
33
+ }
34
+ export function docsOrigin(development = packageProfile().apiEnvironment === "development") {
35
+ return development ? DEVELOPMENT_DOCS : PRODUCTION_DOCS;
36
+ }
37
+ /** Ask the foreground builder for a complete current set, including changes still being compiled. */
38
+ export async function currentPackageProfile(profile = packageProfile(), fetcher = fetch) {
39
+ if (profile.kind !== "local-development")
40
+ return profile;
41
+ let response;
42
+ try {
43
+ response = await fetcher(`${profile.registry}/-/userreason/dev-profile`, { redirect: "error", signal: AbortSignal.timeout(120_000), headers: { Accept: "application/json" } });
44
+ }
45
+ catch {
46
+ throw new Error("Local UserReason builder is unavailable. Start npm run dev:packages in ur-platform and retry.");
47
+ }
48
+ if (!response.ok)
49
+ throw new Error("The current local UserReason build failed. Check the dev:packages terminal and retry after fixing it.");
50
+ const text = await response.text();
51
+ if (text.length > 8192)
52
+ throw new Error("Invalid local UserReason build response");
53
+ const current = parsePackageProfile(JSON.parse(text));
54
+ if (current.registry !== profile.registry)
55
+ throw new Error("Local UserReason build registry does not match this wizard");
56
+ return current;
57
+ }
@@ -0,0 +1,9 @@
1
+ export interface ProjectTarget {
2
+ directory: string;
3
+ file: string;
4
+ siteId: string;
5
+ apiUrl: string;
6
+ workspaceId?: string;
7
+ }
8
+ /** Parse static SDK initialization only. Never execute application code or select an arbitrary account site. */
9
+ export declare function detectProjectTarget(directory?: string): Promise<ProjectTarget | undefined>;
@@ -0,0 +1,87 @@
1
+ import ts from 'typescript';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { apiUrl, uuid } from './contracts.js';
5
+ const explicit = 'Supply --workspace and --site (and --api-url or --dev for that environment).';
6
+ async function source(root, file) {
7
+ const path = resolve(root, file), rel = relative(root, path);
8
+ if (rel.startsWith('..') || !rel)
9
+ return;
10
+ let current = root;
11
+ for (const part of rel.split(sep)) {
12
+ current = join(current, part);
13
+ try {
14
+ if ((await lstat(current)).isSymbolicLink())
15
+ throw new Error(`Cannot inspect symlinked project file ${file}. ${explicit}`);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return;
20
+ throw error;
21
+ }
22
+ }
23
+ const info = await lstat(path);
24
+ if (!info.isFile() || info.size > 2_000_000)
25
+ throw new Error(`Cannot inspect project file ${file}. ${explicit}`);
26
+ return readFile(path, 'utf8');
27
+ }
28
+ /** Parse static SDK initialization only. Never execute application code or select an arbitrary account site. */
29
+ export async function detectProjectTarget(directory = process.cwd()) {
30
+ let root = await realpath(directory);
31
+ while (!(await source(root, 'package.json'))) {
32
+ const parent = dirname(root);
33
+ if (parent === root)
34
+ return;
35
+ root = parent;
36
+ }
37
+ const candidates = new Set();
38
+ for (const folder of ['', 'src/'])
39
+ for (const name of ['main', 'index', 'instrumentation-client', 'userreason.client', 'userreason.generated'])
40
+ for (const extension of ['ts', 'tsx', 'js', 'jsx', 'mjs'])
41
+ candidates.add(`${folder}${name}.${extension}`);
42
+ const html = await source(root, 'index.html');
43
+ for (const match of html?.matchAll(/<script\b([^>]*)>/gi) ?? []) {
44
+ const src = /\bsrc\s*=\s*['"]([^'"]+)['"]/i.exec(match[1])?.[1];
45
+ if (src && /\btype\s*=\s*['"]module['"]/i.test(match[1]) && !/^(?:[a-z]+:|\/\/)|[?#]/i.test(src))
46
+ candidates.add(src.replace(/^\//, ''));
47
+ }
48
+ let found;
49
+ for (const file of candidates) {
50
+ const text = await source(root, file);
51
+ if (!text)
52
+ continue;
53
+ const ast = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /x$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
54
+ const bindings = new Set(ast.statements.filter(ts.isImportDeclaration)
55
+ .filter(node => ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@userreason/js')
56
+ .map(node => node.importClause?.name?.text).filter(Boolean));
57
+ const visit = (node) => {
58
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'init' && bindings.has(node.expression.expression.getText(ast))) {
59
+ if (ast.parseDiagnostics?.length)
60
+ throw new Error(`Invalid SDK initialization in ${file}. ${explicit}`);
61
+ const options = node.arguments[0];
62
+ if (!options || !ts.isObjectLiteralExpression(options))
63
+ throw new Error(`Dynamic SDK initialization in ${file}. ${explicit}`);
64
+ const values = {};
65
+ for (const prop of options.properties) {
66
+ if (ts.isSpreadAssignment(prop))
67
+ throw new Error(`Spread SDK options in ${file}. ${explicit}`);
68
+ const name = prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) ? prop.name.text : '';
69
+ if (!['siteId', 'workspaceId', 'apiBaseUrl'].includes(name))
70
+ continue;
71
+ if (!ts.isPropertyAssignment(prop) || !(ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) || name in values)
72
+ throw new Error(`Dynamic SDK option ${name} in ${file}. ${explicit}`);
73
+ values[name] = prop.initializer.text;
74
+ }
75
+ if (values.siteId) {
76
+ const target = { directory: root, file, siteId: uuid(values.siteId, 'SDK siteId'), apiUrl: apiUrl(values.apiBaseUrl ?? 'https://api.userreason.com'), ...(values.workspaceId ? { workspaceId: uuid(values.workspaceId) } : {}) };
77
+ if (found && (found.siteId !== target.siteId || found.apiUrl !== target.apiUrl || found.workspaceId !== target.workspaceId))
78
+ throw new Error(`Multiple SDK targets in this project. ${explicit}`);
79
+ found = target;
80
+ }
81
+ }
82
+ ts.forEachChild(node, visit);
83
+ };
84
+ visit(ast);
85
+ }
86
+ return found;
87
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ type Role = 'accent' | 'good' | 'warn' | 'danger' | 'muted' | 'code' | 'faint';
2
+ export declare const stripAnsi: (value: string) => string;
3
+ export declare const textWidth: (value: string) => number;
4
+ /** 0 none, 1 sixteen colours, 2 256 colours, 3 truecolor. Honours NO_COLOR and FORCE_COLOR. */
5
+ export type ColorLevel = 0 | 1 | 2 | 3;
6
+ export declare function detectColorLevel({ stream, env, off }?: {
7
+ stream?: {
8
+ isTTY?: boolean;
9
+ };
10
+ env?: NodeJS.ProcessEnv;
11
+ off?: boolean;
12
+ }): ColorLevel;
13
+ export declare function detectAgentHarness(env?: NodeJS.ProcessEnv): string | null;
14
+ export interface UiKit {
15
+ level: ColorLevel;
16
+ c: Record<Role | 'bold', (text: string) => string>;
17
+ S: {
18
+ info: string;
19
+ done: string;
20
+ ask: string;
21
+ warn: string;
22
+ error: string;
23
+ section: string;
24
+ bar: string;
25
+ rule: string;
26
+ arrow: string;
27
+ dot: string;
28
+ frames: string[];
29
+ };
30
+ symbol: {
31
+ info: string;
32
+ done: string;
33
+ ask: string;
34
+ warn: string;
35
+ error: string;
36
+ };
37
+ cols(rows: [string, string?][], options?: {
38
+ gap?: number;
39
+ }): string[];
40
+ table(head: string[], rows: string[][]): string[];
41
+ json(value: unknown, indent?: number): string;
42
+ header(options: {
43
+ mode: string;
44
+ version: string;
45
+ detail?: string | string[];
46
+ trademark?: boolean;
47
+ }): string[];
48
+ }
49
+ export declare function createUiKit({ level, light, ascii, stream }?: {
50
+ level?: ColorLevel;
51
+ light?: boolean;
52
+ ascii?: boolean;
53
+ stream?: {
54
+ isTTY?: boolean;
55
+ };
56
+ }): UiKit;
57
+ /** Stable word prefixes for piped, CI and agent output, so lines can be filtered with grep. */
58
+ export type PlainKind = 'info' | 'done' | 'warn' | 'error' | 'skip' | 'next';
59
+ export declare const plainLine: (kind: PlainKind, text: string) => string;
60
+ /**
61
+ * A one-line spinner. Unlike clack's it leaves no guide line behind, so a finished task can be replaced
62
+ * by a single result line. Without a terminal it prints nothing while it runs.
63
+ */
64
+ export interface Spinner {
65
+ start(message: string): void;
66
+ message(message: string): void;
67
+ stop(finalLine?: string): void;
68
+ }
69
+ export declare function createSpinner(kit: UiKit, stream?: {
70
+ isTTY?: boolean;
71
+ write(chunk: string): unknown;
72
+ }, options?: {
73
+ onCancel?: () => void;
74
+ }): Spinner;
75
+ export {};