@userreason/wizard 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @userreason/wizard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Authenticated installation for UserReason. It shares the CLI client and credentials, uses the same onboarding endpoints, and generates integrations for React, Next.js, Vue and plain browser applications. This package is prepared for publication; no npm publication occurs during its build.
4
+
5
+ Build the local CLI first, then the wizard:
6
+
7
+ ```sh
8
+ cd ../ur-cli && npm ci && npm run build
9
+ cd ../ur-wizard && npm ci && npm run typecheck && npm test
10
+ node dist/bin.js --help
11
+ ```
12
+
13
+ The wizard bundles the CLI client at build time. Its published runtime does not depend on the private `ur-types` package or a local file reference; its only runtime dependency is the OS credential-store binding.
14
+
15
+ After publication:
16
+
17
+ ```sh
18
+ npx -y @userreason/wizard@latest
19
+ ```
20
+
21
+ It discovers the public WorkOS client ID from the configured API, reads the selected project's `package.json`, detects its framework/package manager, reuses an existing OS-stored login or starts public WorkOS device authentication, and selects or creates a workspace and site. `--client-id` remains available for explicit configuration. Only when the operation lacks an effective grant does it ask the human to approve installation permissions. A previously approved client can continue unattended until its grant expires or is revoked. Fresh clients may choose an existing workspace in the browser approval screen even before they have permission to list its name.
22
+
23
+ ```sh
24
+ npx -y @userreason/wizard@latest \
25
+ --directory ./app \
26
+ --workspace WORKSPACE_UUID \
27
+ --site SITE_UUID \
28
+ --origin https://app.example.com \
29
+ --install \
30
+ --yes \
31
+ --json
32
+ ```
33
+
34
+ For new resources, use `--workspace-name` and `--site-name` instead of their UUID flags. `--dev` selects the development API, development WorkOS client configuration and default site environment. `--environment` can select the site's environment independently of the API deployment. Production sites require HTTPS origins.
35
+
36
+ `--install` installs `@userreason/js@^0.2.0` with the detected package manager and dependency scripts disabled. This requires that the remote-compatible SDK has been published first. `--skip-install` prepares the integration without changing dependencies; ensure your existing SDK supports `init({workspaceId, siteId, apiBaseUrl})`. SDK 0.1.x's local-survey API cannot connect to the Worker's remote campaign protocol.
37
+
38
+ Generated files:
39
+
40
+ - `userreason.config.json`: public workspace/site/API configuration and required SDK version.
41
+ - `src/userreason.generated.tsx` for React/Next.js, or `src/userreason.generated.ts` for Vue/vanilla; written at the project root when there is no `src` directory.
42
+
43
+ The generated React/Next component, Vue composable or vanilla initializer must be mounted/called once at the application root. The wizard prints the framework-specific instruction and includes lifecycle cleanup. It leaves existing application source unchanged so the integration can be reviewed. Files are reused if identical; changed files require `--force`, which creates a backup before replacement. Symlinked source directories are refused.
44
+
45
+ React/Next initialization uses a browser layout effect with an SSR-safe fallback, so ordinary descendant `useEffect` calls can report their first event after initialization. Cleanup and reinitialization also run correctly during React Strict Mode's development remount. Descendant layout effects can run earlier: move their event reports to a passive effect or initialize UserReason through a controlled browser entry before rendering that tree. Vue initializes in the root `onBeforeMount`, ahead of descendant mount hooks; call the composable in the root setup before creating descendants. Neither framework starts the SDK during server rendering. Remote configuration still loads asynchronously: await `UserReason.ready()` when an event must wait for its targeting configuration. See [React layout effects](https://react.dev/reference/react/useLayoutEffect) and [Vue lifecycle hooks](https://vuejs.org/api/composition-api-lifecycle.html#onbeforemount).
46
+
47
+ The wizard checks the public SDK configuration endpoint using the exact allowed host origin. Its result explicitly reports `hostConnectionVerified:false`: configuration availability is not proof of a real browser connection. Start the host application, mount the generated integration, and verify its config/session flow from that origin. No campaign is published merely by installing the SDK.
48
+
49
+ Only public SDK configuration is written into the project. Login tokens remain in macOS Keychain, Windows Credential Manager or Linux Secret Service. WorkOS API keys and UserReason signing keys are never required by the wizard.
50
+
51
+ Local tests use temporary projects and HTTP fixtures. Live login, grant approval, npm installation and host-origin operation require the configured WorkOS environment, migrations and deployed onboarding Worker.
package/dist/bin.js ADDED
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { parseArgs } from "node:util";
5
+ import { randomUUID as randomUUID3 } from "node:crypto";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ // ../ur-cli/dist/client.js
9
+ import { randomUUID as randomUUID2 } from "node:crypto";
10
+
11
+ // ../ur-cli/dist/contracts.js
12
+ function object(value, label) {
13
+ if (!value || typeof value !== "object" || Array.isArray(value))
14
+ throw new Error(`Invalid ${label} response`);
15
+ return value;
16
+ }
17
+ function string(value, label) {
18
+ if (typeof value !== "string" || value.length === 0)
19
+ throw new Error(`Invalid ${label}`);
20
+ return value;
21
+ }
22
+ function uuid(value, label = "ID") {
23
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value))
24
+ throw new Error(`${label} must be a UUID`);
25
+ return value;
26
+ }
27
+ function apiUrl(value) {
28
+ const url = new URL(value);
29
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
30
+ throw new Error("API URL must be an origin without credentials, query or path");
31
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
32
+ throw new Error("API URL must use HTTPS; loopback HTTP is allowed for local development");
33
+ return url.origin;
34
+ }
35
+ function origin(value, environment) {
36
+ const normalized = apiUrl(value);
37
+ if (environment === "production" && !normalized.startsWith("https://"))
38
+ throw new Error("Production sites require HTTPS origins");
39
+ return normalized;
40
+ }
41
+ function workspace(value) {
42
+ const data = object(value, "workspace");
43
+ return { ...data, id: uuid(string(data.id, "workspace ID")), name: string(data.name, "workspace name") };
44
+ }
45
+ function site(value) {
46
+ const data = object(value, "site");
47
+ if (data.environment !== "development" && data.environment !== "production")
48
+ throw new Error("Invalid site environment");
49
+ if (!Array.isArray(data.allowed_origins) || !data.allowed_origins.every((item) => typeof item === "string"))
50
+ throw new Error("Invalid site origins");
51
+ return { ...data, id: uuid(string(data.id ?? data.site_id, "site ID")), workspace_id: uuid(string(data.workspace_id, "workspace ID")), name: string(data.name, "site name") };
52
+ }
53
+ function grantRequest(value) {
54
+ const data = object(value, "grant request");
55
+ if (!["pending", "approved", "rejected", "denied", "expired"].includes(String(data.status)))
56
+ throw new Error("Invalid grant request status");
57
+ if (data.approval_url !== void 0) {
58
+ const url = new URL(string(data.approval_url, "approval URL"));
59
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
60
+ throw new Error("Invalid grant approval URL");
61
+ }
62
+ return { ...data, id: uuid(string(data.id, "grant request ID")) };
63
+ }
64
+
65
+ // ../ur-cli/dist/workos.js
66
+ var AuthenticationError = class extends Error {
67
+ code;
68
+ constructor(code, message) {
69
+ super(message);
70
+ this.code = code;
71
+ }
72
+ };
73
+ var sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds));
74
+ var endpoint = "https://api.workos.com/user_management";
75
+ function session(data, config) {
76
+ const user = object(data.user, "authenticated user");
77
+ if (user.email_verified !== true)
78
+ throw new AuthenticationError("email_unverified", "Complete email verification before continuing");
79
+ const accessToken = string(data.access_token, "access token");
80
+ let expiresAt = Date.now() + 12e4;
81
+ try {
82
+ const payload = object(JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()), "token payload");
83
+ if (typeof payload.exp === "number" && Number.isFinite(payload.exp))
84
+ expiresAt = payload.exp * 1e3;
85
+ } catch {
86
+ }
87
+ return { accessToken, refreshToken: string(data.refresh_token, "refresh token"), expiresAt, userId: string(user.id, "user ID"), email: string(user.email, "email"), ...config };
88
+ }
89
+ async function post(path, body, fetcher, signal = AbortSignal.timeout(2e4)) {
90
+ const response = await fetcher(`${endpoint}/${path}`, { method: "POST", redirect: "error", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), signal });
91
+ const data = object(await response.json(), "authentication");
92
+ return { response, data };
93
+ }
94
+ async function login(config, credentials, onDevice, options = {}) {
95
+ const fetcher = options.fetcher ?? fetch, wait = options.sleep ?? sleep, now = options.now ?? Date.now;
96
+ const authorization = await post("authorize/device", { client_id: config.clientId }, fetcher);
97
+ if (!authorization.response.ok)
98
+ throw new AuthenticationError("device_authorization_failed", `Unable to start WorkOS login (${authorization.response.status})`);
99
+ const data = authorization.data;
100
+ const expiresIn = Number(data.expires_in), initialInterval = Number(data.interval);
101
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 1800 || !Number.isFinite(initialInterval) || initialInterval < 1 || initialInterval > 60)
102
+ throw new Error("Invalid device authorization timing");
103
+ const deviceCode = string(data.device_code, "device authorization code");
104
+ const verificationUri = string(data.verification_uri, "verification URL"), verificationUriComplete = string(data.verification_uri_complete, "complete verification URL");
105
+ for (const uri of [verificationUri, verificationUriComplete]) {
106
+ const url = new URL(uri);
107
+ if (url.protocol !== "https:" || url.username || url.password)
108
+ throw new Error("WorkOS returned an invalid verification URL");
109
+ }
110
+ await onDevice({ userCode: string(data.user_code, "user authorization code"), verificationUri, verificationUriComplete, expiresIn });
111
+ let interval = initialInterval;
112
+ const deadline = now() + expiresIn * 1e3;
113
+ while (now() < deadline) {
114
+ await wait(interval * 1e3);
115
+ if (now() >= deadline)
116
+ break;
117
+ let result;
118
+ try {
119
+ result = await post("authenticate", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }, fetcher, AbortSignal.timeout(Math.min(2e4, deadline - now())));
120
+ } catch {
121
+ interval = Math.min(interval * 2, 30);
122
+ continue;
123
+ }
124
+ if (result.response.ok) {
125
+ const authenticated = session(result.data, config);
126
+ await credentials.save(authenticated);
127
+ return authenticated;
128
+ }
129
+ const code = result.data.error ?? result.data.code;
130
+ if (code === "authorization_pending")
131
+ continue;
132
+ if (code === "slow_down") {
133
+ interval += 5;
134
+ continue;
135
+ }
136
+ if (result.response.status >= 500 || result.response.status === 429) {
137
+ interval = Math.min(interval * 2, 30);
138
+ continue;
139
+ }
140
+ if (code === "access_denied")
141
+ throw new AuthenticationError("access_denied", "Login was declined");
142
+ if (code === "expired_token")
143
+ break;
144
+ throw new AuthenticationError("authorization_failed", `WorkOS authorization failed (${result.response.status})`);
145
+ }
146
+ throw new AuthenticationError("expired_token", "The login code expired; run login again");
147
+ }
148
+ async function refresh(config, credentials, current, fetcher = fetch) {
149
+ const result = await post("authenticate", { grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: config.clientId }, fetcher);
150
+ if (!result.response.ok) {
151
+ if (result.data.error === "invalid_grant" || result.data.code === "invalid_grant") {
152
+ await credentials.clear();
153
+ throw new AuthenticationError("invalid_grant", "Your session expired or was revoked; run userreason login again");
154
+ }
155
+ throw new AuthenticationError("refresh_failed", `Unable to refresh your session (${result.response.status}); the stored session was retained`);
156
+ }
157
+ const updated = session(result.data, config);
158
+ await credentials.save(updated);
159
+ return updated;
160
+ }
161
+
162
+ // ../ur-cli/dist/lock.js
163
+ import { createHash, randomUUID } from "node:crypto";
164
+ import { mkdir as mkdir2, open, readFile as readFile2, unlink } from "node:fs/promises";
165
+ import { join as join2 } from "node:path";
166
+
167
+ // ../ur-cli/dist/config.js
168
+ import { homedir } from "node:os";
169
+ import { join } from "node:path";
170
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
171
+ var configDirectory = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "userreason");
172
+ async function discoverAuthConfig(url, development = false, fetcher = fetch) {
173
+ let response;
174
+ try {
175
+ response = await fetcher(`${apiUrl(url)}/onboarding/v1/auth-config`, { headers: { Accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(1e4) });
176
+ } catch {
177
+ throw new Error("Unable to discover UserReason authentication configuration. Check the API deployment or supply its public --client-id.");
178
+ }
179
+ if (!response.ok)
180
+ throw new Error(`UserReason authentication configuration is unavailable (${response.status}). Configure WorkOS in the Worker before running the wizard.`);
181
+ if (!response.headers.get("content-type")?.toLowerCase().startsWith("application/json") || !response.body)
182
+ throw new Error("Invalid UserReason authentication configuration response");
183
+ const reader = response.body.getReader(), chunks = [];
184
+ let bytes = 0;
185
+ try {
186
+ while (true) {
187
+ const part = await reader.read();
188
+ if (part.done)
189
+ break;
190
+ bytes += part.value.byteLength;
191
+ if (bytes > 4096) {
192
+ await reader.cancel();
193
+ throw new Error("UserReason authentication configuration response is too large");
194
+ }
195
+ chunks.push(part.value);
196
+ }
197
+ } finally {
198
+ reader.releaseLock();
199
+ }
200
+ const buffer = new Uint8Array(bytes);
201
+ let offset = 0;
202
+ for (const chunk of chunks) {
203
+ buffer.set(chunk, offset);
204
+ offset += chunk.byteLength;
205
+ }
206
+ let config;
207
+ try {
208
+ config = object(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)), "authentication configuration");
209
+ } catch {
210
+ throw new Error("Invalid UserReason authentication configuration response");
211
+ }
212
+ if (Object.keys(config).some((key) => !["client_id", "authkit_domain", "environment"].includes(key)) || typeof config.client_id !== "string" || !/^client_[A-Za-z0-9]{1,230}$/.test(config.client_id) || typeof config.authkit_domain !== "string" || config.authkit_domain.length > 2048)
213
+ throw new Error("Invalid UserReason authentication configuration response");
214
+ const domain = apiUrl(config.authkit_domain);
215
+ if (!domain.startsWith("https://") || domain !== config.authkit_domain)
216
+ throw new Error("Invalid WorkOS AuthKit domain in authentication configuration");
217
+ if (config.environment !== "local" && config.environment !== (development ? "development" : "production"))
218
+ throw new Error("API authentication environment does not match the selected CLI environment; use --dev for development");
219
+ return config.client_id;
220
+ }
221
+ async function resolveConfig(options = {}) {
222
+ const environment = options.development ? "development" : "production";
223
+ let saved = {};
224
+ try {
225
+ const root = object(JSON.parse(await readFile(join(configDirectory(), "config.json"), "utf8")), "configuration");
226
+ saved = object(root[environment] ?? {}, "environment configuration");
227
+ } catch (error) {
228
+ if (error.code !== "ENOENT")
229
+ throw new Error("Unable to read UserReason configuration");
230
+ }
231
+ 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"));
232
+ const configured = options.clientId ?? (options.development ? process.env.UR_WORKOS_DEV_CLIENT_ID : void 0) ?? process.env.UR_WORKOS_CLIENT_ID ?? (typeof saved.clientId === "string" ? saved.clientId : void 0);
233
+ const clientId = configured ?? await discoverAuthConfig(url, options.development);
234
+ if (!/^client_[A-Za-z0-9]+$/.test(clientId))
235
+ throw new Error("Set UR_WORKOS_CLIENT_ID or pass --client-id with the public WorkOS client ID. Never supply a WorkOS API key.");
236
+ return { apiUrl: url, clientId };
237
+ }
238
+ async function saveConfig(config, development = false) {
239
+ await mkdir(configDirectory(), { recursive: true, mode: 448 });
240
+ let existing = {};
241
+ try {
242
+ existing = object(JSON.parse(await readFile(join(configDirectory(), "config.json"), "utf8")), "configuration");
243
+ } catch (error) {
244
+ if (error.code !== "ENOENT")
245
+ throw new Error("Unable to read UserReason configuration");
246
+ }
247
+ existing[development ? "development" : "production"] = config;
248
+ await writeFile(join(configDirectory(), "config.json"), JSON.stringify(existing, null, 2) + "\n", { mode: 384 });
249
+ }
250
+
251
+ // ../ur-cli/dist/lock.js
252
+ async function withSessionLock(config, operation) {
253
+ const directory = join2(configDirectory(), "locks");
254
+ await mkdir2(directory, { recursive: true, mode: 448 });
255
+ const path = join2(directory, createHash("sha256").update(`${config.apiUrl}
256
+ ${config.clientId}`).digest("hex") + ".lock");
257
+ const id = randomUUID(), deadline = Date.now() + 35e3;
258
+ while (true) {
259
+ try {
260
+ const file = await open(path, "wx", 384);
261
+ await file.writeFile(JSON.stringify({ id, createdAt: Date.now() }));
262
+ await file.close();
263
+ break;
264
+ } catch (error) {
265
+ if (error.code !== "EEXIST")
266
+ throw new Error("Unable to lock the UserReason credential store");
267
+ try {
268
+ const old = JSON.parse(await readFile2(path, "utf8"));
269
+ if (typeof old.createdAt === "number" && Date.now() - old.createdAt > 12e4) {
270
+ await unlink(path);
271
+ continue;
272
+ }
273
+ } catch {
274
+ }
275
+ if (Date.now() >= deadline)
276
+ throw new Error("Another UserReason process is refreshing credentials; try again shortly");
277
+ await new Promise((resolve2) => setTimeout(resolve2, 150));
278
+ }
279
+ }
280
+ try {
281
+ return await operation();
282
+ } finally {
283
+ try {
284
+ const own = JSON.parse(await readFile2(path, "utf8"));
285
+ if (own.id === id)
286
+ await unlink(path);
287
+ } catch {
288
+ }
289
+ }
290
+ }
291
+
292
+ // ../ur-cli/dist/client.js
293
+ var ApiError = class extends Error {
294
+ status;
295
+ code;
296
+ approvalUrl;
297
+ constructor(status, code, approvalUrl) {
298
+ super(`UserReason request failed (${status}, ${code})${approvalUrl ? `. Approve access at ${approvalUrl}` : ""}`);
299
+ this.status = status;
300
+ this.code = code;
301
+ this.approvalUrl = approvalUrl;
302
+ }
303
+ };
304
+ var UserReasonClient = class {
305
+ config;
306
+ credentials;
307
+ options;
308
+ fetcher;
309
+ constructor(config, credentials, options = {}) {
310
+ this.config = config;
311
+ this.credentials = credentials;
312
+ this.options = options;
313
+ this.fetcher = options.fetcher ?? fetch;
314
+ }
315
+ async token(forceRefresh = false, previous) {
316
+ const lock = this.options.lock ?? ((operation) => withSessionLock(this.config, operation));
317
+ return lock(async () => {
318
+ const current = await this.credentials.load();
319
+ if (!current)
320
+ throw new Error("Run userreason login first");
321
+ if (current.apiUrl !== this.config.apiUrl || current.clientId !== this.config.clientId)
322
+ throw new Error("Stored login belongs to a different environment or WorkOS client");
323
+ if (current.expiresAt > Date.now() + 6e4 && (!forceRefresh || current.accessToken !== previous))
324
+ return current;
325
+ return refresh(this.config, this.credentials, current, this.fetcher);
326
+ });
327
+ }
328
+ async request(path, body) {
329
+ if (!path.startsWith("/onboarding/v1/") || path.includes("//") || path.includes("\\"))
330
+ throw new Error("Invalid UserReason API path");
331
+ let session2 = await this.token();
332
+ for (let attempt = 0; attempt < 2; attempt++) {
333
+ const response = await this.fetcher(this.config.apiUrl + path, { method: body === void 0 ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session2.accessToken}`, "x-ur-client": "cli", "Content-Type": "application/json" }, ...body === void 0 ? {} : { body: JSON.stringify(body) }, signal: AbortSignal.timeout(2e4) });
334
+ if (response.status === 401 && attempt === 0) {
335
+ session2 = await this.token(true, session2.accessToken);
336
+ continue;
337
+ }
338
+ let result;
339
+ try {
340
+ result = await response.json();
341
+ } catch {
342
+ throw new ApiError(response.status, "invalid_response");
343
+ }
344
+ if (!response.ok) {
345
+ const data = object(result, "error"), detail = typeof data.error === "object" && data.error ? object(data.error, "error") : data;
346
+ const code = typeof detail.code === "string" && /^[a-z0-9_:-]{1,100}$/i.test(detail.code) ? detail.code : typeof data.error === "string" && /^[a-z0-9_:-]{1,100}$/i.test(data.error) ? data.error : "request_failed";
347
+ const candidate = detail.approval_url ?? data.approval_url;
348
+ let approvalUrl;
349
+ if (typeof candidate === "string") {
350
+ try {
351
+ const url = new URL(candidate);
352
+ if (url.protocol === "https:" && !url.username && !url.password)
353
+ approvalUrl = url.toString();
354
+ } catch {
355
+ }
356
+ }
357
+ throw new ApiError(response.status, code, approvalUrl);
358
+ }
359
+ return result;
360
+ }
361
+ throw new ApiError(401, "authentication_required");
362
+ }
363
+ async account() {
364
+ const data = object(await this.request("/onboarding/v1/account"), "account"), user = object(data.user, "user");
365
+ uuid(string(user.id, "user ID"));
366
+ if (!Array.isArray(data.workspaces) || !Array.isArray(data.grants))
367
+ throw new Error("Invalid account memberships");
368
+ return { ...data, user, workspaces: data.workspaces.map(workspace) };
369
+ }
370
+ async createWorkspace(name, options = {}) {
371
+ if (!name.trim() || name.length > 240)
372
+ throw new Error("Workspace name must contain 1\u2013240 characters");
373
+ const value = object(await this.request("/onboarding/v1/workspaces", { request_id: uuid(options.requestId ?? randomUUID2()), workspace_id: uuid(options.workspaceId ?? randomUUID2()), name: name.trim() }), "workspace creation");
374
+ return workspace(value.workspace ?? value);
375
+ }
376
+ async sites(workspaceId) {
377
+ const data = object(await this.request(`/onboarding/v1/workspaces/${uuid(workspaceId)}/sites`), "sites"), items = data.items ?? data.sites;
378
+ if (!Array.isArray(items))
379
+ throw new Error("Invalid site list");
380
+ return items.map(site);
381
+ }
382
+ async createSite(input) {
383
+ if (!input.name.trim() || input.name.length > 240)
384
+ throw new Error("Site name must contain 1\u2013240 characters");
385
+ const value = object(await this.request("/onboarding/v1/sites", { request_id: uuid(input.requestId ?? randomUUID2()), workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId ?? randomUUID2()), name: input.name.trim(), environment: input.environment, allowed_origins: input.origins }), "site creation");
386
+ return site(value.site ?? value);
387
+ }
388
+ async requestGrant(input) {
389
+ if (!input.scopes.length || input.scopes.length > 7)
390
+ throw new Error("Request at least one supported permission");
391
+ return grantRequest(await this.request("/onboarding/v1/grants/request", { request_id: uuid(input.requestId ?? randomUUID2()), workspace_id: input.workspaceId ? uuid(input.workspaceId) : null, site_ids: input.siteIds?.map((id) => uuid(id)) ?? null, scopes: [...new Set(input.scopes)] }));
392
+ }
393
+ async grantStatus(id) {
394
+ return grantRequest(await this.request(`/onboarding/v1/grants/requests/${uuid(id)}`));
395
+ }
396
+ /** The server parses the complete versioned command and derives authorization from the verified caller. */
397
+ async command(input) {
398
+ uuid(string(input.workspace_id, "workspace ID"));
399
+ uuid(string(input.request_id, "request ID"));
400
+ if (typeof input.action !== "string" || !/^[a-z]+\.[a-z]+$/.test(input.action))
401
+ throw new Error("Command requires a supported resource.action");
402
+ if (input.action === "workspace.create" || input.action === "site.create")
403
+ throw new Error("Use workspace create or site create for authenticated onboarding");
404
+ return this.request("/onboarding/v1/command", input);
405
+ }
406
+ async query(input) {
407
+ if (!["sites", "surveys", "campaigns", "events", "responses", "audits"].includes(input.resource))
408
+ throw new Error("Unsupported query resource");
409
+ const limit = input.limit ?? 50;
410
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
411
+ throw new Error("Query limit must be an integer from 1 to 100");
412
+ 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) } : {} });
413
+ }
414
+ async waitForGrant(id, options = {}) {
415
+ const deadline = Date.now() + (options.timeoutMs ?? 3e5);
416
+ do {
417
+ const request = await this.grantStatus(id);
418
+ options.onStatus?.(request);
419
+ if (request.status === "approved")
420
+ return request;
421
+ if (request.status !== "pending")
422
+ throw new Error(`Access request ${request.status}; request access again if needed`);
423
+ if (request.expires_at && Date.parse(request.expires_at) <= Date.now())
424
+ throw new Error("Access request expired; request access again");
425
+ await new Promise((resolve2) => setTimeout(resolve2, options.intervalMs ?? 2e3));
426
+ } while (Date.now() < deadline);
427
+ throw new Error("Access approval timed out; check userreason grant status --id " + id);
428
+ }
429
+ };
430
+
431
+ // ../ur-cli/dist/credentials.js
432
+ import { createHash as createHash2 } from "node:crypto";
433
+ function keychainCredentials(config) {
434
+ const account = createHash2("sha256").update(`${config.apiUrl}
435
+ ${config.clientId}`).digest("hex");
436
+ async function entry() {
437
+ try {
438
+ const { Entry } = await import("@napi-rs/keyring");
439
+ return new Entry("com.userreason.cli", account, { linux: { store: "secret-service" } });
440
+ } catch {
441
+ 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.");
442
+ }
443
+ }
444
+ return {
445
+ async load() {
446
+ let serialized;
447
+ try {
448
+ serialized = (await entry()).getPassword();
449
+ } catch (error) {
450
+ if (/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
451
+ return null;
452
+ throw new Error("Unable to read the UserReason OS credential-store entry");
453
+ }
454
+ if (!serialized)
455
+ return null;
456
+ try {
457
+ const session2 = JSON.parse(serialized);
458
+ if (session2.apiUrl !== config.apiUrl || session2.clientId !== config.clientId || typeof session2.accessToken !== "string" || typeof session2.refreshToken !== "string" || typeof session2.expiresAt !== "number")
459
+ throw new Error();
460
+ return session2;
461
+ } catch {
462
+ throw new Error("Invalid stored UserReason session; run userreason logout, then login again");
463
+ }
464
+ },
465
+ async save(session2) {
466
+ try {
467
+ (await entry()).setPassword(JSON.stringify(session2));
468
+ } catch {
469
+ throw new Error("Unable to save the UserReason session in the OS credential store");
470
+ }
471
+ },
472
+ async clear() {
473
+ try {
474
+ (await entry()).deletePassword();
475
+ } catch (error) {
476
+ if (!/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
477
+ throw new Error("Unable to remove the UserReason OS credential-store entry");
478
+ }
479
+ }
480
+ };
481
+ }
482
+
483
+ // src/project.ts
484
+ import { lstat, readFile as readFile3, writeFile as writeFile2, access, copyFile } from "node:fs/promises";
485
+ import { join as join3, resolve, dirname } from "node:path";
486
+ import { spawn } from "node:child_process";
487
+ var exists = async (path) => {
488
+ try {
489
+ await access(path);
490
+ return true;
491
+ } catch {
492
+ return false;
493
+ }
494
+ };
495
+ async function inspectProject(directory, framework) {
496
+ directory = resolve(directory);
497
+ const metadata = await lstat(directory);
498
+ if (!metadata.isDirectory()) throw new Error("Project directory must be an existing directory");
499
+ const text = await readFile3(join3(directory, "package.json"), "utf8");
500
+ if (text.length > 1e6) throw new Error("Project package.json is too large");
501
+ const data = JSON.parse(text);
502
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid project package.json");
503
+ const dependencies = { ...data.dependencies, ...data.devDependencies };
504
+ const inferred = dependencies.next ? "next" : dependencies.react ? "react" : dependencies.vue ? "vue" : "vanilla";
505
+ const packageManager = typeof data.packageManager === "string" && /^(pnpm|yarn)@/.test(data.packageManager) ? data.packageManager.split("@")[0] : await exists(join3(directory, "pnpm-lock.yaml")) ? "pnpm" : await exists(join3(directory, "yarn.lock")) ? "yarn" : "npm";
506
+ const yarnMajor = packageManager === "yarn" ? typeof data.packageManager === "string" && /^yarn@\d+/.test(data.packageManager) ? Number(data.packageManager.slice(5).split(".")[0]) : await exists(join3(directory, ".yarnrc.yml")) ? 4 : 1 : void 0;
507
+ return { directory, framework: framework ?? inferred, sourceDirectory: await exists(join3(directory, "src")) ? join3(directory, "src") : directory, packageManager, yarnMajor, name: typeof data.name === "string" && data.name ? data.name : "My site" };
508
+ }
509
+ function integrationSource(framework, config) {
510
+ const options = JSON.stringify(config, null, 2);
511
+ const base = `// Generated by the UserReason wizard. Contains public SDK configuration only.
512
+ `;
513
+ if (framework === "next" || framework === "react") return {
514
+ filename: "userreason.generated.tsx",
515
+ content: `${framework === "next" ? "'use client';\n" : ""}${base}import { useEffect, useLayoutEffect } from 'react';
516
+ import UserReason from '@userreason/js';
517
+
518
+ // Initialize before descendant passive effects; no effects run during SSR.
519
+ const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
520
+
521
+ export function UserReasonProvider() {
522
+ useBrowserLayoutEffect(() => {
523
+ UserReason.init(${options});
524
+ return () => UserReason.destroy();
525
+ }, []);
526
+ return null;
527
+ }
528
+ `,
529
+ instruction: framework === "next" ? "Import UserReasonProvider into your root layout and render <UserReasonProvider /> once inside <body>." : "Import UserReasonProvider into your root application and render <UserReasonProvider /> once."
530
+ };
531
+ if (framework === "vue") return { filename: "userreason.generated.ts", content: `${base}import { onBeforeMount, onBeforeUnmount } from 'vue';
532
+ import UserReason from '@userreason/js';
533
+
534
+ export function useUserReason() {
535
+ onBeforeMount(() => UserReason.init(${options}));
536
+ onBeforeUnmount(() => UserReason.destroy());
537
+ }
538
+ `, instruction: "Import useUserReason into your root Vue component and call it once inside setup()." };
539
+ return { filename: "userreason.generated.ts", content: `${base}import UserReason from '@userreason/js';
540
+
541
+ export function initializeUserReason() {
542
+ UserReason.init(${options});
543
+ return () => UserReason.destroy();
544
+ }
545
+ `, instruction: "Import initializeUserReason at your application entry and call it once after the document body is available. Call the returned cleanup function on teardown." };
546
+ }
547
+ async function writeGenerated(path, content, force) {
548
+ try {
549
+ await writeFile2(path, content, { flag: "wx", mode: 420 });
550
+ return;
551
+ } catch (error) {
552
+ if (error.code !== "EEXIST") throw error;
553
+ }
554
+ const metadata = await lstat(path);
555
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`Refusing to overwrite a non-file: ${path}`);
556
+ if (await readFile3(path, "utf8") === content) return;
557
+ if (!force) throw new Error(`Existing file differs: ${path}. Review it and rerun with --force to create a backup and replace it.`);
558
+ await copyFile(path, `${path}.backup-${Date.now()}`);
559
+ await writeFile2(path, content, { mode: 420 });
560
+ }
561
+ async function writeIntegration(project, config, force = false) {
562
+ const helper = integrationSource(project.framework, config), configPath = join3(project.directory, "userreason.config.json"), helperPath = join3(project.sourceDirectory, helper.filename);
563
+ const source = await lstat(dirname(helperPath));
564
+ if (!source.isDirectory() || source.isSymbolicLink()) throw new Error("Source directory must be a real directory inside the project");
565
+ await writeGenerated(configPath, JSON.stringify({ schemaVersion: 1, sdkVersion: ">=0.2.0", ...config }, null, 2) + "\n", force);
566
+ await writeGenerated(helperPath, helper.content, force);
567
+ return { files: [configPath, helperPath], instruction: helper.instruction };
568
+ }
569
+ async function installSdk(project) {
570
+ const args = project.packageManager === "npm" ? ["install", "--ignore-scripts", "@userreason/js@^0.2.0"] : project.packageManager === "pnpm" ? ["add", "--ignore-scripts", "@userreason/js@^0.2.0"] : (project.yarnMajor ?? 1) >= 2 ? ["add", "--mode=skip-builds", "@userreason/js@^0.2.0"] : ["add", "--ignore-scripts", "@userreason/js@^0.2.0"];
571
+ await new Promise((resolve2, reject) => {
572
+ const child = spawn(project.packageManager, args, { cwd: project.directory, stdio: ["ignore", process.stderr, process.stderr], shell: false, env: { ...process.env, YARN_ENABLE_SCRIPTS: "false", npm_config_ignore_scripts: "true" } });
573
+ child.on("error", () => reject(new Error(`Unable to start ${project.packageManager}; install @userreason/js@^0.2.0 manually`)));
574
+ child.on("exit", (code) => code === 0 ? resolve2() : reject(new Error("SDK installation failed. Publish/install the remote-compatible @userreason/js >=0.2.0 package, then rerun the wizard using the existing workspace/site.")));
575
+ });
576
+ }
577
+ async function verifyConfiguration(config, hostOrigin, fetcher = fetch) {
578
+ const response = await fetcher(`${config.apiBaseUrl}/v1/sdk/${config.workspaceId}/${config.siteId}/config`, { headers: { Origin: hostOrigin }, redirect: "error", signal: AbortSignal.timeout(2e4) });
579
+ if (!response.ok) throw new Error(`Site configuration check failed (${response.status}); verify its allowed origins and Worker deployment`);
580
+ const data = await response.json();
581
+ if (data.workspace_id !== config.workspaceId || data.site_id !== config.siteId || !data.payload || typeof data.payload !== "object") throw new Error("Worker returned a configuration for a different or invalid site");
582
+ return { configured: true, hostConnectionVerified: false };
583
+ }
584
+
585
+ // src/bin.ts
586
+ var help = `UserReason installation wizard
587
+
588
+ userreason-wizard [--directory PATH] [--dev] [--origin URL]
589
+
590
+ --workspace UUID / --workspace-name NAME
591
+ --site UUID / --site-name NAME
592
+ --environment development|production
593
+ --framework react|next|vue|vanilla
594
+ --install / --skip-install
595
+ --yes (non-interactive; requires workspace/site selections or names and origin)
596
+ --force (back up and replace generated files)
597
+ --client-id CLIENT_ID, --api-url ORIGIN, --json
598
+
599
+ Requires remote-compatible @userreason/js >=0.2.0. Only public configuration is written to your project.
600
+ `;
601
+ async function main() {
602
+ const parsed = parseArgs({ allowPositionals: false, options: { help: { type: "boolean", short: "h" }, dev: { type: "boolean" }, yes: { type: "boolean" }, install: { type: "boolean" }, "skip-install": { type: "boolean" }, force: { type: "boolean" }, json: { type: "boolean" }, directory: { type: "string" }, origin: { type: "string" }, workspace: { type: "string" }, "workspace-name": { type: "string" }, site: { type: "string" }, "site-name": { type: "string" }, environment: { type: "string" }, framework: { type: "string" }, "client-id": { type: "string" }, "api-url": { type: "string" } } });
603
+ const values = parsed.values;
604
+ if (values.help) {
605
+ process.stdout.write(help);
606
+ return;
607
+ }
608
+ if (values.install && values["skip-install"]) throw new Error("Choose either --install or --skip-install");
609
+ const framework = values.framework;
610
+ if (framework && !["react", "next", "vue", "vanilla"].includes(framework)) throw new Error("Unsupported framework");
611
+ const project = await inspectProject(values.directory ?? process.cwd(), framework);
612
+ const config = await resolveConfig({ development: values.dev, apiUrl: values["api-url"], clientId: values["client-id"] });
613
+ const credentials = keychainCredentials(config), client = new UserReasonClient(config, credentials);
614
+ const interactive = !values.yes && process.stdin.isTTY;
615
+ const terminal = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : void 0;
616
+ const ask = async (question, fallback) => {
617
+ if (!terminal) {
618
+ if (fallback !== void 0) return fallback;
619
+ throw new Error(`Missing selection: ${question}. Supply the corresponding command option for non-interactive use.`);
620
+ }
621
+ return (await terminal.question(question + (fallback ? ` [${fallback}]` : "") + ": ")).trim() || fallback || "";
622
+ };
623
+ const approve = async (workspaceId) => {
624
+ const request = await client.requestGrant({ workspaceId, scopes: workspaceId ? ["workspace:read", "site:read", "site:create", "site:update"] : ["workspace:create", "workspace:read", "site:read", "site:create", "site:update"] });
625
+ if (request.status === "approved") return request;
626
+ if (request.approval_url) process.stderr.write(`Approve this installation in your browser: ${request.approval_url}
627
+ `);
628
+ return client.waitForGrant(request.id);
629
+ };
630
+ const authorized = async (operation, workspaceId) => {
631
+ try {
632
+ return await operation();
633
+ } catch (error) {
634
+ if (!(error instanceof ApiError) || error.status !== 403 || !["authorization_required", "grant_required"].includes(error.code)) throw error;
635
+ await approve(workspaceId);
636
+ return operation();
637
+ }
638
+ };
639
+ try {
640
+ let stored = await credentials.load();
641
+ if (!stored) {
642
+ stored = await login(config, credentials, (device) => {
643
+ process.stderr.write(`Sign in at ${device.verificationUriComplete}
644
+ Confirm code: ${device.userCode}
645
+ `);
646
+ });
647
+ await saveConfig(config, values.dev);
648
+ }
649
+ let account;
650
+ try {
651
+ account = await client.account();
652
+ } catch (error) {
653
+ if (!(error instanceof AuthenticationError) || error.code !== "invalid_grant") throw error;
654
+ await login(config, credentials, (device) => {
655
+ process.stderr.write(`Sign in at ${device.verificationUriComplete}
656
+ Confirm code: ${device.userCode}
657
+ `);
658
+ });
659
+ account = await client.account();
660
+ }
661
+ let selectedWorkspace;
662
+ if (values.workspace) {
663
+ const requestedId = uuid(values.workspace);
664
+ selectedWorkspace = account.workspaces.find((item) => item.id === requestedId);
665
+ if (!selectedWorkspace) {
666
+ await approve(requestedId);
667
+ account = await client.account();
668
+ selectedWorkspace = account.workspaces.find((item) => item.id === requestedId);
669
+ }
670
+ if (!selectedWorkspace) throw new Error("Selected workspace is not available to this approved client");
671
+ } else if (!values["workspace-name"] && account.workspaces.length) {
672
+ process.stderr.write(account.workspaces.map((item, index) => `${index + 1}. ${item.name} (${item.id})`).join("\n") + "\n");
673
+ const selected = await ask("Workspace number, or 'new'", interactive ? "1" : void 0);
674
+ if (selected !== "new") selectedWorkspace = account.workspaces[Number(selected) - 1];
675
+ if (selected !== "new" && !selectedWorkspace) throw new Error("Invalid workspace selection");
676
+ }
677
+ if (!selectedWorkspace) {
678
+ const name = values["workspace-name"] ?? await ask("New workspace name");
679
+ const ids = { requestId: randomUUID3(), workspaceId: randomUUID3() };
680
+ try {
681
+ selectedWorkspace = await client.createWorkspace(name, ids);
682
+ } catch (error) {
683
+ if (!(error instanceof ApiError) || error.status !== 403 || !["authorization_required", "grant_required"].includes(error.code)) throw error;
684
+ process.stderr.write("Approve a new workspace or select an existing workspace in the browser.\n");
685
+ const approved = await approve();
686
+ account = await client.account();
687
+ const approvedGrant = account.grants.find((value) => value && typeof value === "object" && "id" in value && value.id === approved.grant_id);
688
+ if (approvedGrant?.workspace_id) {
689
+ selectedWorkspace = account.workspaces.find((item) => item.id === approvedGrant.workspace_id);
690
+ if (!selectedWorkspace) throw new Error("The approved workspace also needs workspace:read permission for installation");
691
+ process.stderr.write(`Continuing in the workspace selected during approval: ${selectedWorkspace.name}
692
+ `);
693
+ } else selectedWorkspace = await client.createWorkspace(name, ids);
694
+ }
695
+ }
696
+ const workspaceId = selectedWorkspace.id;
697
+ const sites = await authorized(() => client.sites(workspaceId), workspaceId);
698
+ let selectedSite;
699
+ if (values.site) {
700
+ selectedSite = sites.find((item) => item.id === uuid(values.site));
701
+ if (!selectedSite) throw new Error("Selected site is not in this workspace");
702
+ } else if (!values["site-name"] && sites.length) {
703
+ process.stderr.write(sites.map((item, index) => `${index + 1}. ${item.name} (${item.id})`).join("\n") + "\n");
704
+ const selected = await ask("Site number, or 'new'", interactive ? "1" : void 0);
705
+ if (selected !== "new") selectedSite = sites[Number(selected) - 1];
706
+ if (selected !== "new" && !selectedSite) throw new Error("Invalid site selection");
707
+ }
708
+ const environment = selectedSite?.environment ?? values.environment ?? (values.dev ? "development" : "production");
709
+ if (environment !== "production" && environment !== "development") throw new Error("Invalid site environment");
710
+ const hostOrigin = origin(values.origin ?? await ask("Host site origin", selectedSite?.allowed_origins[0]), environment);
711
+ if (selectedSite && !selectedSite.allowed_origins.includes(hostOrigin)) throw new Error("This origin is not allowed for the selected site. Add it through the dashboard before continuing.");
712
+ if (!selectedSite) {
713
+ const input = { workspaceId, name: values["site-name"] ?? await ask("Site name", project.name), environment, origins: [hostOrigin], requestId: randomUUID3(), siteId: randomUUID3() };
714
+ selectedSite = await authorized(() => client.createSite(input), workspaceId);
715
+ }
716
+ const installation = { workspaceId: selectedWorkspace.id, siteId: selectedSite.id, apiBaseUrl: config.apiUrl };
717
+ const shouldInstall = values.install || !values["skip-install"] && interactive && /^y(es)?$/i.test(await ask("Install @userreason/js >=0.2.0 now?", "yes"));
718
+ if (shouldInstall) await installSdk(project);
719
+ const generated = await writeIntegration(project, installation, values.force);
720
+ const verification = await verifyConfiguration(installation, hostOrigin);
721
+ process.stderr.write(`${generated.instruction}
722
+ Start your application and visit the host site to exercise the SDK session/config flow. Configuration availability is checked; a real browser connection has not been verified.
723
+ `);
724
+ process.stdout.write(JSON.stringify({ workspace: selectedWorkspace, site: selectedSite, framework: project.framework, files: generated.files, sdk_installed: Boolean(shouldInstall), sdk_required_version: ">=0.2.0", verification, next_step: generated.instruction }, null, values.json ? void 0 : 2) + "\n");
725
+ } finally {
726
+ terminal?.close();
727
+ }
728
+ }
729
+ main().catch((error) => {
730
+ const message = error instanceof Error ? error.message : "Installation failed";
731
+ process.stderr.write((process.argv.includes("--json") ? JSON.stringify({ error: "wizard_failed", message }) : message) + "\n");
732
+ process.exitCode = 1;
733
+ });
@@ -0,0 +1,108 @@
1
+ // src/project.ts
2
+ import { lstat, readFile, writeFile, access, copyFile } from "node:fs/promises";
3
+ import { join, resolve, dirname } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ var exists = async (path) => {
6
+ try {
7
+ await access(path);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ };
13
+ async function inspectProject(directory, framework) {
14
+ directory = resolve(directory);
15
+ const metadata = await lstat(directory);
16
+ if (!metadata.isDirectory()) throw new Error("Project directory must be an existing directory");
17
+ const text = await readFile(join(directory, "package.json"), "utf8");
18
+ if (text.length > 1e6) throw new Error("Project package.json is too large");
19
+ const data = JSON.parse(text);
20
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid project package.json");
21
+ const dependencies = { ...data.dependencies, ...data.devDependencies };
22
+ const inferred = dependencies.next ? "next" : dependencies.react ? "react" : dependencies.vue ? "vue" : "vanilla";
23
+ const packageManager = typeof data.packageManager === "string" && /^(pnpm|yarn)@/.test(data.packageManager) ? data.packageManager.split("@")[0] : await exists(join(directory, "pnpm-lock.yaml")) ? "pnpm" : await exists(join(directory, "yarn.lock")) ? "yarn" : "npm";
24
+ const yarnMajor = packageManager === "yarn" ? typeof data.packageManager === "string" && /^yarn@\d+/.test(data.packageManager) ? Number(data.packageManager.slice(5).split(".")[0]) : await exists(join(directory, ".yarnrc.yml")) ? 4 : 1 : void 0;
25
+ return { directory, framework: framework ?? inferred, sourceDirectory: await exists(join(directory, "src")) ? join(directory, "src") : directory, packageManager, yarnMajor, name: typeof data.name === "string" && data.name ? data.name : "My site" };
26
+ }
27
+ function integrationSource(framework, config) {
28
+ const options = JSON.stringify(config, null, 2);
29
+ const base = `// Generated by the UserReason wizard. Contains public SDK configuration only.
30
+ `;
31
+ if (framework === "next" || framework === "react") return {
32
+ filename: "userreason.generated.tsx",
33
+ content: `${framework === "next" ? "'use client';\n" : ""}${base}import { useEffect, useLayoutEffect } from 'react';
34
+ import UserReason from '@userreason/js';
35
+
36
+ // Initialize before descendant passive effects; no effects run during SSR.
37
+ const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
38
+
39
+ export function UserReasonProvider() {
40
+ useBrowserLayoutEffect(() => {
41
+ UserReason.init(${options});
42
+ return () => UserReason.destroy();
43
+ }, []);
44
+ return null;
45
+ }
46
+ `,
47
+ instruction: framework === "next" ? "Import UserReasonProvider into your root layout and render <UserReasonProvider /> once inside <body>." : "Import UserReasonProvider into your root application and render <UserReasonProvider /> once."
48
+ };
49
+ if (framework === "vue") return { filename: "userreason.generated.ts", content: `${base}import { onBeforeMount, onBeforeUnmount } from 'vue';
50
+ import UserReason from '@userreason/js';
51
+
52
+ export function useUserReason() {
53
+ onBeforeMount(() => UserReason.init(${options}));
54
+ onBeforeUnmount(() => UserReason.destroy());
55
+ }
56
+ `, instruction: "Import useUserReason into your root Vue component and call it once inside setup()." };
57
+ return { filename: "userreason.generated.ts", content: `${base}import UserReason from '@userreason/js';
58
+
59
+ export function initializeUserReason() {
60
+ UserReason.init(${options});
61
+ return () => UserReason.destroy();
62
+ }
63
+ `, instruction: "Import initializeUserReason at your application entry and call it once after the document body is available. Call the returned cleanup function on teardown." };
64
+ }
65
+ async function writeGenerated(path, content, force) {
66
+ try {
67
+ await writeFile(path, content, { flag: "wx", mode: 420 });
68
+ return;
69
+ } catch (error) {
70
+ if (error.code !== "EEXIST") throw error;
71
+ }
72
+ const metadata = await lstat(path);
73
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`Refusing to overwrite a non-file: ${path}`);
74
+ if (await readFile(path, "utf8") === content) return;
75
+ if (!force) throw new Error(`Existing file differs: ${path}. Review it and rerun with --force to create a backup and replace it.`);
76
+ await copyFile(path, `${path}.backup-${Date.now()}`);
77
+ await writeFile(path, content, { mode: 420 });
78
+ }
79
+ async function writeIntegration(project, config, force = false) {
80
+ const helper = integrationSource(project.framework, config), configPath = join(project.directory, "userreason.config.json"), helperPath = join(project.sourceDirectory, helper.filename);
81
+ const source = await lstat(dirname(helperPath));
82
+ if (!source.isDirectory() || source.isSymbolicLink()) throw new Error("Source directory must be a real directory inside the project");
83
+ await writeGenerated(configPath, JSON.stringify({ schemaVersion: 1, sdkVersion: ">=0.2.0", ...config }, null, 2) + "\n", force);
84
+ await writeGenerated(helperPath, helper.content, force);
85
+ return { files: [configPath, helperPath], instruction: helper.instruction };
86
+ }
87
+ async function installSdk(project) {
88
+ const args = project.packageManager === "npm" ? ["install", "--ignore-scripts", "@userreason/js@^0.2.0"] : project.packageManager === "pnpm" ? ["add", "--ignore-scripts", "@userreason/js@^0.2.0"] : (project.yarnMajor ?? 1) >= 2 ? ["add", "--mode=skip-builds", "@userreason/js@^0.2.0"] : ["add", "--ignore-scripts", "@userreason/js@^0.2.0"];
89
+ await new Promise((resolve2, reject) => {
90
+ const child = spawn(project.packageManager, args, { cwd: project.directory, stdio: ["ignore", process.stderr, process.stderr], shell: false, env: { ...process.env, YARN_ENABLE_SCRIPTS: "false", npm_config_ignore_scripts: "true" } });
91
+ child.on("error", () => reject(new Error(`Unable to start ${project.packageManager}; install @userreason/js@^0.2.0 manually`)));
92
+ child.on("exit", (code) => code === 0 ? resolve2() : reject(new Error("SDK installation failed. Publish/install the remote-compatible @userreason/js >=0.2.0 package, then rerun the wizard using the existing workspace/site.")));
93
+ });
94
+ }
95
+ async function verifyConfiguration(config, hostOrigin, fetcher = fetch) {
96
+ const response = await fetcher(`${config.apiBaseUrl}/v1/sdk/${config.workspaceId}/${config.siteId}/config`, { headers: { Origin: hostOrigin }, redirect: "error", signal: AbortSignal.timeout(2e4) });
97
+ if (!response.ok) throw new Error(`Site configuration check failed (${response.status}); verify its allowed origins and Worker deployment`);
98
+ const data = await response.json();
99
+ if (data.workspace_id !== config.workspaceId || data.site_id !== config.siteId || !data.payload || typeof data.payload !== "object") throw new Error("Worker returned a configuration for a different or invalid site");
100
+ return { configured: true, hostConnectionVerified: false };
101
+ }
102
+ export {
103
+ inspectProject,
104
+ installSdk,
105
+ integrationSource,
106
+ verifyConfiguration,
107
+ writeIntegration
108
+ };
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@userreason/wizard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Authenticated UserReason SDK installation wizard",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=22.12.0"
9
+ },
10
+ "bin": {
11
+ "userreason-wizard": "dist/bin.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "node scripts/build.mjs",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "npm run build && node --test tests/*.test.mjs",
21
+ "prepack": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "@napi-rs/keyring": "2.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.0.0",
28
+ "@userreason/cli": "file:../ur-cli",
29
+ "esbuild": "^0.28.0",
30
+ "react": "19.2.4",
31
+ "react-dom": "19.2.4",
32
+ "react-test-renderer": "19.2.4",
33
+ "typescript": "^5.9.0",
34
+ "vue": "3.5.43"
35
+ }
36
+ }