libretto 0.6.9 → 0.6.11
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/cli/cli.js +2 -0
- package/dist/cli/commands/auth.js +535 -0
- package/dist/cli/commands/billing.js +74 -0
- package/dist/cli/commands/browser.js +8 -3
- package/dist/cli/commands/deploy.js +2 -7
- package/dist/cli/commands/execution.js +99 -136
- package/dist/cli/commands/snapshot.js +38 -126
- package/dist/cli/core/ai-model.js +0 -3
- package/dist/cli/core/auth-fetch.js +195 -0
- package/dist/cli/core/auth-storage.js +52 -0
- package/dist/cli/core/browser.js +128 -202
- package/dist/cli/core/daemon/config.js +6 -0
- package/dist/cli/core/daemon/daemon.js +298 -0
- package/dist/cli/core/daemon/exec.js +86 -0
- package/dist/cli/core/daemon/index.js +16 -0
- package/dist/cli/core/daemon/ipc.js +171 -0
- package/dist/cli/core/daemon/pages.js +15 -0
- package/dist/cli/core/daemon/snapshot.js +86 -0
- package/dist/cli/core/daemon/spawn.js +90 -0
- package/dist/cli/core/exec-compiler.js +111 -0
- package/dist/cli/core/prompt.js +72 -0
- package/dist/cli/core/providers/libretto-cloud.js +2 -6
- package/dist/cli/core/readonly-exec.js +1 -1
- package/dist/cli/router.js +4 -0
- package/dist/cli/workers/run-integration-runtime.js +0 -5
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +2 -1
- package/docs/browser-automation-approaches.md +435 -0
- package/docs/releasing.md +117 -0
- package/package.json +4 -3
- package/skills/libretto/SKILL.md +14 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +2 -0
- package/src/cli/commands/auth.ts +787 -0
- package/src/cli/commands/billing.ts +133 -0
- package/src/cli/commands/browser.ts +8 -2
- package/src/cli/commands/deploy.ts +2 -7
- package/src/cli/commands/execution.ts +126 -186
- package/src/cli/commands/snapshot.ts +46 -143
- package/src/cli/core/ai-model.ts +4 -5
- package/src/cli/core/auth-fetch.ts +283 -0
- package/src/cli/core/auth-storage.ts +102 -0
- package/src/cli/core/browser.ts +159 -242
- package/src/cli/core/daemon/config.ts +46 -0
- package/src/cli/core/daemon/daemon.ts +429 -0
- package/src/cli/core/daemon/exec.ts +128 -0
- package/src/cli/core/daemon/index.ts +24 -0
- package/src/cli/core/daemon/ipc.ts +294 -0
- package/src/cli/core/daemon/pages.ts +21 -0
- package/src/cli/core/daemon/snapshot.ts +114 -0
- package/src/cli/core/daemon/spawn.ts +171 -0
- package/src/cli/core/exec-compiler.ts +169 -0
- package/src/cli/core/prompt.ts +94 -0
- package/src/cli/core/providers/libretto-cloud.ts +2 -6
- package/src/cli/core/readonly-exec.ts +2 -1
- package/src/cli/router.ts +4 -0
- package/src/cli/workers/run-integration-runtime.ts +0 -6
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/browser-daemon.js +0 -122
- 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
|
+
});
|