@userreason/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +301 -41
  2. package/dist/authorize.d.ts +29 -0
  3. package/dist/authorize.js +23 -0
  4. package/dist/bin.js +565 -50
  5. package/dist/capture.d.ts +18 -0
  6. package/dist/capture.js +256 -0
  7. package/dist/catalog.d.ts +16 -0
  8. package/dist/catalog.js +206 -0
  9. package/dist/client.d.ts +79 -2
  10. package/dist/client.js +140 -17
  11. package/dist/config.d.ts +4 -0
  12. package/dist/config.js +35 -12
  13. package/dist/contracts.d.ts +13 -3
  14. package/dist/contracts.js +4 -4
  15. package/dist/credentials.d.ts +12 -2
  16. package/dist/credentials.js +110 -26
  17. package/dist/docs.d.ts +51 -0
  18. package/dist/docs.js +143 -0
  19. package/dist/errors.d.ts +11 -0
  20. package/dist/errors.js +38 -0
  21. package/dist/event-policy.d.ts +2 -0
  22. package/dist/event-policy.js +9 -0
  23. package/dist/first-survey-preparation.d.ts +8 -0
  24. package/dist/first-survey-preparation.js +29 -0
  25. package/dist/first-survey-source.d.ts +60 -0
  26. package/dist/first-survey-source.js +289 -0
  27. package/dist/first-survey-terminal.d.ts +5 -0
  28. package/dist/first-survey-terminal.js +16 -0
  29. package/dist/first-survey.d.ts +88 -0
  30. package/dist/first-survey.js +227 -0
  31. package/dist/help.d.ts +13 -0
  32. package/dist/help.js +74 -0
  33. package/dist/index.d.ts +23 -0
  34. package/dist/index.js +23 -0
  35. package/dist/lock.js +19 -5
  36. package/dist/profile.d.ts +20 -0
  37. package/dist/profile.js +57 -0
  38. package/dist/project.d.ts +9 -0
  39. package/dist/project.js +87 -0
  40. package/dist/recommended-surveys.d.ts +30 -0
  41. package/dist/recommended-surveys.js +201 -0
  42. package/dist/setup-executor.d.ts +35 -0
  43. package/dist/setup-executor.js +329 -0
  44. package/dist/setup-files.d.ts +11 -0
  45. package/dist/setup-files.js +96 -0
  46. package/dist/setup-runner.d.ts +14 -0
  47. package/dist/setup-runner.js +142 -0
  48. package/dist/setup-source.d.ts +31 -0
  49. package/dist/setup-source.js +48 -0
  50. package/dist/setup-store.d.ts +37 -0
  51. package/dist/setup-store.js +256 -0
  52. package/dist/setup-terminal.d.ts +3 -0
  53. package/dist/setup-terminal.js +6 -0
  54. package/dist/setup-wire.d.ts +1237 -0
  55. package/dist/setup-wire.js +175 -0
  56. package/dist/setup.d.ts +1223 -0
  57. package/dist/setup.js +258 -0
  58. package/dist/starter-survey.d.ts +22 -0
  59. package/dist/starter-survey.js +457 -0
  60. package/dist/telemetry.d.ts +67 -0
  61. package/dist/telemetry.js +304 -0
  62. package/dist/ui.d.ts +75 -0
  63. package/dist/ui.js +107 -0
  64. package/dist/views.d.ts +51 -0
  65. package/dist/views.js +93 -0
  66. package/dist/workos.d.ts +1 -1
  67. package/dist/workos.js +9 -4
  68. package/package.json +29 -12
  69. package/skills/userreason/SKILL.md +88 -8
  70. package/skills/userreason/references/management.md +67 -0
package/dist/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 lock = this.options.lock ?? ((operation) => withSessionLock(this.config, operation));
29
- return lock(async () => {
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 Error("Run userreason login first");
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
- if (current.expiresAt > Date.now() + 60_000 && (!forceRefresh || current.accessToken !== previous))
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
- return refresh(this.config, this.credentials, current, this.fetcher);
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
- let session = await this.token();
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
- const response = await this.fetcher(this.config.apiUrl + path, { method: body === undefined ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session.accessToken}`, "x-ur-client": "cli", "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
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 > 7)
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]+\.[a-z]+$/.test(input.action))
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
- try {
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 new Error("Unable to read UserReason configuration");
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
- await mkdir(configDirectory(), { recursive: true, mode: 0o700 });
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 new Error("Unable to read UserReason configuration");
102
+ throw readConfigurationError(error);
85
103
  }
86
104
  existing[development ? "development" : "production"] = config;
87
- await writeFile(join(configDirectory(), "config.json"), JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
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
  }
@@ -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: unknown[];
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("API URL must be an origin without credentials, query or path");
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("API URL must use HTTPS; loopback HTTP is allowed for local development");
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) {
@@ -1,3 +1,13 @@
1
- import type { Credentials } from "./contracts.js";
2
- import type { ClientConfig } from "./config.js";
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;
@@ -1,13 +1,95 @@
1
- import { createHash } from "node:crypto";
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 = createHash("sha256").update(`${config.apiUrl}\n${config.clientId}`).digest("hex");
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("@napi-rs/keyring");
7
- return new Entry("com.userreason.cli", account, { linux: { store: "secret-service" } });
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 Error("UserReason cannot access the OS credential store. Enable macOS Keychain, Windows Credential Manager or Linux Secret Service; credentials will not be written to a plaintext file.");
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 (/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
102
+ if (error instanceof CredentialStoreError)
103
+ throw error;
104
+ if (absent(error))
21
105
  return null;
22
- throw new Error("Unable to read the UserReason OS credential-store entry");
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
- const session = JSON.parse(serialized);
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
- throw new Error("Invalid stored UserReason session; run userreason logout, then login again");
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;