@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/README.md +94 -41
- package/dist/authorize.d.ts +29 -0
- package/dist/authorize.js +23 -0
- package/dist/bin.js +479 -49
- package/dist/capture.d.ts +18 -0
- package/dist/capture.js +256 -0
- package/dist/catalog.d.ts +16 -0
- package/dist/catalog.js +180 -0
- package/dist/client.d.ts +72 -1
- package/dist/client.js +120 -12
- package/dist/config.d.ts +4 -0
- package/dist/config.js +35 -12
- package/dist/contracts.d.ts +13 -3
- package/dist/contracts.js +4 -4
- package/dist/credentials.d.ts +12 -2
- package/dist/credentials.js +110 -26
- package/dist/docs.d.ts +51 -0
- package/dist/docs.js +143 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +38 -0
- package/dist/event-policy.d.ts +2 -0
- package/dist/event-policy.js +9 -0
- package/dist/help.d.ts +13 -0
- package/dist/help.js +74 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lock.js +19 -5
- package/dist/profile.d.ts +20 -0
- package/dist/profile.js +57 -0
- package/dist/project.d.ts +9 -0
- package/dist/project.js +87 -0
- package/dist/ui.d.ts +75 -0
- package/dist/ui.js +107 -0
- package/dist/views.d.ts +51 -0
- package/dist/views.js +93 -0
- package/dist/workos.js +5 -2
- package/package.json +28 -12
- package/skills/userreason/SKILL.md +45 -8
- package/skills/userreason/references/management.md +61 -0
package/dist/bin.js
CHANGED
|
@@ -1,63 +1,424 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { eventCollection } from './event-policy.js';
|
|
3
|
+
import { CaptureError, capturePage, captureUrl } from './capture.js';
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
2
5
|
import { parseArgs } from "node:util";
|
|
3
6
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { createReadStream } from "node:fs";
|
|
7
|
+
import { createReadStream, readFileSync } from "node:fs";
|
|
5
8
|
import { UserReasonClient, ApiError } from "./client.js";
|
|
6
9
|
import { resolveConfig, saveConfig } from "./config.js";
|
|
7
|
-
import {
|
|
10
|
+
import { resolveCredentials, CredentialStoreError } from "./credentials.js";
|
|
8
11
|
import { object, origin, scope, uuid } from "./contracts.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
12
|
+
import { DocsError, formatDocsSearch, getDocsPage, printable, searchDocs } from "./docs.js";
|
|
13
|
+
import { authorizeCli } from "./authorize.js";
|
|
14
|
+
import { detectProjectTarget } from "./project.js";
|
|
15
|
+
import { cliHelp, commandCatalog, getCommandReference, searchCommands } from './catalog.js';
|
|
16
|
+
import { AuthenticationError } from './workos.js';
|
|
17
|
+
import { ConfigurationError, NetworkError } from './errors.js';
|
|
18
|
+
import { docsOrigin, packageProfile } from "./profile.js";
|
|
19
|
+
import { createSpinner, createUiKit, detectAgentHarness, detectColorLevel } from './ui.js';
|
|
20
|
+
import { nounHelp, overviewHelp, overviewNouns, suggestCommand } from './help.js';
|
|
21
|
+
import { grantSummary, promptsView, resultsOverviewView, sitesView, whoamiView, workspacesView } from './views.js';
|
|
22
|
+
/** Terminal styling applies only to a person at a terminal: never when piped, with --json, or in an agent harness. */
|
|
23
|
+
let errorStyle;
|
|
24
|
+
const cliVersion = () => { try {
|
|
25
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
} };
|
|
30
|
+
const developmentApi = () => { try {
|
|
31
|
+
return packageProfile().apiEnvironment === 'development';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
} };
|
|
36
|
+
class UnknownCommand extends Error {
|
|
37
|
+
word;
|
|
38
|
+
suggestion;
|
|
39
|
+
constructor(word, suggestion) {
|
|
40
|
+
super(`Unknown command "${word}".${suggestion ? ` Did you mean userreason ${suggestion}?` : ''} Run userreason --help to see all commands.`);
|
|
41
|
+
this.word = word;
|
|
42
|
+
this.suggestion = suggestion;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Split an error message into its headline and the sentences that tell the person what to do. */
|
|
46
|
+
function splitMessage(message) {
|
|
47
|
+
const [first = message, ...rest] = message.split(/(?<=[.!?])\s+(?=[A-Z])/);
|
|
48
|
+
return { headline: first.replace(/\.$/, ''), hints: rest };
|
|
49
|
+
}
|
|
50
|
+
function humanError(kit, detail, error, retryInfo) {
|
|
51
|
+
const { c, S } = kit;
|
|
52
|
+
const lines = [];
|
|
53
|
+
let headline, hints;
|
|
54
|
+
if (error instanceof UnknownCommand) {
|
|
55
|
+
headline = `Unknown command ${c.code(error.word)}`;
|
|
56
|
+
hints = [...(error.suggestion ? [`${c.muted('Did you mean')} ${c.code(`userreason ${error.suggestion}`)}${c.muted('?')}`] : []), `${c.muted('Search:')} ${c.code(`userreason cli search "${error.word}"`)}`];
|
|
57
|
+
}
|
|
58
|
+
else if (error instanceof ApiError && error.code === 'authorization_required') {
|
|
59
|
+
headline = `Access required ${c.muted(`${S.dot} the current login lacks access to this target`)}`;
|
|
60
|
+
hints = [`${c.muted('Review workspace access:')} ${c.code('userreason access --open')}`, ...(detail.approval_url ? [`${c.muted('Approval link:')} ${c.code(detail.approval_url)}`] : [])];
|
|
61
|
+
}
|
|
62
|
+
else if (detail.error === 'invalid_grant') {
|
|
63
|
+
headline = `Not signed in ${c.muted(`${S.dot} your session expired or was revoked`)}`;
|
|
64
|
+
hints = [`${c.muted('Run')} ${c.code('userreason login --open')}`];
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const split = splitMessage(detail.message);
|
|
68
|
+
headline = split.headline;
|
|
69
|
+
hints = split.hints.map(hint => c.muted(hint));
|
|
70
|
+
}
|
|
71
|
+
if (retryInfo) {
|
|
72
|
+
const id = retryInfo.prompt_id ?? retryInfo.audience_id ?? retryInfo.event_definition_id;
|
|
73
|
+
hints.push(`${c.muted('Retry with the same input:')} ${c.code(`${id ? `--id ${id} ` : ''}--request-id ${retryInfo.request_id}`)}`, c.muted('Read the resource first if you change the request.'));
|
|
74
|
+
}
|
|
75
|
+
lines.push(`${kit.symbol.error} ${headline}`, ...hints.map(hint => `${c.danger(S.bar)} ${hint}`));
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
let retry;
|
|
79
|
+
async function readJsonFile(file) {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
for await (const part of createReadStream(file, { highWaterMark: 16_384 })) {
|
|
83
|
+
const chunk = Buffer.from(part);
|
|
84
|
+
size += chunk.byteLength;
|
|
85
|
+
if (size > 65_536)
|
|
86
|
+
throw new Error("JSON file exceeds 64 KB");
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
}
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error("File must contain valid UTF-8 JSON");
|
|
95
|
+
}
|
|
96
|
+
return object(body, "JSON file");
|
|
97
|
+
}
|
|
11
98
|
async function main() {
|
|
12
99
|
const parsed = parseArgs({ allowPositionals: true, strict: true, options: {
|
|
13
|
-
|
|
14
|
-
|
|
100
|
+
all: { type: "boolean" }, human: { type: "boolean" }, "no-color": { type: "boolean" },
|
|
101
|
+
width: { type: 'string' }, height: { type: 'string' }, 'capture-bin': { type: 'string' }, 'browser-executable': { type: 'string' }, interactive: { type: 'boolean' },
|
|
102
|
+
preview: { type: "boolean" }, confirm: { type: "string" }, help: { type: "boolean", short: "h" }, json: { type: "boolean" }, dev: { type: "boolean" }, open: { type: "boolean" }, wait: { type: "boolean" }, "no-wait": { type: "boolean" },
|
|
103
|
+
offline: { type: "boolean" }, live: { type: "boolean" }, "credential-store": { type: "string" }, "api-url": { type: "string" }, "docs-url": { type: "string" }, "client-id": { type: "string" }, name: { type: "string" }, workspace: { type: "string" }, id: { type: "string" },
|
|
15
104
|
origin: { type: "string", multiple: true }, scope: { type: "string", multiple: true }, site: { type: "string", multiple: true }, environment: { type: "string" },
|
|
16
105
|
"request-id": { type: "string" }, "workspace-id": { type: "string" }, "site-id": { type: "string" },
|
|
17
|
-
file: { type: "string" }, resource: { type: "string" }, limit: { type: "string" }, after: { type: "string" },
|
|
106
|
+
file: { type: "string" }, "context-file": { type: "string" }, purpose: { type: "string" }, mode: { type: "string" }, "confirmation-token": { type: "string" }, resource: { type: "string" }, limit: { type: "string" }, after: { type: "string" }, revision: { type: "string" }, "prompt-id": { type: "string" },
|
|
107
|
+
days: { type: "string" }, "time-zone": { type: "string" }, country: { type: "string" }, segment: { type: "string" }, by: { type: "string" }, user: { type: "string" }, kind: { type: "string" }, search: { type: "string" }, cursor: { type: "string" },
|
|
108
|
+
run: { type: 'string' }, variant: { type: 'string' }, 'all-attempts': { type: 'boolean' }, 'reported-only': { type: 'boolean' },
|
|
18
109
|
} });
|
|
19
|
-
const values = parsed.values, [command, action] = parsed.positionals;
|
|
110
|
+
const values = parsed.values, [command, action, positionalId] = parsed.positionals;
|
|
111
|
+
if (action === 'delete' && positionalId && command === 'workspace')
|
|
112
|
+
values.workspace = uuid(positionalId);
|
|
113
|
+
if (action === 'delete' && positionalId && command === 'site')
|
|
114
|
+
values.site = [uuid(positionalId)];
|
|
115
|
+
const harness = detectAgentHarness();
|
|
116
|
+
const humanOut = Boolean(process.stdout.isTTY) && !values.json && (!harness || Boolean(values.human));
|
|
117
|
+
const humanErr = Boolean(process.stderr.isTTY) && !values.json && (!harness || Boolean(values.human));
|
|
118
|
+
const kit = createUiKit({ level: detectColorLevel({ stream: process.stdout, off: values["no-color"] }) });
|
|
119
|
+
const errKit = createUiKit({ level: detectColorLevel({ stream: process.stderr, off: values["no-color"] }), stream: process.stderr });
|
|
120
|
+
errorStyle = { human: humanErr, kit: errKit };
|
|
20
121
|
if (values.help || !command) {
|
|
21
|
-
|
|
122
|
+
const noun = command && values.help ? nounHelp(kit, command) : undefined;
|
|
123
|
+
process.stdout.write(values.all ? cliHelp() : noun ?? overviewHelp(kit, { version: cliVersion(), development: values.dev ?? developmentApi(), trademark: humanOut }));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!overviewNouns.includes(command)) {
|
|
127
|
+
const guess = suggestCommand(command);
|
|
128
|
+
throw new UnknownCommand(command, guess);
|
|
129
|
+
}
|
|
130
|
+
// A command name without an action is a usage error, as before: the actions go to stderr and the exit code is 1.
|
|
131
|
+
if (!action && nounHelp(errKit, command)) {
|
|
132
|
+
process.stderr.write(nounHelp(errKit, command));
|
|
133
|
+
process.exitCode = 1;
|
|
22
134
|
return;
|
|
23
135
|
}
|
|
24
136
|
const required = (key) => { const value = values[key]; if (typeof value !== "string" || !value)
|
|
25
137
|
throw new Error(`Missing --${key}`); return value; };
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
138
|
+
if (command === "cli") {
|
|
139
|
+
const words = parsed.positionals.slice(2).join(' ');
|
|
140
|
+
const result = action === 'search' ? searchCommands(words, values.limit === undefined ? 5 : Number(values.limit)) : action === 'get' ? getCommandReference(words) : action === 'list' ? commandCatalog : undefined;
|
|
141
|
+
if (!result)
|
|
142
|
+
throw new Error('Use userreason cli search "QUESTION", cli get "COMMAND", or cli list');
|
|
143
|
+
process.stdout.write((humanOut ? kit.json(result) : JSON.stringify(result, null, values.json ? undefined : 2)) + '\n');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (values.offline && values.live)
|
|
147
|
+
throw new Error('Choose --offline or --live');
|
|
148
|
+
if (command === "docs") {
|
|
149
|
+
// Public documentation: no login, saved configuration or credential store is involved.
|
|
150
|
+
let projectDevelopment;
|
|
151
|
+
if (!values['docs-url'] && !process.env.UR_DOCS_URL && values.dev === undefined) {
|
|
152
|
+
try {
|
|
153
|
+
const target = await detectProjectTarget();
|
|
154
|
+
if (target && ['https://api.userreason.com', 'https://api-dev.userreason.com'].includes(target.apiUrl))
|
|
155
|
+
projectDevelopment = target.apiUrl === 'https://api-dev.userreason.com';
|
|
37
156
|
}
|
|
157
|
+
catch { /* Reading docs never requires a resolvable project. */ }
|
|
158
|
+
}
|
|
159
|
+
const docs = { offline: values.offline, live: values.live ?? false, origin: values["docs-url"] ?? (process.env.UR_DOCS_URL || docsOrigin(values.dev ?? projectDevelopment ?? packageProfile().apiEnvironment === "development")) }, words = parsed.positionals.slice(2);
|
|
160
|
+
if (action === "search") {
|
|
161
|
+
const found = await searchDocs({ ...docs, query: words.join(" "), limit: values.limit === undefined ? undefined : Number(values.limit) });
|
|
162
|
+
if (found.warning)
|
|
163
|
+
process.stderr.write(found.warning.message + '\nUsing the bundled CLI reference.\n');
|
|
164
|
+
process.stdout.write(values.json ? JSON.stringify(found) + "\n" : formatDocsSearch(found));
|
|
165
|
+
}
|
|
166
|
+
else if (action === "get") {
|
|
167
|
+
if (words.length > 1)
|
|
168
|
+
throw new Error("docs get reads one page");
|
|
169
|
+
const page = await getDocsPage({ ...docs, target: words[0] ?? "" });
|
|
170
|
+
process.stdout.write(values.json ? JSON.stringify(page) + "\n" : printable(page.markdown).trimEnd() + "\n");
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
throw new Error('Use userreason docs search "QUESTION" or userreason docs get URL_OR_PATH');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let project;
|
|
177
|
+
try {
|
|
178
|
+
project = await detectProjectTarget();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (!values.workspace && !values.site)
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
const explicitApi = values["api-url"] ?? process.env.UR_API_URL ?? (values.dev ? 'https://api-dev.userreason.com' : undefined);
|
|
185
|
+
if (project && !values.workspace && !values.site && explicitApi && new URL(explicitApi).origin !== project.apiUrl)
|
|
186
|
+
throw new Error('The selected API differs from the project SDK. Supply --site and --workspace explicitly to target another environment.');
|
|
187
|
+
const selectedApi = explicitApi ?? project?.apiUrl;
|
|
188
|
+
const development = values.dev ?? (selectedApi === 'https://api-dev.userreason.com' ? true : selectedApi === 'https://api.userreason.com' ? false : packageProfile().apiEnvironment === 'development');
|
|
189
|
+
const config = await resolveConfig({ development, apiUrl: selectedApi, clientId: values["client-id"], credentialStore: values["credential-store"] });
|
|
190
|
+
const credentials = resolveCredentials(config), client = new UserReasonClient(config, credentials);
|
|
191
|
+
const targeting = ['preview', 'prompt', 'audience', 'results', 'responses', 'user', 'event', 'member', 'query', 'site'].includes(command);
|
|
192
|
+
let targetName;
|
|
193
|
+
if (targeting && !values.workspace) {
|
|
194
|
+
const siteId = values.site?.length === 1 ? values.site[0] : project?.siteId;
|
|
195
|
+
if (siteId) {
|
|
196
|
+
const site = await client.siteById(uuid(siteId));
|
|
197
|
+
targetName = printable(site.name);
|
|
198
|
+
values.workspace = site.workspace_id;
|
|
199
|
+
if (!values.site && project && !(command === 'site' && ['list', 'create'].includes(action ?? '')))
|
|
200
|
+
values.site = [site.id];
|
|
201
|
+
if (!values.json)
|
|
202
|
+
process.stderr.write(humanErr
|
|
203
|
+
? `${errKit.symbol.info} ${errKit.c.muted(`Using ${printable(site.name)} (${site.environment})`)}\n`
|
|
204
|
+
: `Using ${printable(site.name)} (${site.id}) on ${config.apiUrl}\n`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let result;
|
|
208
|
+
if (command === "login" || command === "access") {
|
|
209
|
+
const open = (url) => {
|
|
210
|
+
if (!values.open)
|
|
211
|
+
return;
|
|
212
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
213
|
+
const child = spawn(command, [url], { stdio: "ignore", detached: true });
|
|
214
|
+
child.on("error", () => { });
|
|
215
|
+
child.unref();
|
|
216
|
+
};
|
|
217
|
+
let spinner = undefined;
|
|
218
|
+
const say = (kind, text) => process.stderr.write(`${errKit.symbol[kind]} ${text}\n`);
|
|
219
|
+
const link = (url) => process.stderr.write(`${errKit.c.faint(errKit.S.bar)} ${errKit.c.code(url)}\n`);
|
|
220
|
+
const wait = (text) => { spinner = createSpinner(errKit, process.stderr); spinner.start(text); };
|
|
221
|
+
result = await authorizeCli(config, credentials, {
|
|
222
|
+
client, wait: !values["no-wait"],
|
|
223
|
+
onDevice: device => {
|
|
224
|
+
if (!humanErr) {
|
|
225
|
+
process.stderr.write(`Open ${device.verificationUriComplete}\nConfirm code: ${device.userCode}\n`);
|
|
226
|
+
open(device.verificationUriComplete);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
say('info', `${values.open ? 'Opening the browser to sign in' : 'Open the link to sign in'} ${errKit.S.dot} ${errKit.c.muted('code')} ${errKit.c.code(device.userCode)}`);
|
|
230
|
+
if (!values.open)
|
|
231
|
+
link(device.verificationUriComplete);
|
|
232
|
+
open(device.verificationUriComplete);
|
|
233
|
+
wait('Waiting for sign-in');
|
|
234
|
+
},
|
|
235
|
+
onApproval: request => {
|
|
236
|
+
if (humanErr && spinner) {
|
|
237
|
+
spinner.stop();
|
|
238
|
+
spinner = undefined;
|
|
239
|
+
say('done', 'Signed in');
|
|
240
|
+
}
|
|
241
|
+
if (!request.approval_url)
|
|
242
|
+
return;
|
|
243
|
+
if (!humanErr) {
|
|
244
|
+
process.stderr.write(`Review workspace access and permissions: ${request.approval_url}\n`);
|
|
245
|
+
open(request.approval_url);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
say('info', `Review workspace access and permissions in the browser`);
|
|
249
|
+
if (!values.open)
|
|
250
|
+
link(request.approval_url);
|
|
251
|
+
open(request.approval_url);
|
|
252
|
+
if (!values["no-wait"])
|
|
253
|
+
wait('Waiting for approval');
|
|
254
|
+
},
|
|
38
255
|
});
|
|
39
|
-
|
|
40
|
-
|
|
256
|
+
spinner?.stop();
|
|
257
|
+
if (humanErr && !values["no-wait"]) {
|
|
258
|
+
const grant = result.grant;
|
|
259
|
+
const summary = grantSummary(grant);
|
|
260
|
+
say('done', `Access approved${summary ? ` ${errKit.c.muted(`(${summary})`)}` : ''}`);
|
|
261
|
+
}
|
|
262
|
+
await saveConfig(config, development);
|
|
41
263
|
}
|
|
42
264
|
else if (command === "logout") {
|
|
43
265
|
await credentials.clear();
|
|
44
266
|
result = { logged_out: true, message: "Local credentials removed. Revoke approved agent access in the dashboard if needed." };
|
|
45
267
|
}
|
|
46
|
-
else if (command === "whoami")
|
|
47
|
-
|
|
268
|
+
else if (command === "whoami") {
|
|
269
|
+
const account = await client.account();
|
|
270
|
+
let target = null;
|
|
271
|
+
if (project) {
|
|
272
|
+
try {
|
|
273
|
+
target = { ...project, site: await client.siteById(project.siteId), access: 'approved' };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!(error instanceof ApiError))
|
|
277
|
+
throw error;
|
|
278
|
+
target = { ...project, access: error.code, next_step: 'userreason access --open' };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
result = { ...account, api_url: config.apiUrl, project: target };
|
|
282
|
+
}
|
|
283
|
+
else if (command === 'preview' && action === 'capture') {
|
|
284
|
+
if (!positionalId || parsed.positionals.length !== 3)
|
|
285
|
+
throw new Error('Use userreason preview capture URL --dev');
|
|
286
|
+
const url = captureUrl(positionalId);
|
|
287
|
+
if (values.site?.length !== 1)
|
|
288
|
+
throw new Error('Capture requires one --site or a detected project site');
|
|
289
|
+
const workspaceId = uuid(required('workspace')), siteId = uuid(values.site[0]);
|
|
290
|
+
// Check the environment and existing read access before launching Chrome or downloading tools.
|
|
291
|
+
await client.previewCaptures(workspaceId, siteId, true);
|
|
292
|
+
if (!values.json)
|
|
293
|
+
process.stderr.write('Capturing locally with SingleFile. Only the HTML capture is uploaded.\n');
|
|
294
|
+
const start = performance.now();
|
|
295
|
+
const html = await capturePage(url.href, {
|
|
296
|
+
width: values.width === undefined ? undefined : Number(values.width), height: values.height === undefined ? undefined : Number(values.height),
|
|
297
|
+
engine: values['capture-bin'], browser: values['browser-executable'],
|
|
298
|
+
interactive: values.interactive,
|
|
299
|
+
onProgress: values.json ? undefined : message => process.stderr.write(message + '\n'),
|
|
300
|
+
});
|
|
301
|
+
const captureMs = performance.now() - start, uploadStart = performance.now();
|
|
302
|
+
if (!values.json)
|
|
303
|
+
process.stderr.write(`Captured ${(html.byteLength / 1024).toFixed(0)} KiB. Uploading to the development preview…\n`);
|
|
304
|
+
const saved = object(await client.uploadPreview(workspaceId, siteId, values.name ?? url.hostname, html), 'capture');
|
|
305
|
+
result = { ...saved, capture_ms: Math.round(captureMs), upload_ms: Math.round(performance.now() - uploadStart) };
|
|
306
|
+
if (values.open) {
|
|
307
|
+
const links = object(saved.links, 'capture links'), target = new URL(String(links.preview));
|
|
308
|
+
if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password)
|
|
309
|
+
throw new Error('Invalid dashboard preview URL');
|
|
310
|
+
const child = spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open', [target.href], { stdio: 'ignore', detached: true });
|
|
311
|
+
child.on('error', () => { });
|
|
312
|
+
child.unref();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (command === 'deletion' && action === 'status')
|
|
316
|
+
result = await client.deletion('status', { workspace_id: uuid(required('workspace')), id: uuid(required('id')) });
|
|
317
|
+
else if (['workspace', 'site', 'prompt'].includes(command) && action === 'delete') {
|
|
318
|
+
const workspace_id = uuid(required('workspace'));
|
|
319
|
+
const resource_id = command === 'workspace' ? workspace_id : command === 'site' ? uuid(positionalId ?? values.site?.[0] ?? required('id')) : uuid(positionalId ?? required('id'));
|
|
320
|
+
if (command !== 'workspace' && values.site?.length !== 1)
|
|
321
|
+
throw new Error('Deletion requires exactly one --site');
|
|
322
|
+
const target = { workspace_id, resource_type: command, resource_id, ...(command !== 'workspace' ? { site_id: uuid(values.site[0]) } : {}) };
|
|
323
|
+
if (values.preview || !values.confirm)
|
|
324
|
+
result = await client.deletion('preview', target);
|
|
325
|
+
else {
|
|
326
|
+
const request_id = uuid(values['request-id'] ?? randomUUID());
|
|
327
|
+
retry = { request_id };
|
|
328
|
+
result = await client.deletion('request', { ...target, request_id, expected_revision: required('revision'), confirmation_token: required('confirmation-token'), confirm_name: values.confirm });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
48
331
|
else if (command === "workspace" && action === "list")
|
|
49
332
|
result = (await client.account()).workspaces;
|
|
50
333
|
else if (command === "workspace" && action === "create")
|
|
51
334
|
result = await client.createWorkspace(required("name"), { requestId: values["request-id"], workspaceId: values["workspace-id"] });
|
|
52
335
|
else if (command === "site" && action === "list")
|
|
53
336
|
result = await client.sites(uuid(required("workspace")));
|
|
337
|
+
else if (["results", "responses", "user", "event", "member"].includes(command) || (command === "site" && (action === "activity" || action === "frequency" || action === "events")) || (command === "prompt" && action === "history")) {
|
|
338
|
+
// Reads the dashboard also shows, and the two site settings with commands of their own.
|
|
339
|
+
const workspaceId = uuid(required("workspace"));
|
|
340
|
+
if (command === "member") {
|
|
341
|
+
if (action !== "list")
|
|
342
|
+
throw new Error("Use userreason member list");
|
|
343
|
+
result = await client.read('members.list', { workspaceId });
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
if (!values.site || values.site.length !== 1)
|
|
347
|
+
throw new Error("This command requires one --site");
|
|
348
|
+
const context = { workspaceId, siteId: uuid(values.site[0]) }, sub = parsed.positionals[2];
|
|
349
|
+
const number = (key) => values[key] === undefined ? undefined : Number(values[key]);
|
|
350
|
+
const segment = values.segment === undefined ? [] : values.segment.split("=");
|
|
351
|
+
if (values.segment !== undefined && (segment.length !== 2 || !segment[0]))
|
|
352
|
+
throw new Error("Use --segment key=value, such as --segment browser=Chrome");
|
|
353
|
+
const range = { days: number("days"), time_zone: values["time-zone"] };
|
|
354
|
+
const filters = { country: values.country, ...(segment.length ? { segment_key: segment[0], segment_value: segment[1] } : {}) };
|
|
355
|
+
const page = { cursor: values.cursor, limit: number("limit") };
|
|
356
|
+
const requestId = uuid(values["request-id"] ?? randomUUID());
|
|
357
|
+
const send = async (body, ids = {}) => {
|
|
358
|
+
retry = { request_id: requestId, ...ids };
|
|
359
|
+
const sent = { ...object(await client.command({ ...body, workspace_id: context.workspaceId, site_id: context.siteId, request_id: requestId }), 'result'), ...retry };
|
|
360
|
+
retry = undefined;
|
|
361
|
+
return sent;
|
|
362
|
+
};
|
|
363
|
+
if (command === "results" && action === "overview")
|
|
364
|
+
result = await client.read('insights.overview', { ...context, ...range, ...filters });
|
|
365
|
+
else if (command === "results" && action === "prompt")
|
|
366
|
+
result = await client.read('insights.prompt', { ...context, ...range, ...filters, prompt_id: uuid(positionalId ?? required("prompt-id")) });
|
|
367
|
+
else if (command === "results" && action === "experiments")
|
|
368
|
+
result = await client.read('insights.experiments', { ...context, prompt_id: uuid(positionalId ?? required('prompt-id')), run_id: values.run, all_attempts: values['all-attempts'] ?? false, reported_only: values['reported-only'] ?? false });
|
|
369
|
+
else if (command === "results" && action === "breakdown")
|
|
370
|
+
result = await client.read('insights.breakdown', { ...context, ...range, ...filters, by: required("by"), prompt_id: values["prompt-id"] });
|
|
371
|
+
else if (command === "responses" && action === "list")
|
|
372
|
+
result = await client.read('responses.list', { ...context, ...filters, ...page, days: number("days"), prompt_id: values["prompt-id"], user_id: values.user, search: values.search, run_id: values.run, variant_id: values.variant });
|
|
373
|
+
else if (command === "user" && action === "list")
|
|
374
|
+
result = await client.read('users.list', { ...context, ...page, days: number("days"), kind: values.kind, country: values.country, search: values.search });
|
|
375
|
+
else if (command === "user" && action === "get")
|
|
376
|
+
result = await client.read('users.get', { ...context, user_id: uuid(positionalId ?? required("user")) });
|
|
377
|
+
else if (command === "user" && action === "eligibility") {
|
|
378
|
+
// A file tries an audience that is not published yet on this person: an audience file, or the rule alone.
|
|
379
|
+
const file = values.file ? await readJsonFile(values.file) : undefined;
|
|
380
|
+
result = await client.read('users.eligibility', { ...context, user_id: uuid(positionalId ?? required("user")), draft: file ? { audience: 'rule' in file && !('type' in file) ? file.rule : file } : undefined });
|
|
381
|
+
}
|
|
382
|
+
else if (command === "prompt")
|
|
383
|
+
result = await client.read('prompts.history', context);
|
|
384
|
+
else if (command === "event" && action === "overview")
|
|
385
|
+
result = await client.read('events.overview', { ...context, ...range });
|
|
386
|
+
else if (command === "event" && action === "list")
|
|
387
|
+
result = await client.query({ ...context, resource: 'events', limit: number("limit"), after: values.after });
|
|
388
|
+
else if (command === "event" && (action === "create" || action === "update")) {
|
|
389
|
+
const eventId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
|
|
390
|
+
const file = await readJsonFile(required("file"));
|
|
391
|
+
result = await send({ action: `event.${action}`, event_definition_id: eventId, ...(action === 'create' ? { event_key: file.event_key } : { expected_revision: required("revision") }), properties: file.properties ?? {}, collection: eventCollection(file, values.purpose) }, { event_definition_id: eventId });
|
|
392
|
+
}
|
|
393
|
+
else if (command === "site" && action === "events") {
|
|
394
|
+
const mode = required("mode");
|
|
395
|
+
if (!['all', 'registered', 'explicit'].includes(mode))
|
|
396
|
+
throw new Error('Mode must be all, registered or explicit');
|
|
397
|
+
const limits = values.file ? (await readJsonFile(values.file)) : undefined;
|
|
398
|
+
if (sub === 'preview')
|
|
399
|
+
result = await client.query({ ...context, resource: 'event_capture', eventCaptureMode: mode, eventCollectionLimits: limits });
|
|
400
|
+
else if (sub === 'set')
|
|
401
|
+
result = await send({ action: 'site.events.update', event_capture_mode: mode, event_collection_limits: limits, expected_revision: required('revision'), confirmation_token: values['confirmation-token'] });
|
|
402
|
+
else
|
|
403
|
+
throw new Error('Use site events preview or site events set');
|
|
404
|
+
}
|
|
405
|
+
else if (command === "site" && action === "activity")
|
|
406
|
+
result = await client.read('site.activity', context);
|
|
407
|
+
else if (command === "site" && sub === "get") {
|
|
408
|
+
const record = await client.siteRecord(context);
|
|
409
|
+
result = { site_id: record.id, revision: record.revision, frequency_policy: record.frequency_policy ?? null };
|
|
410
|
+
}
|
|
411
|
+
else if (command === "site" && (sub === "set" || sub === "reset"))
|
|
412
|
+
result = await send({ action: 'site.frequency.update', expected_revision: required("revision"), frequency_policy: sub === "set" ? await readJsonFile(required("file")) : null });
|
|
413
|
+
else
|
|
414
|
+
throw new Error(`Unknown ${command} action. Run userreason --help`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
54
417
|
else if (command === "site" && action === "create") {
|
|
55
418
|
const environment = values.environment ?? (values.dev ? "development" : "production");
|
|
56
419
|
if (environment !== "production" && environment !== "development")
|
|
57
420
|
throw new Error("Invalid --environment");
|
|
58
|
-
|
|
59
|
-
throw new Error("Supply one or more --origin values");
|
|
60
|
-
result = await client.createSite({ workspaceId: uuid(required("workspace")), name: required("name"), environment, origins: [...new Set(values.origin.map(value => origin(value, environment)))], requestId: values["request-id"], siteId: values["site-id"] });
|
|
421
|
+
result = await client.createSite({ workspaceId: uuid(required("workspace")), name: required("name"), environment, origins: [...new Set((values.origin ?? []).map(value => origin(value, environment)))], requestId: values["request-id"], siteId: values["site-id"] });
|
|
61
422
|
}
|
|
62
423
|
else if (command === "grant" && action === "request") {
|
|
63
424
|
const request = await client.requestGrant({ workspaceId: values.workspace, siteIds: values.site, scopes: (values.scope ?? []).flatMap(value => value.split(",")).map(scope), requestId: values["request-id"] });
|
|
@@ -68,36 +429,105 @@ async function main() {
|
|
|
68
429
|
else if (command === "grant" && action === "status")
|
|
69
430
|
result = values.wait ? await client.waitForGrant(required("id")) : await client.grantStatus(required("id"));
|
|
70
431
|
else if (command === "command") {
|
|
71
|
-
|
|
72
|
-
let size = 0;
|
|
73
|
-
for await (const part of createReadStream(required("file"), { highWaterMark: 16_384 })) {
|
|
74
|
-
const chunk = Buffer.from(part);
|
|
75
|
-
size += chunk.byteLength;
|
|
76
|
-
if (size > 65_536)
|
|
77
|
-
throw new Error("Command JSON file exceeds 64 KB");
|
|
78
|
-
chunks.push(chunk);
|
|
79
|
-
}
|
|
80
|
-
let body;
|
|
81
|
-
try {
|
|
82
|
-
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
throw new Error("Command file must contain valid UTF-8 JSON");
|
|
86
|
-
}
|
|
87
|
-
result = await client.command(object(body, "command"));
|
|
432
|
+
result = await client.command(await readJsonFile(required("file")));
|
|
88
433
|
}
|
|
89
434
|
else if (command === "query") {
|
|
90
435
|
if (values.site && values.site.length !== 1)
|
|
91
436
|
throw new Error("A query accepts one --site");
|
|
92
|
-
result = await client.query({ workspaceId: required("workspace"), siteId: values.site?.[0], resource: required("resource"), limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after });
|
|
437
|
+
result = await client.query({ workspaceId: required("workspace"), siteId: values.site?.[0], resource: required("resource"), limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after, id: values.id, promptId: values["prompt-id"] });
|
|
438
|
+
}
|
|
439
|
+
else if (command === "prompt") {
|
|
440
|
+
if (!values.site || values.site.length !== 1)
|
|
441
|
+
throw new Error("Survey commands require one --site");
|
|
442
|
+
const context = { workspaceId: uuid(required("workspace")), siteId: uuid(values.site[0]) };
|
|
443
|
+
const page = { limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after };
|
|
444
|
+
if (action === "validate")
|
|
445
|
+
result = await client.read('prompts.validate', { ...context, definition: await readJsonFile(required("file")), ...(values["context-file"] ? await readJsonFile(values["context-file"]) : {}) });
|
|
446
|
+
else if (action === "list")
|
|
447
|
+
result = await client.prompts({ ...context, ...page });
|
|
448
|
+
else {
|
|
449
|
+
if (positionalId && values.id && positionalId !== values.id)
|
|
450
|
+
throw new Error("Survey IDs disagree");
|
|
451
|
+
const promptId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
|
|
452
|
+
if (action === "get")
|
|
453
|
+
result = await client.prompt({ ...context, promptId });
|
|
454
|
+
else if (action === "responses")
|
|
455
|
+
result = await client.query({ ...context, ...page, resource: 'responses', promptId });
|
|
456
|
+
else {
|
|
457
|
+
if (!["create", "update", "publish", "pause", "resume", "archive"].includes(action ?? ''))
|
|
458
|
+
throw new Error("Unknown survey action");
|
|
459
|
+
const definition = action === 'create' || action === 'update' ? await readJsonFile(required("file")) : undefined;
|
|
460
|
+
const requestId = uuid(values["request-id"] ?? randomUUID());
|
|
461
|
+
retry = { request_id: requestId, prompt_id: promptId };
|
|
462
|
+
result = { ...object(await client.promptCommand({ ...context, action: action, promptId, requestId, revision: values.revision, definition }), 'survey result'), ...retry };
|
|
463
|
+
retry = undefined;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (command === "audience") {
|
|
468
|
+
if (!values.site || values.site.length !== 1)
|
|
469
|
+
throw new Error("Audience commands require one --site");
|
|
470
|
+
const context = { workspaceId: uuid(required("workspace")), siteId: uuid(values.site[0]) };
|
|
471
|
+
if (action === "list")
|
|
472
|
+
result = await client.audiences({ ...context, limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after });
|
|
473
|
+
else {
|
|
474
|
+
if (positionalId && values.id && positionalId !== values.id)
|
|
475
|
+
throw new Error("Audience IDs disagree");
|
|
476
|
+
const audienceId = uuid(positionalId ?? values.id ?? (action === 'create' ? randomUUID() : required("id")));
|
|
477
|
+
if (action === "get")
|
|
478
|
+
result = await client.audience({ ...context, audienceId });
|
|
479
|
+
else {
|
|
480
|
+
if (!["create", "update", "publish", "discard", "archive"].includes(action ?? ''))
|
|
481
|
+
throw new Error("Unknown audience action");
|
|
482
|
+
const audience = action === 'create' || action === 'update' ? await readJsonFile(required("file")) : undefined;
|
|
483
|
+
const requestId = uuid(values["request-id"] ?? randomUUID());
|
|
484
|
+
retry = { request_id: requestId, audience_id: audienceId };
|
|
485
|
+
result = { ...object(await client.audienceCommand({ ...context, action: action, audienceId, requestId, revision: values.revision, audience }), 'audience result'), ...retry };
|
|
486
|
+
retry = undefined;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
93
489
|
}
|
|
94
490
|
else
|
|
95
491
|
throw new Error("Unknown command. Run userreason --help");
|
|
96
|
-
|
|
492
|
+
if (command === 'logout' && humanErr) {
|
|
493
|
+
process.stderr.write(`${errKit.symbol.done} Signed out ${errKit.c.muted(`${errKit.S.dot} local credentials removed`)}\n${errKit.symbol.info} ${errKit.c.muted('Revoke approved agent access in the dashboard if needed.')}\n`);
|
|
494
|
+
if (!humanOut)
|
|
495
|
+
process.stdout.write(JSON.stringify(result, null, values.json ? undefined : 2) + "\n");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// A person at a terminal has just read the progress lines; the JSON result needs --json or a pipe.
|
|
499
|
+
if ((command === 'login' || command === 'access') && humanOut && !values["no-wait"])
|
|
500
|
+
return;
|
|
501
|
+
const view = humanOut ? humanView(kit, command, action, result) : undefined;
|
|
502
|
+
process.stdout.write((view ? view.join('\n') : humanOut ? kit.json(result) : JSON.stringify(result, null, values.json ? undefined : 2)) + "\n");
|
|
503
|
+
function humanView(ui, name, verb, data) {
|
|
504
|
+
try {
|
|
505
|
+
if (name === 'whoami')
|
|
506
|
+
return whoamiView(ui, data, { development });
|
|
507
|
+
if (name === 'site' && verb === 'list' && Array.isArray(data))
|
|
508
|
+
return sitesView(ui, data);
|
|
509
|
+
if (name === 'workspace' && verb === 'list' && Array.isArray(data))
|
|
510
|
+
return workspacesView(ui, data);
|
|
511
|
+
if (name === 'prompt' && verb === 'list' && data && typeof data === 'object')
|
|
512
|
+
return promptsView(ui, data, targetName);
|
|
513
|
+
if (name === 'results' && verb === 'overview' && data && typeof data === 'object')
|
|
514
|
+
return resultsOverviewView(ui, data, targetName);
|
|
515
|
+
}
|
|
516
|
+
catch { /* A response in an unexpected shape falls back to highlighted JSON. */ }
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
97
519
|
}
|
|
98
520
|
main().catch(error => {
|
|
99
521
|
const json = process.argv.includes("--json");
|
|
100
|
-
const detail = { error: error instanceof ApiError ? error.code : "command_failed", message: error instanceof Error ? error.message : "Command failed", ...(error instanceof ApiError && error.approvalUrl ? { approval_url: error.approvalUrl } : {}) };
|
|
101
|
-
|
|
102
|
-
|
|
522
|
+
const detail = { error: error instanceof ApiError || error instanceof DocsError || error instanceof CredentialStoreError || error instanceof NetworkError || error instanceof AuthenticationError || error instanceof ConfigurationError || error instanceof CaptureError ? error.code : "command_failed", message: error instanceof Error ? error.message : "Command failed", ...(error instanceof ApiError && error.approvalUrl ? { approval_url: error.approvalUrl } : {}), ...(retry ? { retry } : {}) };
|
|
523
|
+
const exitCode = error instanceof CaptureError && error.code === 'capture_cancelled' ? 130 : 1;
|
|
524
|
+
if (error instanceof ApiError && error.code === 'authorization_required')
|
|
525
|
+
detail.message += '. The current login lacks access to this target. Run userreason access --open to review workspace access and permissions.';
|
|
526
|
+
if (!json && errorStyle?.human) {
|
|
527
|
+
process.stderr.write(humanError(errorStyle.kit, detail, error, retry).join('\n') + '\n');
|
|
528
|
+
process.exitCode = exitCode;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
process.stderr.write((json ? JSON.stringify(detail) : detail.message + (retry ? `\nRetry with the same input, --id ${retry.prompt_id} --request-id ${retry.request_id}; read the survey first if changing the request.` : "")) + "\n");
|
|
532
|
+
process.exitCode = exitCode;
|
|
103
533
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** External, pinned capture tool; no SingleFile source is bundled into the UserReason package. */
|
|
2
|
+
export declare const CAPTURE_ENGINE = "single-file-cli@2.0.77";
|
|
3
|
+
export type CaptureOptions = {
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
engine?: string;
|
|
7
|
+
browser?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
interactive?: boolean;
|
|
10
|
+
onProgress?: (message: string) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare class CaptureError extends Error {
|
|
13
|
+
code: string;
|
|
14
|
+
constructor(code: string, message: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function captureUrl(value: string): URL;
|
|
17
|
+
export declare function captureArguments(url: string, output: string, options?: CaptureOptions): string[];
|
|
18
|
+
export declare function capturePage(value: string, options?: CaptureOptions): Promise<Uint8Array>;
|