libretto 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +112 -137
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +151 -206
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/browserbase.js +1 -0
  23. package/dist/cli/core/providers/kernel.js +1 -0
  24. package/dist/cli/core/providers/libretto-cloud.js +6 -7
  25. package/dist/cli/core/readonly-exec.js +1 -1
  26. package/dist/cli/router.js +4 -0
  27. package/dist/cli/workers/run-integration-runtime.js +0 -5
  28. package/dist/shared/state/session-state.d.ts +1 -0
  29. package/dist/shared/state/session-state.js +2 -1
  30. package/docs/browser-automation-approaches.md +435 -0
  31. package/docs/releasing.md +117 -0
  32. package/package.json +4 -3
  33. package/skills/libretto/SKILL.md +14 -1
  34. package/skills/libretto-readonly/SKILL.md +1 -1
  35. package/src/cli/cli.ts +2 -0
  36. package/src/cli/commands/auth.ts +787 -0
  37. package/src/cli/commands/billing.ts +133 -0
  38. package/src/cli/commands/browser.ts +8 -2
  39. package/src/cli/commands/deploy.ts +2 -7
  40. package/src/cli/commands/execution.ts +139 -187
  41. package/src/cli/commands/snapshot.ts +46 -143
  42. package/src/cli/core/ai-model.ts +4 -5
  43. package/src/cli/core/auth-fetch.ts +283 -0
  44. package/src/cli/core/auth-storage.ts +102 -0
  45. package/src/cli/core/browser.ts +182 -245
  46. package/src/cli/core/daemon/config.ts +46 -0
  47. package/src/cli/core/daemon/daemon.ts +429 -0
  48. package/src/cli/core/daemon/exec.ts +128 -0
  49. package/src/cli/core/daemon/index.ts +24 -0
  50. package/src/cli/core/daemon/ipc.ts +294 -0
  51. package/src/cli/core/daemon/pages.ts +21 -0
  52. package/src/cli/core/daemon/snapshot.ts +114 -0
  53. package/src/cli/core/daemon/spawn.ts +171 -0
  54. package/src/cli/core/exec-compiler.ts +169 -0
  55. package/src/cli/core/prompt.ts +94 -0
  56. package/src/cli/core/providers/browserbase.ts +1 -0
  57. package/src/cli/core/providers/kernel.ts +1 -0
  58. package/src/cli/core/providers/libretto-cloud.ts +13 -7
  59. package/src/cli/core/providers/types.ts +12 -1
  60. package/src/cli/core/readonly-exec.ts +2 -1
  61. package/src/cli/router.ts +4 -0
  62. package/src/cli/workers/run-integration-runtime.ts +0 -6
  63. package/src/shared/state/session-state.ts +1 -0
  64. package/dist/cli/core/browser-daemon.js +0 -122
  65. package/src/cli/core/browser-daemon.ts +0 -198
