@userbox/cli 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js ADDED
@@ -0,0 +1,1177 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { parseArgs } from "util";
5
+
6
+ // src/commands/apply.ts
7
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
8
+ import { join as join2 } from "path";
9
+
10
+ // src/credentials.ts
11
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
12
+ import { homedir } from "os";
13
+ import { join } from "path";
14
+ function configDir() {
15
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
16
+ return join(base, "userbox");
17
+ }
18
+ function credentialsPath() {
19
+ return join(configDir(), "credentials.json");
20
+ }
21
+ function loadCredentials() {
22
+ try {
23
+ return JSON.parse(readFileSync(credentialsPath(), "utf8"));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function saveCredentials(creds) {
29
+ const dir = configDir();
30
+ mkdirSync(dir, { recursive: true, mode: 448 });
31
+ chmodSync(dir, 448);
32
+ const path = credentialsPath();
33
+ writeFileSync(path, `${JSON.stringify(creds, null, 2)}
34
+ `, { mode: 384 });
35
+ chmodSync(path, 384);
36
+ }
37
+ function clearCredentials() {
38
+ rmSync(credentialsPath(), { force: true });
39
+ }
40
+
41
+ // src/output.ts
42
+ var CliError = class extends Error {
43
+ constructor(code, message, hint) {
44
+ super(message);
45
+ this.code = code;
46
+ this.hint = hint;
47
+ }
48
+ code;
49
+ hint;
50
+ };
51
+ function createOutput(json) {
52
+ const warnings = [];
53
+ return {
54
+ json,
55
+ step(message) {
56
+ if (!json) process.stdout.write(` ${message}
57
+ `);
58
+ },
59
+ warn(message) {
60
+ warnings.push(message);
61
+ if (!json) process.stderr.write(` ! ${message}
62
+ `);
63
+ },
64
+ done(result, lines) {
65
+ if (json) {
66
+ process.stdout.write(`${JSON.stringify({ ok: true, ...result, warnings }, null, 2)}
67
+ `);
68
+ } else {
69
+ for (const line of lines) process.stdout.write(`${line}
70
+ `);
71
+ }
72
+ }
73
+ };
74
+ }
75
+ function printError(err, json) {
76
+ const e = err instanceof CliError ? err : new CliError("UNEXPECTED", err instanceof Error ? err.message : String(err));
77
+ if (json) {
78
+ process.stdout.write(
79
+ `${JSON.stringify({ ok: false, error: { code: e.code, message: e.message, hint: e.hint ?? null } }, null, 2)}
80
+ `
81
+ );
82
+ } else {
83
+ process.stderr.write(`error [${e.code}]: ${e.message}
84
+ `);
85
+ if (e.hint) process.stderr.write(`hint: ${e.hint}
86
+ `);
87
+ }
88
+ }
89
+
90
+ // src/api.ts
91
+ var DEFAULT_API_URL = "https://api.test.userbox.app";
92
+ var ApiError = class extends CliError {
93
+ constructor(status, code, message) {
94
+ super(code, `${message} (HTTP ${status})`);
95
+ this.status = status;
96
+ }
97
+ status;
98
+ };
99
+ async function parseError(res) {
100
+ let body = {};
101
+ try {
102
+ body = await res.json();
103
+ } catch {
104
+ }
105
+ const message = body.error ?? (typeof body.detail === "string" ? body.detail : body.detail ? JSON.stringify(body.detail) : res.statusText);
106
+ return new ApiError(res.status, body.code ?? `HTTP_${res.status}`, message);
107
+ }
108
+ async function rawRequest(apiUrl, path, opts = {}) {
109
+ let res;
110
+ try {
111
+ res = await fetch(`${apiUrl.replace(/\/$/, "")}${path}`, {
112
+ method: opts.method ?? "GET",
113
+ headers: {
114
+ Accept: "application/json",
115
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {},
116
+ ...opts.headers
117
+ },
118
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
119
+ });
120
+ } catch (err) {
121
+ throw new CliError("NETWORK", `cannot reach ${apiUrl}: ${err.message}`);
122
+ }
123
+ if (!res.ok) throw await parseError(res);
124
+ if (res.status === 204) return void 0;
125
+ return await res.json();
126
+ }
127
+ function expiryFrom(tokens) {
128
+ return Date.now() + (tokens.expires_in ?? 900) * 1e3;
129
+ }
130
+ var Session = class _Session {
131
+ constructor(creds) {
132
+ this.creds = creds;
133
+ }
134
+ creds;
135
+ static load() {
136
+ const creds = loadCredentials();
137
+ if (!creds) {
138
+ throw new CliError(
139
+ "NOT_LOGGED_IN",
140
+ "not logged in",
141
+ "Ask the human to run `npx @userbox/cli login` in their own terminal, then retry."
142
+ );
143
+ }
144
+ return new _Session(creds);
145
+ }
146
+ get apiUrl() {
147
+ return this.creds.apiUrl;
148
+ }
149
+ get tenantSlug() {
150
+ return this.creds.tenantSlug;
151
+ }
152
+ get tenantId() {
153
+ return this.creds.tenantId;
154
+ }
155
+ get email() {
156
+ return this.creds.email;
157
+ }
158
+ /** The logged-in user's id: the `sub` of our own access token (not an authorization decision). */
159
+ get userId() {
160
+ const payload = this.creds.accessToken.split(".")[1] ?? "";
161
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).sub;
162
+ }
163
+ async refresh() {
164
+ try {
165
+ const tokens = await rawRequest(this.creds.apiUrl, "/v1/auth/refresh", {
166
+ method: "POST",
167
+ body: { refresh_token: this.creds.refreshToken },
168
+ headers: { "X-Tenant-ID": this.creds.tenantId }
169
+ });
170
+ this.creds = {
171
+ ...this.creds,
172
+ accessToken: tokens.access_token,
173
+ refreshToken: tokens.refresh_token,
174
+ accessExpiresAt: expiryFrom(tokens)
175
+ };
176
+ saveCredentials(this.creds);
177
+ } catch (err) {
178
+ if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
179
+ throw new CliError(
180
+ "SESSION_EXPIRED",
181
+ "the CLI login has expired",
182
+ "Ask the human to run `npx @userbox/cli login` again."
183
+ );
184
+ }
185
+ throw err;
186
+ }
187
+ }
188
+ async request(path, opts = {}) {
189
+ if (Date.now() > this.creds.accessExpiresAt - 3e4) await this.refresh();
190
+ const headers = {
191
+ Authorization: `Bearer ${this.creds.accessToken}`,
192
+ "X-Tenant-ID": this.creds.tenantId,
193
+ ...opts.headers
194
+ };
195
+ if (opts.envId) headers["X-Environment-ID"] = opts.envId;
196
+ try {
197
+ return await rawRequest(this.creds.apiUrl, path, { ...opts, headers });
198
+ } catch (err) {
199
+ if (err instanceof ApiError && err.status === 401) {
200
+ await this.refresh();
201
+ headers.Authorization = `Bearer ${this.creds.accessToken}`;
202
+ return rawRequest(this.creds.apiUrl, path, { ...opts, headers });
203
+ }
204
+ throw err;
205
+ }
206
+ }
207
+ };
208
+
209
+ // src/manifest.ts
210
+ import { parse } from "yaml";
211
+ var PERMISSION_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*){1,4}$/;
212
+ function parseManifest(text) {
213
+ let raw;
214
+ try {
215
+ raw = parse(text);
216
+ } catch (err) {
217
+ throw new CliError("MANIFEST_INVALID", `userbox.yaml is not valid YAML: ${err.message}`);
218
+ }
219
+ const doc = raw ?? {};
220
+ const permissions = doc.permissions ?? [];
221
+ if (!Array.isArray(permissions) || permissions.some((p) => typeof p !== "string")) {
222
+ throw new CliError("MANIFEST_INVALID", "`permissions` must be a list of strings");
223
+ }
224
+ for (const p of permissions) {
225
+ if (!PERMISSION_KEY.test(p)) {
226
+ throw new CliError(
227
+ "MANIFEST_INVALID",
228
+ `invalid permission key "${p}"`,
229
+ "Use 2 to 5 lowercase segments separated by dots, for example projects.delete."
230
+ );
231
+ }
232
+ }
233
+ const rolesRaw = doc.roles ?? {};
234
+ if (typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
235
+ throw new CliError("MANIFEST_INVALID", "`roles` must map role names to lists of permissions");
236
+ }
237
+ const declared = new Set(permissions);
238
+ const roles = {};
239
+ for (const [name, list] of Object.entries(rolesRaw)) {
240
+ if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) {
241
+ throw new CliError("MANIFEST_INVALID", `role "${name}" must be a list of permission keys`);
242
+ }
243
+ const expanded = /* @__PURE__ */ new Set();
244
+ for (const entry of list) {
245
+ if (entry.endsWith(".*")) {
246
+ const prefix = entry.slice(0, -1);
247
+ const matches = [...declared].filter((p) => p.startsWith(prefix));
248
+ if (matches.length === 0) {
249
+ throw new CliError("MANIFEST_INVALID", `role "${name}": "${entry}" matches no declared permission`);
250
+ }
251
+ matches.forEach((m) => expanded.add(m));
252
+ } else if (!declared.has(entry)) {
253
+ throw new CliError(
254
+ "MANIFEST_INVALID",
255
+ `role "${name}" uses "${entry}", which is not under \`permissions\``
256
+ );
257
+ } else {
258
+ expanded.add(entry);
259
+ }
260
+ }
261
+ roles[name] = [...expanded].sort();
262
+ }
263
+ return { permissions: [...declared].sort(), roles };
264
+ }
265
+ function planApply(manifest, remote) {
266
+ const remotePerms = new Set(remote.permissions);
267
+ const plan = {
268
+ createPermissions: manifest.permissions.filter((p) => !remotePerms.has(p)),
269
+ createRoles: Object.keys(manifest.roles).filter((r) => !(r in remote.roles)).sort(),
270
+ grant: [],
271
+ revoke: [],
272
+ extraPermissions: remote.permissions.filter((p) => !manifest.permissions.includes(p)).sort(),
273
+ extraRoles: Object.keys(remote.roles).filter((r) => !(r in manifest.roles)).sort()
274
+ };
275
+ for (const [role, wanted] of Object.entries(manifest.roles)) {
276
+ const have = new Set(remote.roles[role] ?? []);
277
+ for (const p of wanted) if (!have.has(p)) plan.grant.push({ role, permission: p });
278
+ for (const p of have) if (!wanted.includes(p)) plan.revoke.push({ role, permission: p });
279
+ }
280
+ return plan;
281
+ }
282
+ function planIsEmpty(plan) {
283
+ return plan.createPermissions.length === 0 && plan.createRoles.length === 0 && plan.grant.length === 0 && plan.revoke.length === 0;
284
+ }
285
+ function planRevokes(plan) {
286
+ return plan.revoke.length > 0;
287
+ }
288
+
289
+ // src/prompt.ts
290
+ import { createInterface } from "readline";
291
+ import { Writable } from "stream";
292
+ function requireTty(command) {
293
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
294
+ throw new CliError(
295
+ "NEEDS_HUMAN",
296
+ `\`userbox ${command}\` must be run by a human in an interactive terminal`,
297
+ `Ask the human to run \`npx @userbox/cli ${command}\` in their own terminal, then continue.`
298
+ );
299
+ }
300
+ }
301
+ async function ask(question, { secret = false, fallback = "" } = {}) {
302
+ let muted = false;
303
+ const output = new Writable({
304
+ write(chunk, _enc, cb) {
305
+ if (!muted) process.stdout.write(chunk);
306
+ cb();
307
+ }
308
+ });
309
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
310
+ const label = fallback ? `${question} (${fallback}): ` : `${question}: `;
311
+ try {
312
+ return await new Promise((resolve) => {
313
+ rl.question(label, (answer) => {
314
+ if (secret) process.stdout.write("\n");
315
+ resolve(answer.trim() || fallback);
316
+ });
317
+ muted = secret;
318
+ });
319
+ } finally {
320
+ rl.close();
321
+ }
322
+ }
323
+ async function confirm(question) {
324
+ const answer = await ask(`${question} [y/N]`);
325
+ return /^y(es)?$/i.test(answer);
326
+ }
327
+
328
+ // src/userbox.ts
329
+ async function defaultEnvironment(s) {
330
+ const { environments } = await s.request("/v1/environments");
331
+ const env = environments.find((e) => e.isDefault);
332
+ if (!env) throw new CliError("NO_DEFAULT_ENVIRONMENT", "the tenant has no default environment");
333
+ return env;
334
+ }
335
+ function getAuthSettings(s, env) {
336
+ return s.request(`/v1/environments/${env.id}/auth-settings`);
337
+ }
338
+ async function ensureRedirectDomain(s, env, host) {
339
+ const settings = await getAuthSettings(s, env);
340
+ const domains = settings.allowed_redirect_domains ?? [];
341
+ if (domains.includes(host)) return false;
342
+ await s.request(`/v1/environments/${env.id}/auth-settings`, {
343
+ method: "PATCH",
344
+ body: { allowed_redirect_domains: [...domains, host] }
345
+ });
346
+ return true;
347
+ }
348
+ async function createApiKey(s, env, name, scopes) {
349
+ const res = await s.request("/v1/api-keys", {
350
+ method: "POST",
351
+ envId: env.id,
352
+ body: { name, scopes }
353
+ });
354
+ return res.key;
355
+ }
356
+ async function listWorkspaces(s, env) {
357
+ const res = await s.request("/v1/workspaces?status=active&per_page=100", {
358
+ envId: env.id
359
+ });
360
+ return res.items;
361
+ }
362
+ function createWorkspace(s, env, name, slug) {
363
+ return s.request("/v1/workspaces", { method: "POST", envId: env.id, body: { name, slug } });
364
+ }
365
+ async function listExternalPermissions(s, env) {
366
+ const all = [];
367
+ let cursor = null;
368
+ do {
369
+ const q = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
370
+ const page = await s.request(
371
+ `/v1/permissions?type=external&limit=200${q}`,
372
+ { envId: env.id }
373
+ );
374
+ all.push(...page.items);
375
+ cursor = page.next_cursor;
376
+ } while (cursor);
377
+ return all;
378
+ }
379
+ function createPermission(s, env, key) {
380
+ return s.request("/v1/permissions", { method: "POST", envId: env.id, body: { key, name: key } });
381
+ }
382
+ async function listRoles(s, env, workspaceId) {
383
+ const res = await s.request(`/v1/workspaces/${workspaceId}/roles?limit=200`, {
384
+ envId: env.id
385
+ });
386
+ return res.items;
387
+ }
388
+ function createRole(s, env, workspaceId, name) {
389
+ return s.request(`/v1/workspaces/${workspaceId}/roles`, { method: "POST", envId: env.id, body: { name } });
390
+ }
391
+ async function listRolePermissions(s, env, workspaceId, roleId) {
392
+ const res = await s.request(
393
+ `/v1/workspaces/${workspaceId}/roles/${roleId}/permissions?limit=200`,
394
+ { envId: env.id }
395
+ );
396
+ return res.items;
397
+ }
398
+ async function listMemberships(s, workspaceId) {
399
+ const res = await s.request(
400
+ `/v1/memberships?workspace_id=${workspaceId}&per_page=100`
401
+ );
402
+ return res.items;
403
+ }
404
+ function inviteMember(s, workspaceId, email) {
405
+ return s.request("/v1/memberships", { method: "POST", body: { email, workspace_id: workspaceId } });
406
+ }
407
+ function assignRole(s, membershipId, roleId) {
408
+ return s.request(`/v1/memberships/${membershipId}/roles`, { method: "POST", body: { role_id: roleId } });
409
+ }
410
+ function grantRolePermission(s, env, workspaceId, roleId, permissionId) {
411
+ return s.request(`/v1/workspaces/${workspaceId}/roles/${roleId}/permissions`, {
412
+ method: "POST",
413
+ envId: env.id,
414
+ body: { permission_id: permissionId, effect: "allow" }
415
+ });
416
+ }
417
+ function revokeRolePermission(s, env, workspaceId, roleId, permissionId) {
418
+ return s.request(`/v1/workspaces/${workspaceId}/roles/${roleId}/permissions/${permissionId}`, {
419
+ method: "DELETE",
420
+ envId: env.id
421
+ });
422
+ }
423
+
424
+ // src/commands/apply.ts
425
+ function isInteractive(flags) {
426
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !flags.json;
427
+ }
428
+ async function confirmRevokes(sync, flags, command) {
429
+ if (!sync.plans.some((p) => planRevokes(p.plan))) return true;
430
+ const lines = describe(sync).filter((l) => l.startsWith(" - "));
431
+ if (!isInteractive(flags)) {
432
+ throw new CliError(
433
+ "NEEDS_HUMAN",
434
+ `the plan removes permissions from existing roles:
435
+ ${lines.join("\n")}`,
436
+ `Ask the human to review it and run \`npx @userbox/cli ${command}\` in their own terminal.`
437
+ );
438
+ }
439
+ process.stdout.write(`This removes permissions from existing roles:
440
+ ${lines.join("\n")}
441
+ `);
442
+ return confirm("Remove them?");
443
+ }
444
+ function loadManifest(root) {
445
+ const path = join2(root, "userbox.yaml");
446
+ if (!existsSync(path)) {
447
+ throw new CliError(
448
+ "NO_MANIFEST",
449
+ "no userbox.yaml in this directory",
450
+ "Create one:\n permissions: [projects.read, projects.delete]\n roles:\n admin: [projects.*]\n member: [projects.read]"
451
+ );
452
+ }
453
+ return parseManifest(readFileSync2(path, "utf8"));
454
+ }
455
+ async function workspacePlan(s, env, manifest, ws, envPerms) {
456
+ const roles = await listRoles(s, env, ws.id);
457
+ const remoteRoles = {};
458
+ const roleIds = /* @__PURE__ */ new Map();
459
+ for (const role of roles) {
460
+ roleIds.set(role.name, role.id);
461
+ const perms = await listRolePermissions(s, env, ws.id, role.id);
462
+ remoteRoles[role.name] = perms.filter((p) => p.type === "external").map((p) => p.key);
463
+ }
464
+ return { workspace: ws, plan: planApply(manifest, { permissions: envPerms, roles: remoteRoles }), roleIds };
465
+ }
466
+ async function executeRoles(s, env, wp, permissionIds) {
467
+ const ids = new Map(wp.roleIds);
468
+ for (const name of wp.plan.createRoles) {
469
+ ids.set(name, (await createRole(s, env, wp.workspace.id, name)).id);
470
+ }
471
+ for (const { role, permission } of wp.plan.grant) {
472
+ await grantRolePermission(s, env, wp.workspace.id, ids.get(role), permissionIds.get(permission));
473
+ }
474
+ for (const { role, permission } of wp.plan.revoke) {
475
+ const pid = permissionIds.get(permission);
476
+ if (pid) await revokeRolePermission(s, env, wp.workspace.id, ids.get(role), pid);
477
+ }
478
+ }
479
+ async function syncManifest(s, env, manifest, workspaces) {
480
+ const existing = await listExternalPermissions(s, env);
481
+ const permissionIds = new Map(existing.map((p) => [p.key, p.id]));
482
+ const plans = [];
483
+ for (const ws of workspaces) plans.push(await workspacePlan(s, env, manifest, ws, [...permissionIds.keys()]));
484
+ return { createdPermissions: manifest.permissions.filter((p) => !permissionIds.has(p)), plans, permissionIds };
485
+ }
486
+ async function executeSync(s, env, sync) {
487
+ for (const key of sync.createdPermissions) {
488
+ sync.permissionIds.set(key, (await createPermission(s, env, key)).id);
489
+ }
490
+ for (const wp of sync.plans) await executeRoles(s, env, wp, sync.permissionIds);
491
+ }
492
+ function describe(sync) {
493
+ const lines = [];
494
+ for (const p of sync.createdPermissions) lines.push(` + permission ${p}`);
495
+ for (const { workspace, plan } of sync.plans) {
496
+ const label = workspace.slug ?? workspace.name;
497
+ for (const r of plan.createRoles) lines.push(` + role ${r} in ${label}`);
498
+ for (const g of plan.grant) lines.push(` + ${g.role} \u2192 ${g.permission} in ${label}`);
499
+ for (const r of plan.revoke) lines.push(` - ${r.role} \u2192 ${r.permission} in ${label}`);
500
+ }
501
+ return lines;
502
+ }
503
+ async function apply(flags, out) {
504
+ const manifest = loadManifest(process.cwd());
505
+ const session = Session.load();
506
+ const env = await defaultEnvironment(session);
507
+ const workspaces = await listWorkspaces(session, env);
508
+ if (workspaces.length === 0) {
509
+ out.warn("No workspaces yet: roles are created per workspace. Run `userbox dev seed` to create one.");
510
+ }
511
+ const sync = await syncManifest(session, env, manifest, workspaces);
512
+ const changes = describe(sync);
513
+ const extras = new Set(sync.plans.flatMap((p) => p.plan.extraPermissions));
514
+ if (extras.size > 0) out.warn(`Not in userbox.yaml, left as they are: ${[...extras].join(", ")}`);
515
+ const empty = sync.createdPermissions.length === 0 && sync.plans.every((p) => planIsEmpty(p.plan));
516
+ const summary = {
517
+ environment: { id: env.id, name: env.name },
518
+ workspaces: workspaces.map((w) => w.slug ?? w.id),
519
+ changes
520
+ };
521
+ if (empty) {
522
+ out.done({ ...summary, applied: false, upToDate: true }, ["Up to date: nothing to apply."]);
523
+ return;
524
+ }
525
+ if (!flags.yes) {
526
+ if (!isInteractive(flags)) {
527
+ out.done({ ...summary, applied: false, hint: "Re-run with --yes to apply." }, [
528
+ "Plan:",
529
+ ...changes,
530
+ "Re-run with --yes to apply."
531
+ ]);
532
+ return;
533
+ }
534
+ process.stdout.write(`Plan for ${env.name}:
535
+ ${changes.join("\n")}
536
+ `);
537
+ if (!await confirm("Apply these changes?")) {
538
+ out.done({ ...summary, applied: false }, ["Nothing applied."]);
539
+ return;
540
+ }
541
+ } else if (!await confirmRevokes(sync, flags, "apply")) {
542
+ out.done({ ...summary, applied: false }, ["Nothing applied."]);
543
+ return;
544
+ }
545
+ await executeSync(session, env, sync);
546
+ out.done({ ...summary, applied: true }, ["Applied:", ...changes]);
547
+ }
548
+
549
+ // src/commands/dev-seed.ts
550
+ import { existsSync as existsSync2 } from "fs";
551
+ import { join as join3 } from "path";
552
+ var SLUG = "dev";
553
+ async function devSeed(flags, out) {
554
+ const session = Session.load();
555
+ const env = await defaultEnvironment(session);
556
+ if (env.type === "production") {
557
+ out.warn(`The default environment "${env.name}" is a production environment; the dev workspace is created there.`);
558
+ }
559
+ const workspaces = await listWorkspaces(session, env);
560
+ let ws = workspaces.find((w) => w.slug === SLUG);
561
+ if (ws) out.step(`Workspace ${SLUG} exists`);
562
+ else {
563
+ ws = await createWorkspace(session, env, "Dev workspace", SLUG);
564
+ out.step(`Created workspace ${SLUG}`);
565
+ }
566
+ if (existsSync2(join3(process.cwd(), "userbox.yaml"))) {
567
+ const sync = await syncManifest(session, env, loadManifest(process.cwd()), [ws]);
568
+ if (await confirmRevokes(sync, flags, "dev seed")) {
569
+ await executeSync(session, env, sync);
570
+ out.step("Synced userbox.yaml roles into the workspace");
571
+ } else {
572
+ out.warn("Skipped the userbox.yaml sync: nothing was removed from the roles.");
573
+ }
574
+ } else {
575
+ out.warn("No userbox.yaml: the workspace has no roles yet. Add one and run `userbox apply`.");
576
+ }
577
+ const roles = new Map((await listRoles(session, env, ws.id)).map((r) => [r.name, r.id]));
578
+ const memberships = await listMemberships(session, ws.id);
579
+ const mine = memberships.find((m) => m.user_id === session.userId);
580
+ let adminAssigned = false;
581
+ const adminRole = roles.get("admin");
582
+ if (mine && adminRole) {
583
+ await assignRole(session, mine.id, adminRole);
584
+ adminAssigned = true;
585
+ out.step(`You (${session.email}) have the admin role in ${SLUG}`);
586
+ } else if (!adminRole) {
587
+ out.warn("No `admin` role in userbox.yaml, so your membership has no role.");
588
+ } else {
589
+ out.warn("You have no membership in the dev workspace; it was created by another user.");
590
+ }
591
+ const invited = [];
592
+ for (const spec of flags.invite) {
593
+ const [email, roleName = "member"] = spec.split(":");
594
+ if (!email) continue;
595
+ const already = memberships.find((m) => m.email?.toLowerCase() === email.toLowerCase());
596
+ const membership = already ?? await inviteMember(session, ws.id, email);
597
+ const roleId = roles.get(roleName);
598
+ if (roleId) await assignRole(session, membership.id, roleId);
599
+ else out.warn(`No role "${roleName}" for ${email}; invited without a role.`);
600
+ invited.push({ email, role: roleId ? roleName : null });
601
+ out.step(already ? `${email} is already a member` : `Invited ${email} as ${roleName}`);
602
+ }
603
+ out.done(
604
+ { workspace: { id: ws.id, slug: SLUG }, environment: { id: env.id, name: env.name }, adminAssigned, invited },
605
+ [
606
+ `Dev workspace: ${ws.id}`,
607
+ "Sign in to your app with your Userbox login; you are admin in this workspace.",
608
+ ...flags.invite.length === 0 ? ["Add a second user with `userbox dev seed --invite someone@example.com:member` (they get an email)."] : []
609
+ ]
610
+ );
611
+ }
612
+
613
+ // src/commands/doctor.ts
614
+ import { randomUUID } from "crypto";
615
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
616
+ import { join as join5 } from "path";
617
+
618
+ // src/envfile.ts
619
+ var ENV_KEYS = [
620
+ "NEXT_PUBLIC_USERBOX_TENANT_SLUG",
621
+ "NEXT_PUBLIC_USERBOX_API_URL",
622
+ "USERBOX_TENANT_ID",
623
+ "USERBOX_API_KEY",
624
+ "APP_ORIGIN"
625
+ ];
626
+ var LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
627
+ function parseEnv(text) {
628
+ const out = /* @__PURE__ */ new Map();
629
+ for (const raw of text.split(/\r?\n/)) {
630
+ const m = LINE.exec(raw);
631
+ if (!m) continue;
632
+ let value = (m[2] ?? "").trim();
633
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
634
+ value = value.slice(1, -1);
635
+ }
636
+ out.set(m[1], value);
637
+ }
638
+ return out;
639
+ }
640
+ function mergeEnv(existing, values, force = /* @__PURE__ */ new Set()) {
641
+ const current = parseEnv(existing);
642
+ const added = [];
643
+ const replaced = [];
644
+ const kept = [];
645
+ const lines = existing === "" ? [] : existing.replace(/\n$/, "").split(/\r?\n/);
646
+ const toAppend = [];
647
+ for (const [key, value] of Object.entries(values)) {
648
+ const had = current.get(key);
649
+ if (had && !force.has(key)) {
650
+ kept.push(key);
651
+ continue;
652
+ }
653
+ const idx = lines.findIndex((l) => LINE.exec(l)?.[1] === key);
654
+ if (idx >= 0) {
655
+ lines[idx] = `${key}=${value}`;
656
+ (had ? replaced : added).push(key);
657
+ } else {
658
+ toAppend.push(`${key}=${value}`);
659
+ added.push(key);
660
+ }
661
+ }
662
+ if (toAppend.length > 0) {
663
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
664
+ lines.push("# Userbox (written by `userbox init`)", ...toAppend);
665
+ }
666
+ return { text: lines.length ? `${lines.join("\n")}
667
+ ` : "", added, replaced, kept };
668
+ }
669
+
670
+ // src/project.ts
671
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
672
+ import { join as join4 } from "path";
673
+ function detectPackageManager(root) {
674
+ if (existsSync3(join4(root, "pnpm-lock.yaml"))) return "pnpm";
675
+ if (existsSync3(join4(root, "yarn.lock"))) return "yarn";
676
+ if (existsSync3(join4(root, "bun.lockb")) || existsSync3(join4(root, "bun.lock"))) return "bun";
677
+ return "npm";
678
+ }
679
+ function detectNextProject(root) {
680
+ const pkgPath = join4(root, "package.json");
681
+ if (!existsSync3(pkgPath)) {
682
+ throw new CliError("NOT_A_PROJECT", `no package.json in ${root}`, "Run this in the root of your Next.js app.");
683
+ }
684
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
685
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
686
+ if (!deps.next) {
687
+ throw new CliError(
688
+ "UNSUPPORTED_FRAMEWORK",
689
+ "this is not a Next.js app (no `next` dependency)",
690
+ "`userbox init` supports Next.js App Router only for now; see https://docs.test.userbox.app/llms.txt for other stacks."
691
+ );
692
+ }
693
+ const appDir = existsSync3(join4(root, "src", "app")) ? "src/app" : existsSync3(join4(root, "app")) ? "app" : null;
694
+ if (!appDir) {
695
+ throw new CliError(
696
+ "UNSUPPORTED_FRAMEWORK",
697
+ "no `app/` or `src/app/` directory: only the App Router is supported"
698
+ );
699
+ }
700
+ return {
701
+ root,
702
+ appDir,
703
+ packageManager: detectPackageManager(root),
704
+ nextVersion: deps.next ?? null,
705
+ typescript: existsSync3(join4(root, "tsconfig.json"))
706
+ };
707
+ }
708
+ function installCommand(pm, packages) {
709
+ switch (pm) {
710
+ case "pnpm":
711
+ return ["pnpm", ["add", ...packages]];
712
+ case "yarn":
713
+ return ["yarn", ["add", ...packages]];
714
+ case "bun":
715
+ return ["bun", ["add", ...packages]];
716
+ default:
717
+ return ["npm", ["install", ...packages]];
718
+ }
719
+ }
720
+
721
+ // src/commands/doctor.ts
722
+ var MIN = { "@userbox/react": 4, "@userbox/node": 1 };
723
+ function alphaNumber(version) {
724
+ const m = /^1\.0\.0-alpha\.(\d+)$/.exec(version);
725
+ if (m) return Number(m[1]);
726
+ return /^\d+\.\d+\.\d+$/.test(version) && !version.startsWith("0.") ? Number.POSITIVE_INFINITY : null;
727
+ }
728
+ async function doctor(flags, out) {
729
+ const root = process.cwd();
730
+ const checks = [];
731
+ const add = (c) => {
732
+ checks.push(c);
733
+ const mark = { pass: "\u2713", warn: "!", fail: "\u2717", skip: "-" }[c.status];
734
+ out.step(`${mark} ${c.name}: ${c.message}${c.hint && c.status !== "pass" ? `
735
+ \u2192 ${c.hint}` : ""}`);
736
+ };
737
+ let appDir = "app";
738
+ try {
739
+ const project = detectNextProject(root);
740
+ appDir = project.appDir;
741
+ add({ name: "project", status: "pass", message: `Next.js ${project.nextVersion ?? ""} (${appDir})`.trim() });
742
+ } catch (err) {
743
+ add({ name: "project", status: "fail", message: err.message, hint: err.hint });
744
+ }
745
+ for (const [name, min] of Object.entries(MIN)) {
746
+ const pkgPath = join5(root, "node_modules", name, "package.json");
747
+ if (!existsSync4(pkgPath)) {
748
+ add({ name, status: "fail", message: "not installed", hint: "Run `userbox init` or install @userbox/react@alpha @userbox/node@alpha." });
749
+ continue;
750
+ }
751
+ const version = JSON.parse(readFileSync4(pkgPath, "utf8")).version;
752
+ const n = alphaNumber(version);
753
+ add(
754
+ n !== null && n >= min ? { name, status: "pass", message: version } : { name, status: "fail", message: `${version} is too old`, hint: `Install ${name}@alpha (1.0.0-alpha.${min} or newer).` }
755
+ );
756
+ }
757
+ const files = [
758
+ `${appDir}/api/auth/refresh/route.ts`,
759
+ `${appDir}/auth/callback/page.tsx`
760
+ ];
761
+ for (const f of files) {
762
+ const found = [f, f.replace(/\.tsx?$/, ".js"), f.replace(/\.tsx$/, ".jsx")].some((p) => existsSync4(join5(root, p)));
763
+ add(found ? { name: f, status: "pass", message: "present" } : { name: f, status: "fail", message: "missing", hint: "Run `userbox init` to write it." });
764
+ }
765
+ const envPath = join5(root, ".env.local");
766
+ const env = existsSync4(envPath) ? parseEnv(readFileSync4(envPath, "utf8")) : /* @__PURE__ */ new Map();
767
+ const missing = ENV_KEYS.filter((k) => !env.get(k));
768
+ add(
769
+ missing.length === 0 ? { name: ".env.local", status: "pass", message: "all five variables set" } : { name: ".env.local", status: "fail", message: `missing ${missing.join(", ")}`, hint: "Run `userbox init` (it never overwrites existing values without --force)." }
770
+ );
771
+ const leaked = [...env.entries()].filter(
772
+ ([k, v]) => k.startsWith("NEXT_PUBLIC_") && (/KEY|SECRET|TOKEN|PASSWORD/.test(k) || /^ub_(live|prod|stg|dev)_/.test(v))
773
+ );
774
+ add(
775
+ leaked.length === 0 ? { name: "secrets", status: "pass", message: "no secret in a NEXT_PUBLIC_ variable" } : { name: "secrets", status: "fail", message: `${leaked.map(([k]) => k).join(", ")} is exposed to the browser`, hint: "Rename it without NEXT_PUBLIC_ and rotate the key in the console." }
776
+ );
777
+ const gitignore = existsSync4(join5(root, ".gitignore")) ? readFileSync4(join5(root, ".gitignore"), "utf8") : "";
778
+ add(
779
+ /^(\.env\*|\.env\.local|\.env\*\.local|\*\.local)\s*$/m.test(gitignore) ? { name: ".gitignore", status: "pass", message: ".env.local is ignored" } : { name: ".gitignore", status: "fail", message: ".env.local is not ignored", hint: "Add `.env.local` to .gitignore before committing." }
780
+ );
781
+ const apiUrl = env.get("NEXT_PUBLIC_USERBOX_API_URL");
782
+ const apiKey = env.get("USERBOX_API_KEY");
783
+ const tenantId = env.get("USERBOX_TENANT_ID");
784
+ if (apiUrl) {
785
+ try {
786
+ const jwks = await rawRequest(apiUrl, "/.well-known/jwks.json");
787
+ add({ name: "api", status: jwks.keys.length > 0 ? "pass" : "fail", message: `${apiUrl} reachable, JWKS has ${jwks.keys.length} key(s)` });
788
+ } catch (err) {
789
+ add({ name: "api", status: "fail", message: err.message, hint: "Check NEXT_PUBLIC_USERBOX_API_URL." });
790
+ }
791
+ }
792
+ if (apiUrl && apiKey) {
793
+ const auth = { Authorization: `Bearer ${apiKey}` };
794
+ try {
795
+ const t = await rawRequest(apiUrl, "/v1/tenants/me", { headers: auth });
796
+ const slugOk = t.slug === env.get("NEXT_PUBLIC_USERBOX_TENANT_SLUG");
797
+ add(
798
+ t.id === tenantId && slugOk ? { name: "api key", status: "pass", message: `valid for tenant ${t.slug}` } : { name: "api key", status: "fail", message: `the key belongs to tenant ${t.slug} (${t.id}), not the one in .env.local`, hint: "Fix USERBOX_TENANT_ID / NEXT_PUBLIC_USERBOX_TENANT_SLUG, or run `userbox init --force`." }
799
+ );
800
+ } catch (err) {
801
+ add({ name: "api key", status: "fail", message: err.message, hint: "The key is revoked or wrong: run `userbox init --force` for a new one." });
802
+ }
803
+ try {
804
+ await rawRequest(apiUrl, "/v1/access/check", {
805
+ method: "POST",
806
+ headers: auth,
807
+ body: { user_id: randomUUID(), workspace_id: randomUUID(), permission_key: "doctor.probe" }
808
+ });
809
+ add({ name: "scope access:check", status: "pass", message: "granted" });
810
+ } catch (err) {
811
+ const scope = err instanceof ApiError && err.code === "INSUFFICIENT_SCOPE";
812
+ add(scope ? { name: "scope access:check", status: "fail", message: "missing: can() always returns false", hint: "Run `userbox init --force` for a key with the right scopes." } : { name: "scope access:check", status: "pass", message: "granted" });
813
+ }
814
+ }
815
+ const creds = loadCredentials();
816
+ if (!creds) {
817
+ add({ name: "redirect domain", status: "skip", message: "not logged in", hint: "Run `userbox login` to also check the tenant settings." });
818
+ } else {
819
+ try {
820
+ const session = Session.load();
821
+ if (tenantId && session.tenantId !== tenantId) {
822
+ add({ name: "login", status: "warn", message: `logged in to ${session.tenantSlug}, but .env.local points at another tenant` });
823
+ } else {
824
+ const envr = await defaultEnvironment(session);
825
+ const settings = await getAuthSettings(session, envr);
826
+ const host = new URL(env.get("APP_ORIGIN") ?? "http://localhost:3000").hostname;
827
+ const domains = settings.allowed_redirect_domains ?? [];
828
+ add(domains.includes(host) ? { name: "redirect domain", status: "pass", message: `${host} allowed` } : { name: "redirect domain", status: "fail", message: `${host} is not an allowed redirect domain`, hint: "Run `userbox init`, or add it in Console \u2192 Auth settings." });
829
+ add(settings.allow_self_signup ? { name: "sign-up", status: "pass", message: "self-service sign-up is on" } : { name: "sign-up", status: "warn", message: "sign-up is invitation-only", hint: "Fine for dev. Turn on self-service sign-up in the console if new users should register." });
830
+ }
831
+ } catch (err) {
832
+ add({ name: "login", status: "warn", message: err.message, hint: err.hint });
833
+ }
834
+ }
835
+ const failed = checks.filter((c) => c.status === "fail").length;
836
+ out.done({ healthy: failed === 0, checks }, [
837
+ failed === 0 ? "\nAll checks passed." : `
838
+ ${failed} check(s) failed.`
839
+ ]);
840
+ return failed === 0 ? 0 : 1;
841
+ }
842
+
843
+ // src/commands/init.ts
844
+ import { spawnSync } from "child_process";
845
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
846
+ import { hostname } from "os";
847
+ import { join as join7 } from "path";
848
+
849
+ // src/scaffold.ts
850
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
851
+ import { dirname, join as join6 } from "path";
852
+ function providers(ts) {
853
+ const props = ts ? "{ children }: { children: React.ReactNode }" : "{ children }";
854
+ const bang = ts ? "!" : "";
855
+ return `"use client";
856
+ import { UserboxProvider } from "@userbox/react";
857
+
858
+ export function Providers(${props}) {
859
+ return (
860
+ <UserboxProvider
861
+ tenantSlug={process.env.NEXT_PUBLIC_USERBOX_TENANT_SLUG${bang}}
862
+ baseUrl={process.env.NEXT_PUBLIC_USERBOX_API_URL${bang}}
863
+ tokenStorage="cookie"
864
+ refreshUrl="/api/auth/refresh"
865
+ >
866
+ {children}
867
+ </UserboxProvider>
868
+ );
869
+ }
870
+ `;
871
+ }
872
+ var CALLBACK = `"use client";
873
+ import { useRouter } from "next/navigation";
874
+ import { UserboxCallback } from "@userbox/react";
875
+
876
+ export default function Callback() {
877
+ const router = useRouter();
878
+ return <UserboxCallback onSuccess={() => router.replace("/")} onError={(e) => console.error(e.message)} />;
879
+ }
880
+ `;
881
+ var REFRESH = `import { createRefreshHandler } from "@userbox/react/nextjs";
882
+
883
+ export const { POST } = createRefreshHandler();
884
+ `;
885
+ function writeIfMissing(root, rel, content, result) {
886
+ const path = join6(root, rel);
887
+ if (existsSync5(path)) {
888
+ result.skipped.push(rel);
889
+ return;
890
+ }
891
+ mkdirSync2(dirname(path), { recursive: true });
892
+ writeFileSync2(path, content);
893
+ result.written.push(rel);
894
+ }
895
+ function wrapLayout(text, importPath) {
896
+ if (/<Providers[\s>]/.test(text)) return null;
897
+ const occurrences = text.split("{children}").length - 1;
898
+ if (occurrences !== 1) return null;
899
+ const wrapped = text.replace("{children}", "<Providers>{children}</Providers>");
900
+ const importLine = `import { Providers } from "${importPath}";
901
+ `;
902
+ const lastImport = [...wrapped.matchAll(/^import .*$/gm)].pop();
903
+ if (!lastImport || lastImport.index === void 0) return importLine + wrapped;
904
+ const at = lastImport.index + lastImport[0].length;
905
+ return `${wrapped.slice(0, at)}
906
+ ${importLine.trimEnd()}${wrapped.slice(at)}`;
907
+ }
908
+ function scaffold(project) {
909
+ const result = { written: [], skipped: [], todo: [] };
910
+ const { root, appDir, typescript: ts } = project;
911
+ const x = ts ? "tsx" : "jsx";
912
+ const s = ts ? "ts" : "js";
913
+ writeIfMissing(root, `${appDir}/providers.${x}`, providers(ts), result);
914
+ writeIfMissing(root, `${appDir}/auth/callback/page.${x}`, CALLBACK, result);
915
+ writeIfMissing(root, `${appDir}/api/auth/refresh/route.${s}`, REFRESH, result);
916
+ const layoutRel = [`${appDir}/layout.tsx`, `${appDir}/layout.jsx`, `${appDir}/layout.js`].find(
917
+ (p) => existsSync5(join6(root, p))
918
+ );
919
+ if (!layoutRel) {
920
+ result.todo.push(`Wrap {children} in your root layout with <Providers> from ./providers.`);
921
+ return result;
922
+ }
923
+ const layoutPath = join6(root, layoutRel);
924
+ const layout = readFileSync5(layoutPath, "utf8");
925
+ if (/<Providers[\s>]/.test(layout)) {
926
+ result.skipped.push(layoutRel);
927
+ return result;
928
+ }
929
+ const next = wrapLayout(layout, "./providers");
930
+ if (next === null) {
931
+ result.todo.push(`Wrap {children} in ${layoutRel} with <Providers> (import { Providers } from "./providers").`);
932
+ } else {
933
+ writeFileSync2(layoutPath, next);
934
+ result.written.push(`${layoutRel} (wrapped {children} with <Providers>)`);
935
+ }
936
+ return result;
937
+ }
938
+
939
+ // src/commands/init.ts
940
+ var KEY_SCOPES = ["access:check", "memberships:read", "workspaces:read"];
941
+ var SDKS = ["@userbox/react@alpha", "@userbox/node@alpha"];
942
+ function ensureGitignored(root) {
943
+ const path = join7(root, ".gitignore");
944
+ const text = existsSync6(path) ? readFileSync6(path, "utf8") : "";
945
+ const covered = text.split(/\r?\n/).map((l) => l.trim()).some((l) => [".env*", ".env.local", ".env*.local", "*.local"].includes(l));
946
+ if (covered) return false;
947
+ writeFileSync3(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}.env.local
948
+ `);
949
+ return true;
950
+ }
951
+ async function init(flags, out) {
952
+ const root = process.cwd();
953
+ const project = detectNextProject(root);
954
+ const session = Session.load();
955
+ const origin = flags.origin ?? "http://localhost:3000";
956
+ let host;
957
+ try {
958
+ host = new URL(origin).hostname;
959
+ } catch {
960
+ throw new CliError("INVALID_ORIGIN", `--origin must be a URL such as http://localhost:3000, got "${origin}"`);
961
+ }
962
+ const env = await defaultEnvironment(session);
963
+ out.step(`Tenant ${session.tenantSlug}, environment ${env.name} (${env.type})`);
964
+ if (env.type === "production") out.warn("The default environment is production; the key created here is a production key.");
965
+ if (await ensureRedirectDomain(session, env, host)) out.step(`Added ${host} to the allowed redirect domains`);
966
+ else out.step(`${host} is already an allowed redirect domain`);
967
+ const envPath = join7(root, ".env.local");
968
+ const existing = existsSync6(envPath) ? readFileSync6(envPath, "utf8") : "";
969
+ const current = parseEnv(existing);
970
+ const values = {
971
+ NEXT_PUBLIC_USERBOX_TENANT_SLUG: session.tenantSlug,
972
+ NEXT_PUBLIC_USERBOX_API_URL: session.apiUrl,
973
+ USERBOX_TENANT_ID: session.tenantId,
974
+ APP_ORIGIN: origin
975
+ };
976
+ let keyCreated = false;
977
+ if (flags.force || !current.get("USERBOX_API_KEY")) {
978
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
979
+ values.USERBOX_API_KEY = await createApiKey(session, env, `cli ${hostname()} ${date}`, KEY_SCOPES);
980
+ keyCreated = true;
981
+ out.step(`Created an API key with scopes ${KEY_SCOPES.join(", ")}`);
982
+ }
983
+ const merged = mergeEnv(existing, values, flags.force ? new Set(ENV_KEYS) : /* @__PURE__ */ new Set());
984
+ writeFileSync3(envPath, merged.text, { mode: 384 });
985
+ out.step(`.env.local: ${merged.added.length} added, ${merged.replaced.length} replaced, ${merged.kept.length} kept`);
986
+ const ignored = ensureGitignored(root);
987
+ if (ignored) out.step("Added .env.local to .gitignore");
988
+ for (const key of merged.kept) {
989
+ if (current.get(key) !== values[key] && key !== "USERBOX_API_KEY") {
990
+ out.warn(`.env.local keeps a different ${key}; run with --force to replace it.`);
991
+ }
992
+ }
993
+ let installed = false;
994
+ if (!flags.skipInstall) {
995
+ const [cmd, args] = installCommand(project.packageManager, SDKS);
996
+ out.step(`Installing the SDKs: ${cmd} ${args.join(" ")}`);
997
+ const res = spawnSync(cmd, args, {
998
+ cwd: root,
999
+ stdio: flags.json ? ["ignore", "ignore", "inherit"] : "inherit",
1000
+ shell: process.platform === "win32"
1001
+ });
1002
+ if (res.status !== 0) {
1003
+ throw new CliError("INSTALL_FAILED", `${cmd} exited with ${res.status ?? res.signal}`, `Run \`${cmd} ${args.join(" ")}\` yourself.`);
1004
+ }
1005
+ installed = true;
1006
+ }
1007
+ const files = scaffold(project);
1008
+ for (const f of files.written) out.step(`Wrote ${f}`);
1009
+ for (const t of files.todo) out.warn(t);
1010
+ out.done(
1011
+ {
1012
+ tenant: { id: session.tenantId, slug: session.tenantSlug },
1013
+ environment: { id: env.id, name: env.name, type: env.type },
1014
+ redirectDomain: host,
1015
+ envFile: { path: ".env.local", added: merged.added, replaced: merged.replaced, kept: merged.kept },
1016
+ apiKeyCreated: keyCreated,
1017
+ sdksInstalled: installed,
1018
+ files,
1019
+ next: ["userbox apply", "userbox dev seed", "userbox doctor"]
1020
+ },
1021
+ [
1022
+ "",
1023
+ "Userbox is set up. Next:",
1024
+ " 1. Declare permissions and roles in userbox.yaml, then run `userbox apply`.",
1025
+ " 2. Run `userbox dev seed` for a workspace where you are admin.",
1026
+ " 3. Run `userbox doctor`, then start the app and sign in.",
1027
+ "Use verifyRequest() and can() from @userbox/node in your API routes (see https://docs.test.userbox.app/llms.txt)."
1028
+ ]
1029
+ );
1030
+ }
1031
+
1032
+ // src/commands/login.ts
1033
+ async function login(flags, out) {
1034
+ requireTty("login");
1035
+ const previous = loadCredentials();
1036
+ const apiUrl = (flags.apiUrl ?? previous?.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
1037
+ const tenantSlug = await ask("Tenant slug", { fallback: previous?.tenantSlug ?? "" });
1038
+ const email = await ask("Email", { fallback: previous?.email ?? "" });
1039
+ const password = await ask("Password", { secret: true });
1040
+ const tokens = await rawRequest(apiUrl, "/v1/auth/login", {
1041
+ method: "POST",
1042
+ body: { email, password, tenant_slug: tenantSlug }
1043
+ });
1044
+ const tenant = await rawRequest(apiUrl, "/v1/tenants/me", {
1045
+ headers: { Authorization: `Bearer ${tokens.access_token}` }
1046
+ });
1047
+ saveCredentials({
1048
+ apiUrl,
1049
+ tenantSlug: tenant.slug,
1050
+ tenantId: tenant.id,
1051
+ email,
1052
+ accessToken: tokens.access_token,
1053
+ refreshToken: tokens.refresh_token,
1054
+ accessExpiresAt: expiryFrom(tokens)
1055
+ });
1056
+ out.done({ tenant: { id: tenant.id, slug: tenant.slug }, email, apiUrl }, [
1057
+ `Logged in to ${tenant.name} (${tenant.slug}) as ${email}.`,
1058
+ "The login is stored in ~/.config/userbox/credentials.json and lasts 7 days of inactivity."
1059
+ ]);
1060
+ }
1061
+ function logout(out) {
1062
+ clearCredentials();
1063
+ out.done({}, ["Logged out."]);
1064
+ }
1065
+ async function whoami(out) {
1066
+ const session = Session.load();
1067
+ const tenant = await session.request("/v1/tenants/me");
1068
+ out.done(
1069
+ { tenant: { id: tenant.id, slug: tenant.slug }, email: session.email, apiUrl: session.apiUrl },
1070
+ [`${session.email} on ${tenant.name} (${tenant.slug}), ${session.apiUrl}`]
1071
+ );
1072
+ }
1073
+
1074
+ // src/bin.ts
1075
+ var VERSION = "0.1.0-alpha.1";
1076
+ var HELP = `userbox \u2014 set up Userbox in your app
1077
+
1078
+ Usage: userbox <command> [options]
1079
+
1080
+ Commands
1081
+ login Log in as a tenant admin (interactive, human only)
1082
+ logout Forget the local login
1083
+ whoami Show the current login
1084
+ init Set up this Next.js app: env, API key, SDKs, auth files
1085
+ apply Sync userbox.yaml (permissions and roles) to the environment
1086
+ dev seed Create a dev workspace with an admin and a member user
1087
+ doctor Check this app's Userbox setup
1088
+
1089
+ Options
1090
+ --json Machine-readable output (for coding agents)
1091
+ --yes, -y Apply without asking (apply)
1092
+ --force init: replace existing USERBOX_* values in .env.local
1093
+ --origin <url> init: the app origin (default http://localhost:3000)
1094
+ --skip-install init: do not install the SDKs
1095
+ --api-url <url> login: the Userbox API (default https://api.test.userbox.app)
1096
+ --invite <email[:role]> dev seed: invite a user (repeatable, role defaults to member)
1097
+ --help, -h Show this help
1098
+ --version, -v Show the version
1099
+
1100
+ Docs: https://docs.test.userbox.app/llms.txt`;
1101
+ async function main(argv) {
1102
+ const { values, positionals } = parseArgs({
1103
+ args: argv,
1104
+ allowPositionals: true,
1105
+ strict: true,
1106
+ options: {
1107
+ json: { type: "boolean", default: false },
1108
+ yes: { type: "boolean", short: "y", default: false },
1109
+ force: { type: "boolean", default: false },
1110
+ origin: { type: "string" },
1111
+ "skip-install": { type: "boolean", default: false },
1112
+ "api-url": { type: "string" },
1113
+ invite: { type: "string", multiple: true, default: [] },
1114
+ help: { type: "boolean", short: "h", default: false },
1115
+ version: { type: "boolean", short: "v", default: false }
1116
+ }
1117
+ });
1118
+ const flags = {
1119
+ json: values.json,
1120
+ yes: values.yes,
1121
+ force: values.force,
1122
+ origin: values.origin,
1123
+ skipInstall: values["skip-install"],
1124
+ apiUrl: values["api-url"],
1125
+ invite: values.invite
1126
+ };
1127
+ if (values.version) {
1128
+ process.stdout.write(`${VERSION}
1129
+ `);
1130
+ return 0;
1131
+ }
1132
+ const command = positionals.join(" ");
1133
+ if (values.help || command === "") {
1134
+ process.stdout.write(`${HELP}
1135
+ `);
1136
+ return 0;
1137
+ }
1138
+ const out = createOutput(flags.json);
1139
+ try {
1140
+ switch (command) {
1141
+ case "login":
1142
+ await login(flags, out);
1143
+ break;
1144
+ case "logout":
1145
+ logout(out);
1146
+ break;
1147
+ case "whoami":
1148
+ await whoami(out);
1149
+ break;
1150
+ case "init":
1151
+ await init(flags, out);
1152
+ break;
1153
+ case "apply":
1154
+ await apply(flags, out);
1155
+ break;
1156
+ case "dev seed":
1157
+ await devSeed(flags, out);
1158
+ break;
1159
+ case "doctor":
1160
+ return await doctor(flags, out);
1161
+ default:
1162
+ throw new CliError("UNKNOWN_COMMAND", `unknown command "${command}"`, "Run `userbox --help`.");
1163
+ }
1164
+ return 0;
1165
+ } catch (err) {
1166
+ printError(err, flags.json);
1167
+ return 1;
1168
+ }
1169
+ }
1170
+ main(process.argv.slice(2)).then(
1171
+ (code) => process.exit(code),
1172
+ (err) => {
1173
+ printError(err, process.argv.includes("--json"));
1174
+ process.exit(1);
1175
+ }
1176
+ );
1177
+ //# sourceMappingURL=bin.js.map