@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.
- package/README.md +301 -41
- package/dist/authorize.d.ts +29 -0
- package/dist/authorize.js +23 -0
- package/dist/bin.js +565 -50
- package/dist/capture.d.ts +18 -0
- package/dist/capture.js +256 -0
- package/dist/catalog.d.ts +16 -0
- package/dist/catalog.js +206 -0
- package/dist/client.d.ts +79 -2
- package/dist/client.js +140 -17
- 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/first-survey-preparation.d.ts +8 -0
- package/dist/first-survey-preparation.js +29 -0
- package/dist/first-survey-source.d.ts +60 -0
- package/dist/first-survey-source.js +289 -0
- package/dist/first-survey-terminal.d.ts +5 -0
- package/dist/first-survey-terminal.js +16 -0
- package/dist/first-survey.d.ts +88 -0
- package/dist/first-survey.js +227 -0
- package/dist/help.d.ts +13 -0
- package/dist/help.js +74 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -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/recommended-surveys.d.ts +30 -0
- package/dist/recommended-surveys.js +201 -0
- package/dist/setup-executor.d.ts +35 -0
- package/dist/setup-executor.js +329 -0
- package/dist/setup-files.d.ts +11 -0
- package/dist/setup-files.js +96 -0
- package/dist/setup-runner.d.ts +14 -0
- package/dist/setup-runner.js +142 -0
- package/dist/setup-source.d.ts +31 -0
- package/dist/setup-source.js +48 -0
- package/dist/setup-store.d.ts +37 -0
- package/dist/setup-store.js +256 -0
- package/dist/setup-terminal.d.ts +3 -0
- package/dist/setup-terminal.js +6 -0
- package/dist/setup-wire.d.ts +1237 -0
- package/dist/setup-wire.js +175 -0
- package/dist/setup.d.ts +1223 -0
- package/dist/setup.js +258 -0
- package/dist/starter-survey.d.ts +22 -0
- package/dist/starter-survey.js +457 -0
- package/dist/telemetry.d.ts +67 -0
- package/dist/telemetry.js +304 -0
- package/dist/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.d.ts +1 -1
- package/dist/workos.js +9 -4
- package/package.json +29 -12
- package/skills/userreason/SKILL.md +88 -8
- package/skills/userreason/references/management.md +67 -0
package/dist/client.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { object, string, uuid, workspace, site, grantRequest } from "./contracts.js";
|
|
3
|
-
import { refresh } from "./workos.js";
|
|
3
|
+
import { refresh, AuthenticationError } from "./workos.js";
|
|
4
4
|
import { withSessionLock } from "./lock.js";
|
|
5
|
+
import { fetchWithErrors } from './errors.js';
|
|
6
|
+
/** What the dashboard reads beside records: results, the response feed, users, events and members. */
|
|
7
|
+
export const READ_OPERATIONS = ['workspace.ai', 'conversation.transcript', 'insights.conversations', 'insights.overview', 'insights.prompt', 'insights.experiments', 'insights.breakdown', 'responses.list', 'users.list', 'users.get', 'users.eligibility', 'prompts.validate', 'prompts.history', 'events.overview', 'site.activity', 'members.list'];
|
|
5
8
|
export class ApiError extends Error {
|
|
6
9
|
status;
|
|
7
10
|
code;
|
|
@@ -24,27 +27,48 @@ export class UserReasonClient {
|
|
|
24
27
|
this.options = options;
|
|
25
28
|
this.fetcher = options.fetcher ?? fetch;
|
|
26
29
|
}
|
|
27
|
-
async token(forceRefresh = false, previous) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
+
async token(forceRefresh = false, previous, signal) {
|
|
31
|
+
const read = async () => {
|
|
32
|
+
signal?.throwIfAborted();
|
|
30
33
|
const current = await this.credentials.load();
|
|
31
34
|
if (!current)
|
|
32
|
-
throw new
|
|
35
|
+
throw new AuthenticationError('login_required', 'Run userreason login --open first. Existing Keychain logins can be used with --credential-store keychain.');
|
|
33
36
|
if (current.apiUrl !== this.config.apiUrl || current.clientId !== this.config.clientId)
|
|
34
37
|
throw new Error("Stored login belongs to a different environment or WorkOS client");
|
|
35
|
-
|
|
38
|
+
return current;
|
|
39
|
+
};
|
|
40
|
+
const usable = (session) => session.expiresAt > Date.now() + 60_000 && (!forceRefresh || session.accessToken !== previous);
|
|
41
|
+
const initial = await read();
|
|
42
|
+
if (usable(initial))
|
|
43
|
+
return initial;
|
|
44
|
+
if (initial.accessToken.startsWith('ur-install.'))
|
|
45
|
+
throw new AuthenticationError('installation_expired', 'Installation access expired or was revoked; rerun the wizard to reconnect');
|
|
46
|
+
const lock = this.options.lock ?? ((operation) => withSessionLock(this.config, operation));
|
|
47
|
+
return lock(async () => {
|
|
48
|
+
const current = await read();
|
|
49
|
+
if (usable(current))
|
|
36
50
|
return current;
|
|
37
|
-
|
|
51
|
+
if (current.accessToken.startsWith('ur-install.'))
|
|
52
|
+
throw new AuthenticationError('installation_expired', 'Installation access expired or was revoked; rerun the wizard to reconnect');
|
|
53
|
+
return refresh(this.config, this.credentials, current, this.fetcher, signal);
|
|
38
54
|
});
|
|
39
55
|
}
|
|
40
|
-
async request(path, body) {
|
|
56
|
+
async request(path, body, signal) {
|
|
57
|
+
return this.authenticatedRequest(path, body === undefined ? undefined : JSON.stringify(body), 'application/json', signal);
|
|
58
|
+
}
|
|
59
|
+
async authenticatedRequest(path, body, contentType, signal) {
|
|
41
60
|
if (!path.startsWith("/onboarding/v1/") || path.includes("//") || path.includes("\\"))
|
|
42
61
|
throw new Error("Invalid UserReason API path");
|
|
43
|
-
|
|
62
|
+
// Planning may use the API's full 40-second deadline. One transport budget
|
|
63
|
+
// also bounds authentication refresh; cancellation must survive a retry.
|
|
64
|
+
const requestSignal = AbortSignal.any([AbortSignal.timeout(path === '/onboarding/v1/planning/analyze' ? 45_000 : 20_000), ...(signal ? [signal] : [])]);
|
|
65
|
+
requestSignal.throwIfAborted();
|
|
66
|
+
let session = await this.token(false, undefined, requestSignal);
|
|
44
67
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
45
|
-
|
|
68
|
+
requestSignal.throwIfAborted();
|
|
69
|
+
const response = await fetchWithErrors(this.fetcher, this.config.apiUrl + path, { method: body === undefined ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session.accessToken}`, "x-ur-client": "cli", "Content-Type": contentType }, ...(body === undefined ? {} : { body: body }), signal: requestSignal }, 'UserReason API request');
|
|
46
70
|
if (response.status === 401 && attempt === 0) {
|
|
47
|
-
session = await this.token(true, session.accessToken);
|
|
71
|
+
session = await this.token(true, session.accessToken, requestSignal);
|
|
48
72
|
continue;
|
|
49
73
|
}
|
|
50
74
|
let result;
|
|
@@ -73,6 +97,23 @@ export class UserReasonClient {
|
|
|
73
97
|
}
|
|
74
98
|
throw new ApiError(401, "authentication_required");
|
|
75
99
|
}
|
|
100
|
+
async previewCaptures(workspaceId, siteId, forUpload = false) {
|
|
101
|
+
const query = new URLSearchParams({ workspace_id: uuid(workspaceId), site_id: uuid(siteId) });
|
|
102
|
+
if (forUpload)
|
|
103
|
+
query.set('intent', 'upload');
|
|
104
|
+
return this.request(`/onboarding/v1/dev/preview/captures?${query}`);
|
|
105
|
+
}
|
|
106
|
+
async uploadPreview(workspaceId, siteId, name, html) {
|
|
107
|
+
if (!name.trim() || name.length > 100)
|
|
108
|
+
throw new Error('Capture name must contain 1–100 characters');
|
|
109
|
+
if (!html.byteLength || html.byteLength > 4 * 1024 * 1024)
|
|
110
|
+
throw new Error('Capture must be between 1 byte and 4 MiB');
|
|
111
|
+
const query = new URLSearchParams({ workspace_id: uuid(workspaceId), site_id: uuid(siteId), name: name.trim() });
|
|
112
|
+
return this.authenticatedRequest(`/onboarding/v1/dev/preview/captures?${query}`, html, 'text/html; charset=utf-8');
|
|
113
|
+
}
|
|
114
|
+
async deletion(operation, input) {
|
|
115
|
+
return this.request(`/onboarding/v1/deletions/${operation}`, input);
|
|
116
|
+
}
|
|
76
117
|
async account() {
|
|
77
118
|
const data = object(await this.request("/onboarding/v1/account"), "account"), user = object(data.user, "user");
|
|
78
119
|
uuid(string(user.id, "user ID"));
|
|
@@ -92,35 +133,117 @@ export class UserReasonClient {
|
|
|
92
133
|
throw new Error("Invalid site list");
|
|
93
134
|
return items.map(site);
|
|
94
135
|
}
|
|
136
|
+
async siteById(siteId) {
|
|
137
|
+
return site(await this.request(`/onboarding/v1/sites/${uuid(siteId)}`));
|
|
138
|
+
}
|
|
95
139
|
async createSite(input) {
|
|
96
140
|
if (!input.name.trim() || input.name.length > 240)
|
|
97
141
|
throw new Error("Site name must contain 1–240 characters");
|
|
98
|
-
const value = object(await this.request("/onboarding/v1/sites", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId ?? randomUUID()), name: input.name.trim(), environment: input.environment, allowed_origins: input.origins }), "site creation");
|
|
142
|
+
const value = object(await this.request("/onboarding/v1/sites", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId ?? randomUUID()), name: input.name.trim(), environment: input.environment, allowed_origins: input.origins ?? [] }), "site creation");
|
|
99
143
|
return site(value.site ?? value);
|
|
100
144
|
}
|
|
101
145
|
async requestGrant(input) {
|
|
102
|
-
if (!input.scopes.length || input.scopes.length >
|
|
146
|
+
if (!input.scopes.length || input.scopes.length > 8)
|
|
103
147
|
throw new Error("Request at least one supported permission");
|
|
104
|
-
return grantRequest(await this.request("/onboarding/v1/grants/request", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: input.workspaceId ? uuid(input.workspaceId) : null, site_ids: input.siteIds?.map(id => uuid(id)) ?? null, scopes: [...new Set(input.scopes)] }));
|
|
148
|
+
return grantRequest(await this.request("/onboarding/v1/grants/request", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: input.workspaceId ? uuid(input.workspaceId) : null, site_ids: input.siteIds?.map(id => uuid(id)) ?? null, ...(input.allWorkspaces ? { all_workspaces: true } : {}), ...(input.workspaceIds ? { workspace_ids: input.workspaceIds.map(id => uuid(id)) } : {}), scopes: [...new Set(input.scopes)] }));
|
|
105
149
|
}
|
|
106
150
|
async grantStatus(id) { return grantRequest(await this.request(`/onboarding/v1/grants/requests/${uuid(id)}`)); }
|
|
107
151
|
/** The server parses the complete versioned command and derives authorization from the verified caller. */
|
|
108
152
|
async command(input) {
|
|
109
153
|
uuid(string(input.workspace_id, "workspace ID"));
|
|
110
154
|
uuid(string(input.request_id, "request ID"));
|
|
111
|
-
if (typeof input.action !== "string" || !/^[a-z]
|
|
155
|
+
if (typeof input.action !== "string" || !/^[a-z]+(\.[a-z]+){1,2}$/.test(input.action))
|
|
112
156
|
throw new Error("Command requires a supported resource.action");
|
|
113
157
|
if (input.action === "workspace.create" || input.action === "site.create")
|
|
114
158
|
throw new Error("Use workspace create or site create for authenticated onboarding");
|
|
115
159
|
return this.request("/onboarding/v1/command", input);
|
|
116
160
|
}
|
|
117
161
|
async query(input) {
|
|
118
|
-
if (!["sites", "surveys", "campaigns", "events", "responses", "audits"].includes(input.resource))
|
|
162
|
+
if (!["sites", "surveys", "campaigns", "prompts", "audiences", "events", "event_capture", "responses", "audits"].includes(input.resource))
|
|
119
163
|
throw new Error("Unsupported query resource");
|
|
164
|
+
if (input.promptId && input.resource !== 'responses')
|
|
165
|
+
throw new Error('A survey filter applies only to responses');
|
|
120
166
|
const limit = input.limit ?? 50;
|
|
121
167
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
122
168
|
throw new Error("Query limit must be an integer from 1 to 100");
|
|
123
|
-
return this.request("/onboarding/v1/query", { workspace_id: uuid(input.workspaceId), ...(input.siteId ? { site_id: uuid(input.siteId) } : {}), resource: input.resource, limit, ...(input.after ? { after: uuid(input.after) } : {}) });
|
|
169
|
+
return this.request("/onboarding/v1/query", { workspace_id: uuid(input.workspaceId), ...(input.siteId ? { site_id: uuid(input.siteId) } : {}), resource: input.resource, event_capture_mode: input.eventCaptureMode, event_collection_limits: input.eventCollectionLimits, limit, ...(input.after ? { after: uuid(input.after) } : {}), ...(input.id ? { id: uuid(input.id) } : {}), ...(input.promptId ? { prompt_id: uuid(input.promptId) } : {}) });
|
|
170
|
+
}
|
|
171
|
+
/** A read-only view of one workspace (members) or one of its sites (everything else). Empty values are left out. */
|
|
172
|
+
async read(operation, input) {
|
|
173
|
+
if (!READ_OPERATIONS.includes(operation))
|
|
174
|
+
throw new Error("Unsupported read operation");
|
|
175
|
+
const { workspaceId, siteId, ...rest } = input;
|
|
176
|
+
if (!['members.list', 'workspace.ai'].includes(operation) && !siteId)
|
|
177
|
+
throw new Error("This read requires a site");
|
|
178
|
+
if (rest.run_id !== undefined)
|
|
179
|
+
uuid(string(rest.run_id, 'Experiment run ID'));
|
|
180
|
+
if (rest.variant_id && !rest.run_id)
|
|
181
|
+
throw new Error('A variant filter requires an experiment run');
|
|
182
|
+
const body = { workspace_id: uuid(workspaceId), ...(operation === 'members.list' || !siteId ? {} : { site_id: uuid(siteId) }) };
|
|
183
|
+
for (const [key, value] of Object.entries(rest))
|
|
184
|
+
if (value !== undefined && value !== null && value !== '')
|
|
185
|
+
body[key] = value;
|
|
186
|
+
return this.request(`/onboarding/v1/read/${operation}`, body);
|
|
187
|
+
}
|
|
188
|
+
/** Workspace allowance settings and optional site product context; server enforces the caller's role. */
|
|
189
|
+
async updateWorkspaceAi(input) {
|
|
190
|
+
const allowed = new Set(['enabled', 'voice_enabled', 'monthly_limit_microusd', 'concurrent_limit', 'preview_daily_limit', 'product_context']);
|
|
191
|
+
if (!Object.keys(input.settings).length || Object.keys(input.settings).some(key => !allowed.has(key)))
|
|
192
|
+
throw new Error('AI settings require supported setting fields only');
|
|
193
|
+
if ('product_context' in input.settings && !input.siteId)
|
|
194
|
+
throw new Error('Product context requires a site');
|
|
195
|
+
return this.request('/onboarding/v1/workspace/ai/update', { ...input.settings, workspace_id: uuid(input.workspaceId), ...(input.siteId ? { site_id: uuid(input.siteId) } : {}) });
|
|
196
|
+
}
|
|
197
|
+
/** One site as stored, with its revision and frequency policy (null while it follows the starting values). */
|
|
198
|
+
async siteRecord(input) {
|
|
199
|
+
const page = object(await this.query({ ...input, resource: 'sites', id: input.siteId, limit: 1 }), 'sites');
|
|
200
|
+
const items = Array.isArray(page.items) ? page.items : [];
|
|
201
|
+
if (!items.length)
|
|
202
|
+
throw new ApiError(404, 'not_found');
|
|
203
|
+
return object(items[0], 'site');
|
|
204
|
+
}
|
|
205
|
+
async prompts(input) {
|
|
206
|
+
return this.query({ ...input, resource: 'prompts' });
|
|
207
|
+
}
|
|
208
|
+
async prompt(input) {
|
|
209
|
+
const page = object(await this.query({ ...input, resource: 'prompts', id: input.promptId, promptId: undefined, limit: 1 }), 'prompts');
|
|
210
|
+
if (!Array.isArray(page.items) || !page.items.length)
|
|
211
|
+
throw new Error('Survey unavailable in this workspace/site');
|
|
212
|
+
return object(page.items[0], 'prompt');
|
|
213
|
+
}
|
|
214
|
+
async promptCommand(input) {
|
|
215
|
+
if (!['create', 'update', 'publish', 'pause', 'resume', 'archive'].includes(input.action))
|
|
216
|
+
throw new Error('Unsupported survey action');
|
|
217
|
+
if (input.action !== 'create' && (!input.revision || !/^[1-9][0-9]{0,18}$/.test(input.revision) || BigInt(input.revision) > 9223372036854775807n))
|
|
218
|
+
throw new Error('Supply the current positive --revision from prompt get');
|
|
219
|
+
if ((input.action === 'create' || input.action === 'update') && !input.definition)
|
|
220
|
+
throw new Error('A complete survey definition JSON file is required');
|
|
221
|
+
return this.command({ action: `prompt.${input.action}`, workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId), prompt_id: uuid(input.promptId), request_id: uuid(input.requestId), ...(input.action !== 'create' ? { expected_revision: input.revision } : {}), ...(input.definition ? { definition: input.definition } : {}) });
|
|
222
|
+
}
|
|
223
|
+
/** Saved audiences: named conditions that several prompts of a site share. */
|
|
224
|
+
async audiences(input) {
|
|
225
|
+
return this.query({ ...input, resource: 'audiences' });
|
|
226
|
+
}
|
|
227
|
+
async audience(input) {
|
|
228
|
+
const page = object(await this.query({ ...input, resource: 'audiences', id: input.audienceId, limit: 1 }), 'audiences');
|
|
229
|
+
if (!Array.isArray(page.items) || !page.items.length)
|
|
230
|
+
throw new Error('Audience unavailable in this workspace/site');
|
|
231
|
+
return object(page.items[0], 'audience');
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Create and update save a draft from `{ name, description, rule }`. Publish makes every prompt
|
|
235
|
+
* that names the audience follow the new version at once; read `used_by` first.
|
|
236
|
+
*/
|
|
237
|
+
async audienceCommand(input) {
|
|
238
|
+
if (!['create', 'update', 'publish', 'discard', 'archive'].includes(input.action))
|
|
239
|
+
throw new Error('Unsupported audience action');
|
|
240
|
+
if (input.action !== 'create' && (!input.revision || !/^[1-9][0-9]{0,18}$/.test(input.revision) || BigInt(input.revision) > 9223372036854775807n))
|
|
241
|
+
throw new Error('Supply the current positive --revision from audience get');
|
|
242
|
+
const saves = input.action === 'create' || input.action === 'update';
|
|
243
|
+
if (saves && (!input.audience || typeof input.audience.name !== 'string' || !input.audience.rule || typeof input.audience.rule !== 'object'))
|
|
244
|
+
throw new Error('An audience JSON file with name, description and rule is required');
|
|
245
|
+
return this.command({ action: `audience.${input.action}`, workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId), audience_id: uuid(input.audienceId), request_id: uuid(input.requestId), ...(input.action !== 'create' ? { expected_revision: input.revision } : {}),
|
|
246
|
+
...(saves ? { name: input.audience.name, description: input.audience.description ?? null, rule: input.audience.rule } : {}) });
|
|
124
247
|
}
|
|
125
248
|
async waitForGrant(id, options = {}) {
|
|
126
249
|
const deadline = Date.now() + (options.timeoutMs ?? 300_000);
|
package/dist/config.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
export type CredentialStoreKind = 'file' | 'keychain';
|
|
1
2
|
export interface ClientConfig {
|
|
2
3
|
apiUrl: string;
|
|
3
4
|
clientId: string;
|
|
5
|
+
credentialStore?: CredentialStoreKind;
|
|
4
6
|
}
|
|
7
|
+
export declare function credentialStoreKind(value: unknown): CredentialStoreKind;
|
|
5
8
|
export declare const configDirectory: () => string;
|
|
6
9
|
export declare function discoverAuthConfig(url: string, development?: boolean, fetcher?: typeof fetch): Promise<string>;
|
|
7
10
|
export declare function resolveConfig(options?: {
|
|
8
11
|
development?: boolean;
|
|
9
12
|
apiUrl?: string;
|
|
10
13
|
clientId?: string;
|
|
14
|
+
credentialStore?: string;
|
|
11
15
|
}): Promise<ClientConfig>;
|
|
12
16
|
export declare function saveConfig(config: ClientConfig, development?: boolean): Promise<void>;
|
package/dist/config.js
CHANGED
|
@@ -2,15 +2,25 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { apiUrl, object } from "./contracts.js";
|
|
5
|
+
import { packageProfile } from "./profile.js";
|
|
6
|
+
import { ConfigurationError, fetchWithErrors } from "./errors.js";
|
|
7
|
+
export function credentialStoreKind(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return 'file';
|
|
10
|
+
if (value !== 'file' && value !== 'keychain')
|
|
11
|
+
throw new Error('Credential storage must be file or keychain (--credential-store or UR_CREDENTIAL_STORE)');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
5
14
|
export const configDirectory = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "userreason");
|
|
15
|
+
function readConfigurationError(error) {
|
|
16
|
+
const invalid = error instanceof SyntaxError || (error instanceof Error && error.message.startsWith('Invalid '));
|
|
17
|
+
return new ConfigurationError(invalid ? 'invalid_config' : 'config_read_failed', invalid
|
|
18
|
+
? 'Invalid UserReason user configuration; repair config.json in the user configuration directory.'
|
|
19
|
+
: `Unable to read UserReason configuration in ${configDirectory()}. Allow local read access; workspace grants are unrelated.`);
|
|
20
|
+
}
|
|
6
21
|
export async function discoverAuthConfig(url, development = false, fetcher = fetch) {
|
|
7
22
|
let response;
|
|
8
|
-
|
|
9
|
-
response = await fetcher(`${apiUrl(url)}/onboarding/v1/auth-config`, { headers: { Accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
10
|
-
}
|
|
11
|
-
catch {
|
|
12
|
-
throw new Error("Unable to discover UserReason authentication configuration. Check the API deployment or supply its public --client-id.");
|
|
13
|
-
}
|
|
23
|
+
response = await fetchWithErrors(fetcher, `${apiUrl(url)}/onboarding/v1/auth-config`, { headers: { Accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) }, 'Unable to discover UserReason authentication configuration');
|
|
14
24
|
if (!response.ok)
|
|
15
25
|
throw new Error(`UserReason authentication configuration is unavailable (${response.status}). Configure WorkOS in the Worker before running the wizard.`);
|
|
16
26
|
if (!response.headers.get("content-type")?.toLowerCase().startsWith("application/json") || !response.body)
|
|
@@ -56,6 +66,7 @@ export async function discoverAuthConfig(url, development = false, fetcher = fet
|
|
|
56
66
|
return config.client_id;
|
|
57
67
|
}
|
|
58
68
|
export async function resolveConfig(options = {}) {
|
|
69
|
+
options = { ...options, development: options.development ?? packageProfile().apiEnvironment === "development" };
|
|
59
70
|
const environment = options.development ? "development" : "production";
|
|
60
71
|
let saved = {};
|
|
61
72
|
try {
|
|
@@ -64,25 +75,37 @@ export async function resolveConfig(options = {}) {
|
|
|
64
75
|
}
|
|
65
76
|
catch (error) {
|
|
66
77
|
if (error.code !== "ENOENT")
|
|
67
|
-
throw
|
|
78
|
+
throw readConfigurationError(error);
|
|
68
79
|
}
|
|
69
80
|
const url = apiUrl(options.apiUrl ?? process.env.UR_API_URL ?? (typeof saved.apiUrl === "string" ? saved.apiUrl : options.development ? "https://api-dev.userreason.com" : "https://api.userreason.com"));
|
|
70
|
-
const configured = options.clientId ?? (options.development ? process.env.UR_WORKOS_DEV_CLIENT_ID : undefined) ?? process.env.UR_WORKOS_CLIENT_ID ?? (typeof saved.clientId === "string" ? saved.clientId : undefined);
|
|
81
|
+
const configured = options.clientId ?? (options.development ? process.env.UR_WORKOS_DEV_CLIENT_ID : undefined) ?? process.env.UR_WORKOS_CLIENT_ID ?? (saved.apiUrl === url && typeof saved.clientId === "string" ? saved.clientId : undefined);
|
|
82
|
+
const storage = options.credentialStore ?? process.env.UR_CREDENTIAL_STORE ?? (saved.apiUrl === url ? saved.credentialStore : undefined);
|
|
83
|
+
const kind = credentialStoreKind(storage);
|
|
71
84
|
const clientId = configured ?? await discoverAuthConfig(url, options.development);
|
|
72
85
|
if (!/^client_[A-Za-z0-9]+$/.test(clientId))
|
|
73
86
|
throw new Error("Set UR_WORKOS_CLIENT_ID or pass --client-id with the public WorkOS client ID. Never supply a WorkOS API key.");
|
|
74
|
-
return { apiUrl: url, clientId };
|
|
87
|
+
return { apiUrl: url, clientId, ...(storage === undefined ? {} : { credentialStore: kind }) };
|
|
75
88
|
}
|
|
76
89
|
export async function saveConfig(config, development = false) {
|
|
77
|
-
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(configDirectory(), { recursive: true, mode: 0o700 });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new ConfigurationError('config_write_failed', `Unable to save UserReason configuration in ${configDirectory()}. Allow local write access to remember this login's API and credential store.`);
|
|
95
|
+
}
|
|
78
96
|
let existing = {};
|
|
79
97
|
try {
|
|
80
98
|
existing = object(JSON.parse(await readFile(join(configDirectory(), "config.json"), "utf8")), "configuration");
|
|
81
99
|
}
|
|
82
100
|
catch (error) {
|
|
83
101
|
if (error.code !== "ENOENT")
|
|
84
|
-
throw
|
|
102
|
+
throw readConfigurationError(error);
|
|
85
103
|
}
|
|
86
104
|
existing[development ? "development" : "production"] = config;
|
|
87
|
-
|
|
105
|
+
try {
|
|
106
|
+
await writeFile(join(configDirectory(), "config.json"), JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new ConfigurationError('config_write_failed', `Unable to save UserReason configuration in ${configDirectory()}. Allow local write access to remember this login's API and credential store.`);
|
|
110
|
+
}
|
|
88
111
|
}
|
package/dist/contracts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const SCOPES: readonly ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish"];
|
|
1
|
+
export declare const SCOPES: readonly ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish", "resource:delete"];
|
|
2
2
|
export type Scope = typeof SCOPES[number];
|
|
3
3
|
export interface Workspace {
|
|
4
4
|
id: string;
|
|
@@ -14,6 +14,16 @@ export interface Site {
|
|
|
14
14
|
allowed_origins: string[];
|
|
15
15
|
revision?: string;
|
|
16
16
|
}
|
|
17
|
+
export interface Grant {
|
|
18
|
+
id: string;
|
|
19
|
+
workspace_id: string | null;
|
|
20
|
+
all_workspaces?: boolean;
|
|
21
|
+
workspace_ids?: string[] | null;
|
|
22
|
+
site_ids: string[] | null;
|
|
23
|
+
scopes: Scope[];
|
|
24
|
+
expires_at: string;
|
|
25
|
+
revoked_at: string | null;
|
|
26
|
+
}
|
|
17
27
|
export interface Account {
|
|
18
28
|
user: {
|
|
19
29
|
id: string;
|
|
@@ -21,7 +31,7 @@ export interface Account {
|
|
|
21
31
|
name?: string | null;
|
|
22
32
|
};
|
|
23
33
|
workspaces: Workspace[];
|
|
24
|
-
grants:
|
|
34
|
+
grants: Grant[];
|
|
25
35
|
}
|
|
26
36
|
export interface GrantRequest {
|
|
27
37
|
id: string;
|
|
@@ -48,7 +58,7 @@ export declare function object(value: unknown, label: string): Record<string, un
|
|
|
48
58
|
export declare function string(value: unknown, label: string): string;
|
|
49
59
|
export declare function uuid(value: string, label?: string): string;
|
|
50
60
|
export declare function scope(value: string): Scope;
|
|
51
|
-
export declare function apiUrl(value: string): string;
|
|
61
|
+
export declare function apiUrl(value: string, label?: string): string;
|
|
52
62
|
export declare function origin(value: string, environment: Site["environment"]): string;
|
|
53
63
|
export declare function workspace(value: unknown): Workspace;
|
|
54
64
|
export declare function site(value: unknown): Site;
|
package/dist/contracts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SCOPES = ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish"];
|
|
1
|
+
export const SCOPES = ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish", "resource:delete"];
|
|
2
2
|
export function object(value, label) {
|
|
3
3
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4
4
|
throw new Error(`Invalid ${label} response`);
|
|
@@ -19,12 +19,12 @@ export function scope(value) {
|
|
|
19
19
|
throw new Error(`Unknown scope: ${value}`);
|
|
20
20
|
return value;
|
|
21
21
|
}
|
|
22
|
-
export function apiUrl(value) {
|
|
22
|
+
export function apiUrl(value, label = "API URL") {
|
|
23
23
|
const url = new URL(value);
|
|
24
24
|
if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
|
|
25
|
-
throw new Error(
|
|
25
|
+
throw new Error(`${label} must be an origin without credentials, query or path`);
|
|
26
26
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error(`${label} must use HTTPS; loopback HTTP is allowed for local development`);
|
|
28
28
|
return url.origin;
|
|
29
29
|
}
|
|
30
30
|
export function origin(value, environment) {
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
import type { Credentials } from
|
|
2
|
-
import type
|
|
1
|
+
import type { Credentials } from './contracts.js';
|
|
2
|
+
import { type ClientConfig } from './config.js';
|
|
3
|
+
export declare class CredentialStoreError extends Error {
|
|
4
|
+
readonly code: 'native_addons_blocked' | 'unavailable' | 'read_failed' | 'write_failed' | 'delete_failed' | 'invalid_session' | 'lock_failed' | 'lock_timeout';
|
|
5
|
+
constructor(code: 'native_addons_blocked' | 'unavailable' | 'read_failed' | 'write_failed' | 'delete_failed' | 'invalid_session' | 'lock_failed' | 'lock_timeout', message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare const credentialAccount: (config: ClientConfig) => string;
|
|
8
|
+
export declare const credentialFile: (config: ClientConfig) => string;
|
|
9
|
+
/** Reads never create a directory or lock. Atomic replacement prevents partial session reads. */
|
|
10
|
+
export declare function fileCredentials(config: ClientConfig): Credentials;
|
|
11
|
+
/** An explicit Keychain choice never silently falls back to plaintext. */
|
|
12
|
+
export declare function resolveCredentials(config: ClientConfig): Credentials;
|
|
3
13
|
export declare function keychainCredentials(config: ClientConfig): Credentials;
|
package/dist/credentials.js
CHANGED
|
@@ -1,13 +1,95 @@
|
|
|
1
|
-
import { createHash } from
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { configDirectory, credentialStoreKind } from './config.js';
|
|
5
|
+
export class CredentialStoreError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const credentialAccount = (config) => createHash('sha256').update(`${config.apiUrl}\n${config.clientId}`).digest('hex');
|
|
13
|
+
export const credentialFile = (config) => join(configDirectory(), 'credentials', `${credentialAccount(config)}.json`);
|
|
14
|
+
function parseSession(serialized, config) {
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(serialized);
|
|
17
|
+
if (!value || value.apiUrl !== config.apiUrl || value.clientId !== config.clientId ||
|
|
18
|
+
!['accessToken', 'refreshToken', 'userId', 'email'].every(key => typeof value[key] === 'string') ||
|
|
19
|
+
!value.accessToken || !value.refreshToken || !Number.isFinite(value.expiresAt))
|
|
20
|
+
throw new Error();
|
|
21
|
+
// Only persist known credential fields, never arbitrary configuration or properties.
|
|
22
|
+
const { accessToken, refreshToken, expiresAt, userId, email, apiUrl, clientId } = value;
|
|
23
|
+
return { accessToken, refreshToken, expiresAt, userId, email, apiUrl, clientId };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new CredentialStoreError('invalid_session', 'Invalid stored UserReason session for this API/client; run userreason logout, then login again using the same credential store.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Reads never create a directory or lock. Atomic replacement prevents partial session reads. */
|
|
30
|
+
export function fileCredentials(config) {
|
|
31
|
+
const path = credentialFile(config);
|
|
32
|
+
return {
|
|
33
|
+
async load() {
|
|
34
|
+
let serialized;
|
|
35
|
+
try {
|
|
36
|
+
serialized = await readFile(path, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT')
|
|
40
|
+
return null;
|
|
41
|
+
throw new CredentialStoreError('read_failed', `Unable to read UserReason credentials at ${path}. Allow local read access; workspace grants are unrelated.`);
|
|
42
|
+
}
|
|
43
|
+
return parseSession(serialized, config);
|
|
44
|
+
},
|
|
45
|
+
async save(session) {
|
|
46
|
+
const serialized = JSON.stringify(parseSession(JSON.stringify(session), config)) + '\n';
|
|
47
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(join(configDirectory(), 'credentials'), { recursive: true, mode: 0o700 });
|
|
50
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(serialized, 'utf8');
|
|
53
|
+
await handle.sync();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
await rename(temporary, path);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new CredentialStoreError('write_failed', `Unable to save UserReason credentials at ${path}. Login and token refresh require local write access; the previous file is retained if replacement fails.`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async clear() {
|
|
68
|
+
try {
|
|
69
|
+
await rm(path, { force: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new CredentialStoreError('delete_failed', `Unable to remove UserReason credentials at ${path}. Allow local write access and retry logout.`);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** An explicit Keychain choice never silently falls back to plaintext. */
|
|
78
|
+
export function resolveCredentials(config) {
|
|
79
|
+
return credentialStoreKind(config.credentialStore ?? process.env.UR_CREDENTIAL_STORE) === 'keychain' ? keychainCredentials(config) : fileCredentials(config);
|
|
80
|
+
}
|
|
81
|
+
const absent = (error) => /No entry|NoEntry|No matching entry found in secure storage|not found|not exist/i.test(String(error.message));
|
|
2
82
|
export function keychainCredentials(config) {
|
|
3
|
-
const account =
|
|
83
|
+
const account = credentialAccount(config);
|
|
4
84
|
async function entry() {
|
|
85
|
+
if (process.permission && !process.permission.has('addons'))
|
|
86
|
+
throw new CredentialStoreError('native_addons_blocked', 'The Node permission policy blocks the native module required for OS credential storage.');
|
|
5
87
|
try {
|
|
6
|
-
const { Entry } = await import(
|
|
7
|
-
return new Entry(
|
|
88
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
89
|
+
return new Entry('com.userreason.cli', account, { linux: { store: 'secret-service' } });
|
|
8
90
|
}
|
|
9
91
|
catch {
|
|
10
|
-
throw new
|
|
92
|
+
throw new CredentialStoreError('unavailable', 'UserReason cannot access the selected OS credential store. Enable macOS Keychain, Windows Credential Manager or Linux Secret Service and install the optional @napi-rs/keyring dependency. To choose file storage explicitly, run login --credential-store file; no plaintext fallback was used.');
|
|
11
93
|
}
|
|
12
94
|
}
|
|
13
95
|
return {
|
|
@@ -17,34 +99,36 @@ export function keychainCredentials(config) {
|
|
|
17
99
|
serialized = (await entry()).getPassword();
|
|
18
100
|
}
|
|
19
101
|
catch (error) {
|
|
20
|
-
if (
|
|
102
|
+
if (error instanceof CredentialStoreError)
|
|
103
|
+
throw error;
|
|
104
|
+
if (absent(error))
|
|
21
105
|
return null;
|
|
22
|
-
throw new
|
|
106
|
+
throw new CredentialStoreError('read_failed', 'Unable to read the UserReason OS credential-store entry');
|
|
23
107
|
}
|
|
24
108
|
if (!serialized)
|
|
25
109
|
return null;
|
|
110
|
+
return parseSession(serialized, config);
|
|
111
|
+
},
|
|
112
|
+
async save(session) {
|
|
26
113
|
try {
|
|
27
|
-
|
|
28
|
-
if (session.apiUrl !== config.apiUrl || session.clientId !== config.clientId || typeof session.accessToken !== "string" || typeof session.refreshToken !== "string" || typeof session.expiresAt !== "number")
|
|
29
|
-
throw new Error();
|
|
30
|
-
return session;
|
|
114
|
+
(await entry()).setPassword(JSON.stringify(session));
|
|
31
115
|
}
|
|
32
|
-
catch {
|
|
33
|
-
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof CredentialStoreError)
|
|
118
|
+
throw error;
|
|
119
|
+
throw new CredentialStoreError('write_failed', 'Unable to save the UserReason session in the OS credential store');
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async clear() {
|
|
123
|
+
try {
|
|
124
|
+
(await entry()).deletePassword();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof CredentialStoreError)
|
|
128
|
+
throw error;
|
|
129
|
+
if (!absent(error))
|
|
130
|
+
throw new CredentialStoreError('delete_failed', 'Unable to remove the UserReason OS credential-store entry');
|
|
34
131
|
}
|
|
35
132
|
},
|
|
36
|
-
async save(session) { try {
|
|
37
|
-
(await entry()).setPassword(JSON.stringify(session));
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
throw new Error("Unable to save the UserReason session in the OS credential store");
|
|
41
|
-
} },
|
|
42
|
-
async clear() { try {
|
|
43
|
-
(await entry()).deletePassword();
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (!/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
|
|
47
|
-
throw new Error("Unable to remove the UserReason OS credential-store entry");
|
|
48
|
-
} },
|
|
49
133
|
};
|
|
50
134
|
}
|
package/dist/docs.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Fetch } from "./workos.js";
|
|
2
|
+
export declare const DOCS_URL = "https://docs.userreason.com";
|
|
3
|
+
export interface DocsSearchResult {
|
|
4
|
+
title: string;
|
|
5
|
+
heading: string | null;
|
|
6
|
+
url: string;
|
|
7
|
+
markdown_url: string;
|
|
8
|
+
package?: string;
|
|
9
|
+
version?: string;
|
|
10
|
+
since?: string;
|
|
11
|
+
passage: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DocsSearch {
|
|
14
|
+
query: string;
|
|
15
|
+
results: DocsSearchResult[];
|
|
16
|
+
source?: 'bundled';
|
|
17
|
+
warning?: {
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface DocsPage {
|
|
23
|
+
url: string;
|
|
24
|
+
markdown: string;
|
|
25
|
+
tokens?: number;
|
|
26
|
+
source?: 'bundled';
|
|
27
|
+
}
|
|
28
|
+
export interface DocsOptions {
|
|
29
|
+
origin?: string;
|
|
30
|
+
fetcher?: Fetch;
|
|
31
|
+
offline?: boolean;
|
|
32
|
+
live?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare class DocsError extends Error {
|
|
35
|
+
code: string;
|
|
36
|
+
status?: number | undefined;
|
|
37
|
+
constructor(code: string, message: string, status?: number | undefined);
|
|
38
|
+
}
|
|
39
|
+
export declare const docsUrl: (value: string) => string;
|
|
40
|
+
export declare function searchDocs(input: DocsOptions & {
|
|
41
|
+
query: string;
|
|
42
|
+
limit?: number;
|
|
43
|
+
}): Promise<DocsSearch>;
|
|
44
|
+
/** Accepts a page address or path. Search results name the public site, so its addresses are read from the selected origin. */
|
|
45
|
+
export declare function docsPageUrl(target: string, origin?: string): string;
|
|
46
|
+
export declare function getDocsPage(input: DocsOptions & {
|
|
47
|
+
target: string;
|
|
48
|
+
}): Promise<DocsPage>;
|
|
49
|
+
/** Terminal output keeps line breaks and tabs and drops other control characters. */
|
|
50
|
+
export declare const printable: (text: string) => string;
|
|
51
|
+
export declare function formatDocsSearch(found: DocsSearch): string;
|