@@ -0,0 +1,787 @@
1
+ /**
2
+ * Experimental auth commands for the libretto hosted platform.
3
+ *
4
+ * libretto experimental auth signup
5
+ * libretto experimental auth login
6
+ * libretto experimental auth logout
7
+ * libretto experimental auth invite <email> [--role member|admin|owner]
8
+ * libretto experimental auth accept-invite <tenantSlug> <invitationId>
9
+ * libretto experimental auth api-key issue [--label <label>]
10
+ * libretto experimental auth api-key list
11
+ * libretto experimental auth api-key revoke <id>
12
+ * libretto experimental auth whoami
13
+ *
14
+ * Credentials live at ~/.libretto/auth.json (mode 0600). The CLI sends either
15
+ * the stored API key or the stored session cookie depending on what's
16
+ * available, with LIBRETTO_API_KEY winning when set.
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import { SimpleCLI } from "../framework/simple-cli.js";
21
+ import {
22
+ ApiCallError,
23
+ betterAuthCall,
24
+ HOSTED_API_URL,
25
+ NOT_AUTHENTICATED_MESSAGE,
26
+ orpcCall,
27
+ pickCredential,
28
+ resolveApiUrl,
29
+ } from "../core/auth-fetch.js";
30
+ import {
31
+ authStatePath,
32
+ clearAuthState,
33
+ readAuthState,
34
+ setCookieToCookieHeader,
35
+ writeAuthState,
36
+ type AuthState,
37
+ } from "../core/auth-storage.js";
38
+ import { prompt, promptPassword, slugify } from "../core/prompt.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Shared helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function isSlugTakenData(data: unknown): boolean {
45
+ return (
46
+ !!data &&
47
+ typeof data === "object" &&
48
+ (data as { reason?: unknown }).reason === "slug_taken"
49
+ );
50
+ }
51
+
52
+ type SignupResponse = {
53
+ userId: string;
54
+ email: string;
55
+ organizationId: string;
56
+ organizationSlug: string | null;
57
+ sessionToken: string | null;
58
+ setCookie: string[];
59
+ emailVerified: boolean;
60
+ };
61
+
62
+ type Session = {
63
+ user: { id: string; email: string; emailVerified: boolean; name?: string };
64
+ session: { id: string; expiresAt: string };
65
+ };
66
+
67
+ type ApiKeyCreateResponse = {
68
+ id: string;
69
+ name: string | null;
70
+ prefix?: string | null;
71
+ /** The raw key, returned exactly once. */
72
+ key: string;
73
+ };
74
+
75
+ type ApiKeyListItem = {
76
+ id: string;
77
+ name: string | null;
78
+ prefix?: string | null;
79
+ start?: string | null;
80
+ enabled: boolean | null;
81
+ createdAt: string;
82
+ lastRequest?: string | null;
83
+ };
84
+
85
+ async function getCurrentSession(
86
+ apiUrl: string,
87
+ /**
88
+ * Optional credential override. Pass this when the caller has just
89
+ * performed a sign-in / sign-up and the new cookie hasn't been written
90
+ * to ~/.libretto/auth.json yet — without this, betterAuthCall would
91
+ * fall back to the stale cookie from the file (or none at all).
92
+ */
93
+ cookie?: string,
94
+ ): Promise<Session | null> {
95
+ try {
96
+ const { data } = await betterAuthCall<Session | null>({
97
+ apiUrl,
98
+ path: "/api/auth/get-session",
99
+ method: "GET",
100
+ credential: cookie ? { source: "cookie", cookie } : undefined,
101
+ });
102
+ return data && typeof data === "object" && "user" in data ? data : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function pollForVerification(
109
+ apiUrl: string,
110
+ pollIntervalMs = 4000,
111
+ maxWaitMs = 10 * 60 * 1000,
112
+ ): Promise<boolean> {
113
+ // The signup flow writes the cookie to disk before this poll starts, so
114
+ // the default credential pick in `betterAuthCall` (env key > stored
115
+ // cookie) reads the right one.
116
+ const start = Date.now();
117
+ while (Date.now() - start < maxWaitMs) {
118
+ const session = await getCurrentSession(apiUrl);
119
+ if (session?.user.emailVerified) return true;
120
+ process.stdout.write(".");
121
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
122
+ }
123
+ console.log();
124
+ return false;
125
+ }
126
+
127
+ async function persistSignupSession(
128
+ apiUrl: string,
129
+ result: SignupResponse,
130
+ ): Promise<AuthState> {
131
+ const cookie = setCookieToCookieHeader(result.setCookie);
132
+ if (!cookie) {
133
+ throw new Error("Sign-up did not return a session cookie. Check the server.");
134
+ }
135
+ const next: AuthState = {
136
+ apiUrl,
137
+ session: {
138
+ cookie,
139
+ userId: result.userId,
140
+ email: result.email,
141
+ expiresAt: null,
142
+ },
143
+ };
144
+ await writeAuthState(next);
145
+ return next;
146
+ }
147
+
148
+ /**
149
+ * Look up the user's organization id via /organization/list. Used by the
150
+ * invite command to populate the `organizationId` field in the request
151
+ * body — Better Auth's invite-member endpoint requires it (or an active
152
+ * org on the session, which API-key sessions don't have).
153
+ *
154
+ * The server still permission-checks membership, so this is just a UX
155
+ * helper, not a security control.
156
+ */
157
+ async function resolveActiveOrgId(
158
+ apiUrl: string,
159
+ credential: ReturnType<typeof pickCredential>,
160
+ ): Promise<string> {
161
+ const { data: orgs } = await betterAuthCall<Array<{ id: string }>>({
162
+ apiUrl,
163
+ path: "/api/auth/organization/list",
164
+ method: "GET",
165
+ credential,
166
+ });
167
+ if (!Array.isArray(orgs) || orgs.length === 0) {
168
+ throw new Error(
169
+ "No organization on this account — sign up or accept an invite first.",
170
+ );
171
+ }
172
+ return orgs[0]!.id;
173
+ }
174
+
175
+ async function issueApiKey(
176
+ apiUrl: string,
177
+ name: string,
178
+ credential: ReturnType<typeof pickCredential>,
179
+ ): Promise<ApiKeyCreateResponse> {
180
+ // We do NOT pass metadata.tenantId — the api-key/create hook in
181
+ // api/src/auth.ts sets it server-side from auth.users.tenantId. Anything
182
+ // we'd send would be overridden anyway, so don't send it.
183
+ const { data } = await betterAuthCall<ApiKeyCreateResponse>({
184
+ apiUrl,
185
+ path: "/api/auth/api-key/create",
186
+ input: { name },
187
+ credential,
188
+ });
189
+ if (!data?.key) {
190
+ throw new Error("API-key creation returned no raw key.");
191
+ }
192
+ return data;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // signup
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export const signupCommand = SimpleCLI.command({
200
+ description: "Create a new hosted-platform account and organization",
201
+ experimental: true,
202
+ })
203
+ .input(SimpleCLI.input({ positionals: [], named: {} }))
204
+ .handle(async () => {
205
+ const apiUrl = HOSTED_API_URL;
206
+ console.log("Sign up for libretto cloud");
207
+ console.log();
208
+ console.log("Heads up: a libretto user can only belong to one organization.");
209
+ console.log(
210
+ "If your team already has a libretto org, ask a teammate for an invite instead — switching orgs later isn't supported.",
211
+ );
212
+ console.log("Type 'q' at the name prompt to quit if that applies to you.");
213
+ console.log();
214
+
215
+ const name = await prompt("Your name:");
216
+ if (name.toLowerCase() === "q" || name.length === 0) {
217
+ console.log(
218
+ "OK — ask an existing teammate to run `libretto experimental auth invite <your-email>` and then run `libretto experimental auth accept-invite <slug> <invitation-id>` from this machine.",
219
+ );
220
+ return;
221
+ }
222
+
223
+ const email = await prompt("Your email:");
224
+ const password = await promptPassword("Choose a password (8+ chars):");
225
+
226
+ const orgName = await prompt("Organization name:");
227
+ const defaultSlug = slugify(orgName);
228
+ let orgSlug = (await prompt("Organization slug:", { defaultValue: defaultSlug })).toLowerCase();
229
+ const debugNotificationEmail = await prompt(
230
+ "Alert email (for hosted workflow failures):",
231
+ { defaultValue: email },
232
+ );
233
+
234
+ console.log();
235
+ console.log("Creating account...");
236
+
237
+ // Retry loop: if the server reports slug-taken (data.reason === "slug_taken"),
238
+ // re-prompt for just the slug and try again — keeping the entered name,
239
+ // email, and password. Other errors propagate.
240
+ //
241
+ // The server's slug pre-check (added to /v1/auth/signupAndCreateOrg)
242
+ // catches the conflict before any user is created, so retrying doesn't
243
+ // leave dangling user rows behind. The transaction-level unique-violation
244
+ // catch carries the same `data.reason` so a race-loser is also handled.
245
+ let result: SignupResponse;
246
+ while (true) {
247
+ try {
248
+ result = await orpcCall<SignupResponse>({
249
+ apiUrl,
250
+ path: "/v1/auth/signupAndCreateOrg",
251
+ input: {
252
+ name,
253
+ email,
254
+ password,
255
+ organizationName: orgName,
256
+ organizationSlug: orgSlug,
257
+ debugNotificationEmail,
258
+ },
259
+ unauthenticated: true,
260
+ });
261
+ break;
262
+ } catch (e) {
263
+ if (
264
+ e instanceof ApiCallError &&
265
+ e.code === "CONFLICT" &&
266
+ isSlugTakenData(e.data)
267
+ ) {
268
+ console.log();
269
+ console.log(e.message);
270
+ orgSlug = (await prompt("Organization slug:")).toLowerCase();
271
+ continue;
272
+ }
273
+ throw e;
274
+ }
275
+ }
276
+
277
+ await persistSignupSession(apiUrl, result);
278
+
279
+ console.log(`Account created. Verification email sent to ${result.email}.`);
280
+ console.log("Click the link in the email to verify, then return here.");
281
+ console.log("Waiting for verification");
282
+
283
+ const verified = await pollForVerification(apiUrl);
284
+ if (!verified) {
285
+ console.log();
286
+ console.log(
287
+ "Timed out waiting for email verification. Click the link in the email when you're ready — your CLI session is already saved, no need to re-run signup.",
288
+ );
289
+ return;
290
+ }
291
+
292
+ console.log();
293
+ console.log("Email verified. You're logged in.");
294
+ console.log(`Session saved to ${authStatePath()}`);
295
+ console.log();
296
+ console.log("To generate an API key, run:");
297
+ console.log(" libretto experimental auth api-key issue --label <label>");
298
+ console.log("Then add LIBRETTO_API_KEY=<key> to your project's .env file.");
299
+ });
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // login
303
+ // ---------------------------------------------------------------------------
304
+
305
+ export const loginCommand = SimpleCLI.command({
306
+ description: "Sign in to an existing hosted-platform account",
307
+ experimental: true,
308
+ })
309
+ .input(SimpleCLI.input({ positionals: [], named: {} }))
310
+ .handle(async () => {
311
+ const apiUrl = HOSTED_API_URL;
312
+
313
+ const email = await prompt("Email:");
314
+ const password = await promptPassword("Password:");
315
+
316
+ const { data, setCookie } = await betterAuthCall<{
317
+ token: string;
318
+ user: { id: string; email: string; emailVerified: boolean };
319
+ }>({
320
+ apiUrl,
321
+ path: "/api/auth/sign-in/email",
322
+ input: { email, password },
323
+ unauthenticated: true,
324
+ });
325
+
326
+ const cookie = setCookieToCookieHeader(setCookie);
327
+ if (!cookie) {
328
+ throw new Error("Login response did not include a session cookie.");
329
+ }
330
+
331
+ // Pass the just-issued cookie explicitly — at this point we haven't
332
+ // persisted it yet, so a default credential pick would read the stale
333
+ // (or missing) cookie from disk.
334
+ const session = await getCurrentSession(apiUrl, cookie);
335
+
336
+ const next: AuthState = {
337
+ apiUrl,
338
+ session: {
339
+ cookie,
340
+ userId: data.user.id,
341
+ email: data.user.email,
342
+ expiresAt: session?.session.expiresAt ?? null,
343
+ },
344
+ };
345
+ await writeAuthState(next);
346
+
347
+ console.log(`Logged in as ${data.user.email}.`);
348
+ if (!data.user.emailVerified) {
349
+ console.log(
350
+ "Heads up: your email isn't verified yet. Re-sending the verification link to your inbox — click it to finish setup.",
351
+ );
352
+ try {
353
+ await betterAuthCall({
354
+ apiUrl,
355
+ path: "/api/auth/send-verification-email",
356
+ input: {
357
+ email: data.user.email,
358
+ callbackURL: `${apiUrl}/auth/verified`,
359
+ },
360
+ unauthenticated: true,
361
+ });
362
+ console.log(`Verification email sent to ${data.user.email}.`);
363
+ } catch (error) {
364
+ const message = error instanceof Error ? error.message : "unknown error";
365
+ console.log(
366
+ `Couldn't resend the verification email (${message}). Try again, or hit /api/auth/send-verification-email directly.`,
367
+ );
368
+ }
369
+ }
370
+ });
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // logout
374
+ // ---------------------------------------------------------------------------
375
+
376
+ export const logoutCommand = SimpleCLI.command({
377
+ description: "Clear local libretto credentials",
378
+ experimental: true,
379
+ })
380
+ .handle(async () => {
381
+ const state = await readAuthState();
382
+ if (state?.session?.cookie) {
383
+ try {
384
+ await betterAuthCall({
385
+ apiUrl: state.apiUrl,
386
+ path: "/api/auth/sign-out",
387
+ credential: { source: "cookie", cookie: state.session.cookie },
388
+ });
389
+ } catch {
390
+ // best-effort; clearing local state is the important part.
391
+ }
392
+ }
393
+ await clearAuthState();
394
+ console.log("Logged out.");
395
+ });
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // invite
399
+ // ---------------------------------------------------------------------------
400
+
401
+ export const inviteCommand = SimpleCLI.command({
402
+ description: "Invite a teammate to your active organization",
403
+ experimental: true,
404
+ })
405
+ .input(
406
+ SimpleCLI.input({
407
+ positionals: [
408
+ SimpleCLI.positional("email", z.string().email(), {
409
+ help: "Email address of the person to invite.",
410
+ }),
411
+ ],
412
+ named: {
413
+ role: SimpleCLI.option(
414
+ z
415
+ .enum(["member", "admin", "owner"])
416
+ .default("member"),
417
+ { help: "Role to assign (default: member)." },
418
+ ),
419
+ },
420
+ }),
421
+ )
422
+ .handle(async ({ input }) => {
423
+ const state = await readAuthState();
424
+ const apiUrl = resolveApiUrl(state);
425
+ const credential = pickCredential(state);
426
+ if (credential.source === "none") {
427
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
428
+ }
429
+
430
+ // Always resolve the org id explicitly. Better Auth's invite-member
431
+ // 404s with "Organization not found" if neither `organizationId` is
432
+ // passed nor an active-org is set on the session — and API-key
433
+ // sessions don't carry an active-org by default.
434
+ const organizationId = await resolveActiveOrgId(apiUrl, credential);
435
+
436
+ const body: Record<string, unknown> = {
437
+ email: input.email,
438
+ role: input.role,
439
+ organizationId,
440
+ };
441
+
442
+ const { data } = await betterAuthCall<{
443
+ id: string;
444
+ email: string;
445
+ role: string;
446
+ organizationId: string;
447
+ expiresAt: string;
448
+ }>({
449
+ apiUrl,
450
+ path: "/api/auth/organization/invite-member",
451
+ input: body,
452
+ credential,
453
+ });
454
+
455
+ // Fetch the inviter's org so we can print the slug. The accept
456
+ // command requires the recipient to type the slug as confirmation
457
+ // (slug is uniquely indexed; name is not), so showing it here helps
458
+ // the inviter share the right command.
459
+ const { data: orgs } = await betterAuthCall<
460
+ Array<{ id: string; name: string; slug: string | null }>
461
+ >({
462
+ apiUrl,
463
+ path: "/api/auth/organization/list",
464
+ method: "GET",
465
+ credential,
466
+ });
467
+ const org = orgs?.find((o) => o.id === data.organizationId);
468
+ const orgName = org?.name ?? "<your-org-name>";
469
+ const orgSlug = org?.slug ?? "<your-org-slug>";
470
+
471
+ console.log(`Invitation sent to ${data.email}.`);
472
+ console.log(`Invitation id: ${data.id}`);
473
+ console.log(`Organization: ${orgName} (${orgSlug})`);
474
+ console.log(`Expires at: ${data.expiresAt}`);
475
+ console.log();
476
+ console.log("Tell them to run:");
477
+ console.log(
478
+ ` libretto experimental auth accept-invite ${orgSlug} ${data.id}`,
479
+ );
480
+ });
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // accept-invite
484
+ // ---------------------------------------------------------------------------
485
+
486
+ export const acceptInviteCommand = SimpleCLI.command({
487
+ description: "Accept an organization invitation",
488
+ experimental: true,
489
+ })
490
+ .input(
491
+ SimpleCLI.input({
492
+ positionals: [
493
+ SimpleCLI.positional(
494
+ "tenantSlug",
495
+ z
496
+ .string()
497
+ .min(2)
498
+ .max(60)
499
+ .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, {
500
+ message:
501
+ "Slug must be lowercase letters, numbers, and hyphens (no leading/trailing hyphen).",
502
+ }),
503
+ {
504
+ help:
505
+ "Slug of the organization you're joining. Must match the org slug in the invitation email — acts as a confirmation step.",
506
+ },
507
+ ),
508
+ SimpleCLI.positional("invitationId", z.string().min(1), {
509
+ help: "Invitation id from the invite email.",
510
+ }),
511
+ ],
512
+ named: {},
513
+ }),
514
+ )
515
+ .handle(async ({ input }) => {
516
+ const stored = await readAuthState();
517
+ const apiUrl = HOSTED_API_URL;
518
+ const credential = pickCredential(stored);
519
+ const expectedTenantSlug = input.tenantSlug;
520
+
521
+ if (credential.source !== "none") {
522
+ // Path A — already signed in. Better Auth will try to insert a row
523
+ // into `members` for the new org, but `members.userId` is UNIQUE
524
+ // (one libretto user = one organization). Pre-check the user's
525
+ // existing memberships and refuse with a clear message rather than
526
+ // letting it 500 with a Postgres constraint error.
527
+ const { data: existingOrgs } = await betterAuthCall<Array<{ id: string }>>({
528
+ apiUrl,
529
+ path: "/api/auth/organization/list",
530
+ method: "GET",
531
+ credential,
532
+ });
533
+ if (Array.isArray(existingOrgs) && existingOrgs.length > 0) {
534
+ throw new Error(
535
+ [
536
+ "You're already a member of an organization.",
537
+ "A libretto user can only belong to one organization at a time.",
538
+ "To accept this invite: log out, delete the existing account, and re-run `auth accept-invite` with a new account (or a fresh email).",
539
+ ].join("\n"),
540
+ );
541
+ }
542
+
543
+ // Confirmation step: fetch the invitation and require the user to
544
+ // have typed the matching organization slug. Same lightweight
545
+ // second-factor check that the public ORPC route enforces for
546
+ // Path B. Slug is the right field here because `tenants.slug` is
547
+ // uniquely indexed; `tenants.name` is not, so a name-based check
548
+ // could be bypassed by a colliding lowercase name.
549
+ const { data: invitation } = await betterAuthCall<{
550
+ organizationName: string;
551
+ organizationSlug: string | null;
552
+ organizationId: string;
553
+ }>({
554
+ apiUrl,
555
+ path: `/api/auth/organization/get-invitation?id=${encodeURIComponent(input.invitationId)}`,
556
+ method: "GET",
557
+ credential,
558
+ });
559
+ if (
560
+ !invitation?.organizationSlug ||
561
+ invitation.organizationSlug !== expectedTenantSlug
562
+ ) {
563
+ throw new Error(
564
+ "Organization slug doesn't match this invitation. Double-check the slug shown in the invitation email.",
565
+ );
566
+ }
567
+
568
+ await betterAuthCall<{ member: { organizationId: string } }>({
569
+ apiUrl,
570
+ path: "/api/auth/organization/accept-invitation",
571
+ input: { invitationId: input.invitationId },
572
+ credential,
573
+ });
574
+ console.log(`Invitation accepted. You're now a member of ${invitation.organizationName}.`);
575
+ return;
576
+ }
577
+
578
+ // Not signed in: collect a name + password and call the public ORPC route.
579
+ // The server validates tenantSlug against the invitation server-side too.
580
+ console.log("Accepting invite — let's create your account.");
581
+ const name = await prompt("Your name:");
582
+ const password = await promptPassword("Choose a password (8+ chars):");
583
+
584
+ const result = await orpcCall<SignupResponse>({
585
+ apiUrl,
586
+ path: "/v1/auth/acceptInviteAndSignup",
587
+ input: {
588
+ invitationId: input.invitationId,
589
+ tenantSlug: input.tenantSlug,
590
+ name,
591
+ password,
592
+ },
593
+ unauthenticated: true,
594
+ });
595
+
596
+ await persistSignupSession(apiUrl, result);
597
+
598
+ console.log(`Account created. Verification email sent to ${result.email}.`);
599
+ console.log("Click the link in the email and return here.");
600
+ console.log("Waiting for verification");
601
+
602
+ const verified = await pollForVerification(apiUrl);
603
+ if (!verified) {
604
+ console.log();
605
+ console.log(
606
+ "Timed out waiting for email verification. Click the link in the email when ready — your CLI session is already saved.",
607
+ );
608
+ return;
609
+ }
610
+
611
+ console.log();
612
+ console.log("Email verified. You're logged in and a member of the organization.");
613
+ console.log("To generate an API key, run:");
614
+ console.log(" libretto experimental auth api-key issue --label <label>");
615
+ console.log("Then add LIBRETTO_API_KEY=<key> to your project's .env file.");
616
+ });
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // api-key issue / list / revoke
620
+ // ---------------------------------------------------------------------------
621
+
622
+ export const apiKeyIssueCommand = SimpleCLI.command({
623
+ description: "Issue a new API key for the active organization",
624
+ experimental: true,
625
+ })
626
+ .input(
627
+ SimpleCLI.input({
628
+ positionals: [],
629
+ named: {
630
+ label: SimpleCLI.option(z.string(), {
631
+ help:
632
+ "Label to identify this key (e.g. 'laptop-dev', 'github-actions').",
633
+ }),
634
+ },
635
+ }),
636
+ )
637
+ .handle(async ({ input }) => {
638
+ const stored = await readAuthState();
639
+ const apiUrl = resolveApiUrl(stored);
640
+ const credential = pickCredential(stored);
641
+ if (credential.source === "none") {
642
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
643
+ }
644
+
645
+ const key = await issueApiKey(apiUrl, input.label, credential);
646
+
647
+ console.log(`API key issued (id: ${key.id}, label: ${key.name ?? input.label}).`);
648
+ console.log(`Key (shown once — keep it safe):`);
649
+ console.log(` ${key.key}`);
650
+ console.log();
651
+ console.log("Add the following to your project's .env file:");
652
+ console.log(` LIBRETTO_API_KEY=${key.key}`);
653
+ console.log();
654
+ console.log(
655
+ "The key is not stored on disk by the CLI — losing it means revoking + re-issuing.",
656
+ );
657
+ });
658
+
659
+ export const apiKeyListCommand = SimpleCLI.command({
660
+ description: "List API keys for the active organization",
661
+ experimental: true,
662
+ })
663
+ .handle(async () => {
664
+ const stored = await readAuthState();
665
+ const apiUrl = resolveApiUrl(stored);
666
+ const credential = pickCredential(stored);
667
+ if (credential.source === "none") {
668
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
669
+ }
670
+
671
+ const { data } = await betterAuthCall<ApiKeyListItem[]>({
672
+ apiUrl,
673
+ path: "/api/auth/api-key/list",
674
+ method: "GET",
675
+ credential,
676
+ });
677
+
678
+ if (!Array.isArray(data) || data.length === 0) {
679
+ console.log("No API keys.");
680
+ return;
681
+ }
682
+
683
+ for (const key of data) {
684
+ const enabled = key.enabled === false ? " [disabled]" : "";
685
+ const last = key.lastRequest ? ` last-used ${key.lastRequest}` : "";
686
+ console.log(
687
+ `${key.id} ${key.name ?? "(unnamed)"} ${key.start ?? key.prefix ?? ""}… created ${key.createdAt}${enabled}${last}`,
688
+ );
689
+ }
690
+ });
691
+
692
+ export const apiKeyRevokeCommand = SimpleCLI.command({
693
+ description: "Revoke an API key by id",
694
+ experimental: true,
695
+ })
696
+ .input(
697
+ SimpleCLI.input({
698
+ positionals: [
699
+ SimpleCLI.positional("id", z.string().min(1), {
700
+ help: "API key id (from `auth api-key list`).",
701
+ }),
702
+ ],
703
+ named: {},
704
+ }),
705
+ )
706
+ .handle(async ({ input }) => {
707
+ const stored = await readAuthState();
708
+ const apiUrl = resolveApiUrl(stored);
709
+ const credential = pickCredential(stored);
710
+ if (credential.source === "none") {
711
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
712
+ }
713
+
714
+ await betterAuthCall({
715
+ apiUrl,
716
+ path: "/api/auth/api-key/delete",
717
+ input: { keyId: input.id },
718
+ credential,
719
+ });
720
+
721
+ console.log(`API key ${input.id} revoked.`);
722
+ console.log(
723
+ "If this key was in your .env, remove the LIBRETTO_API_KEY value and issue a new one with `auth api-key issue --label <label>`.",
724
+ );
725
+ });
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // whoami
729
+ // ---------------------------------------------------------------------------
730
+
731
+ export const whoamiCommand = SimpleCLI.command({
732
+ description: "Print the active session and credential source",
733
+ experimental: true,
734
+ })
735
+ .handle(async () => {
736
+ const stored = await readAuthState();
737
+ const credential = pickCredential(stored);
738
+
739
+ const envKey = process.env.LIBRETTO_API_KEY?.trim();
740
+
741
+ if (credential.source === "none") {
742
+ console.log(
743
+ "Not authenticated. Run `libretto experimental auth signup`, `login`, or set LIBRETTO_API_KEY in your env.",
744
+ );
745
+ return;
746
+ }
747
+
748
+ console.log(`Auth source: ${credential.source}`);
749
+ console.log(`API URL: ${HOSTED_API_URL}`);
750
+ console.log(
751
+ `LIBRETTO_API_KEY: ${envKey ? `set in env (${envKey.slice(0, 6)}…)` : "not set in env"}`,
752
+ );
753
+ if (stored?.session) {
754
+ console.log(`Session email: ${stored.session.email}`);
755
+ console.log(`Session user id: ${stored.session.userId}`);
756
+ if (stored.session.expiresAt) {
757
+ console.log(`Session expires: ${stored.session.expiresAt}`);
758
+ }
759
+ console.log(`Session file: ${authStatePath()}`);
760
+ } else {
761
+ console.log("Session file: (none on disk)");
762
+ }
763
+ });
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // Group export
767
+ // ---------------------------------------------------------------------------
768
+
769
+ export const authCommands = SimpleCLI.group({
770
+ description: "Hosted-platform auth commands",
771
+ routes: {
772
+ signup: signupCommand,
773
+ login: loginCommand,
774
+ logout: logoutCommand,
775
+ invite: inviteCommand,
776
+ "accept-invite": acceptInviteCommand,
777
+ whoami: whoamiCommand,
778
+ "api-key": SimpleCLI.group({
779
+ description: "Manage API keys",
780
+ routes: {
781
+ issue: apiKeyIssueCommand,
782
+ list: apiKeyListCommand,
783
+ revoke: apiKeyRevokeCommand,
784
+ },
785
+ }),
786
+ },
787
+ });