libretto 0.6.31 → 0.6.33

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 (51) hide show
  1. package/README.md +1 -1
  2. package/README.template.md +1 -1
  3. package/dist/cli/commands/auth.js +119 -268
  4. package/dist/cli/commands/browser.js +1 -0
  5. package/dist/cli/commands/cloud-credentials.js +5 -17
  6. package/dist/cli/commands/cloud-jobs.js +125 -0
  7. package/dist/cli/commands/cloud-schedules.js +128 -0
  8. package/dist/cli/commands/cloud-settings.js +75 -0
  9. package/dist/cli/commands/cloud-sharing.js +13 -27
  10. package/dist/cli/commands/deploy.js +7 -16
  11. package/dist/cli/commands/execution.js +3 -2
  12. package/dist/cli/commands/profiles.js +8 -21
  13. package/dist/cli/commands/shared.js +17 -0
  14. package/dist/cli/core/browser.js +2 -1
  15. package/dist/cli/core/daemon/daemon.js +2 -1
  16. package/dist/cli/core/daemon/ipc.js +23 -16
  17. package/dist/cli/core/deploy-artifact.js +41 -16
  18. package/dist/cli/core/providers/kernel.js +3 -2
  19. package/dist/cli/core/providers/libretto-cloud.js +2 -1
  20. package/dist/cli/core/telemetry.js +14 -2
  21. package/dist/cli/router.js +6 -0
  22. package/dist/index.d.ts +1 -1
  23. package/dist/shared/workflow/workflow.d.ts +18 -0
  24. package/dist/shared/workflow/workflow.js +9 -0
  25. package/package.json +1 -1
  26. package/skills/libretto/SKILL.md +17 -2
  27. package/skills/libretto/references/website-authentication.md +18 -2
  28. package/skills/libretto-readonly/SKILL.md +1 -1
  29. package/src/cli/commands/auth.ts +169 -382
  30. package/src/cli/commands/browser.ts +1 -0
  31. package/src/cli/commands/cloud-credentials.ts +6 -18
  32. package/src/cli/commands/cloud-jobs.ts +157 -0
  33. package/src/cli/commands/cloud-schedules.ts +164 -0
  34. package/src/cli/commands/cloud-settings.ts +101 -0
  35. package/src/cli/commands/cloud-sharing.ts +20 -28
  36. package/src/cli/commands/deploy.ts +8 -19
  37. package/src/cli/commands/execution.ts +2 -1
  38. package/src/cli/commands/profiles.ts +10 -22
  39. package/src/cli/commands/shared.ts +29 -0
  40. package/src/cli/core/browser.ts +2 -0
  41. package/src/cli/core/daemon/config.ts +1 -0
  42. package/src/cli/core/daemon/daemon.ts +1 -0
  43. package/src/cli/core/daemon/ipc.ts +27 -18
  44. package/src/cli/core/deploy-artifact.ts +63 -14
  45. package/src/cli/core/providers/kernel.ts +3 -2
  46. package/src/cli/core/providers/libretto-cloud.ts +1 -0
  47. package/src/cli/core/providers/types.ts +1 -0
  48. package/src/cli/core/telemetry.ts +15 -1
  49. package/src/cli/router.ts +6 -0
  50. package/src/index.ts +1 -0
  51. package/src/shared/workflow/workflow.ts +22 -0
package/README.md CHANGED
@@ -95,7 +95,7 @@ All Libretto state lives in a `.libretto/` directory at your project root. See t
95
95
 
96
96
  ## Telemetry
97
97
 
98
- Libretto records anonymous CLI telemetry to help understand CLI usage and help us prioritize improvements. Each resolved command can send only an install id, timestamp, command event name such as `libretto run`, error boolean, package version, and build channel (`node_modules`, `source`, or `unknown`). Libretto does not send command arguments, URLs, project paths, auth state, API keys, error messages or details, or user identity.
98
+ Libretto records CLI telemetry to help understand CLI usage and help us prioritize improvements. Each resolved command can send only an install id, timestamp, command event name such as `libretto run`, error boolean, package version, build channel (`node_modules`, `source`, or `unknown`), and, when signed into Libretto Cloud, the configured cloud user id. Libretto does not send command arguments, URLs, project paths, session cookies, API keys, error messages or details, or emails.
99
99
 
100
100
  The install id is stored in the telemetry file at `~/.libretto/telemetry.json`. The implementation lives in [`src/cli/core/telemetry.ts`](src/cli/core/telemetry.ts).
101
101
 
@@ -93,7 +93,7 @@ All Libretto state lives in a `.libretto/` directory at your project root. See t
93
93
 
94
94
  ## Telemetry
95
95
 
96
- Libretto records anonymous CLI telemetry to help understand CLI usage and help us prioritize improvements. Each resolved command can send only an install id, timestamp, command event name such as `libretto run`, error boolean, package version, and build channel (`node_modules`, `source`, or `unknown`). Libretto does not send command arguments, URLs, project paths, auth state, API keys, error messages or details, or user identity.
96
+ Libretto records CLI telemetry to help understand CLI usage and help us prioritize improvements. Each resolved command can send only an install id, timestamp, command event name such as `libretto run`, error boolean, package version, build channel (`node_modules`, `source`, or `unknown`), and, when signed into Libretto Cloud, the configured cloud user id. Libretto does not send command arguments, URLs, project paths, session cookies, API keys, error messages or details, or emails.
97
97
 
98
98
  The install id is stored in the telemetry file at `~/.libretto/telemetry.json`. The implementation lives in [`{{LIBRETTO_PATH_PREFIX}}src/cli/core/telemetry.ts`]({{LIBRETTO_PATH_PREFIX}}src/cli/core/telemetry.ts).
99
99
 
@@ -1,7 +1,7 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { z } from "zod";
2
3
  import { SimpleCLI } from "affordance";
3
4
  import {
4
- ApiCallError,
5
5
  betterAuthCall,
6
6
  NOT_AUTHENTICATED_MESSAGE,
7
7
  orpcCall,
@@ -13,12 +13,29 @@ import {
13
13
  authStatePath,
14
14
  clearAuthState,
15
15
  readAuthState,
16
- setCookieToCookieHeader,
17
16
  writeAuthState
18
17
  } from "../core/auth-storage.js";
19
- import { prompt, promptPassword, slugify } from "../core/prompt.js";
20
- function isSlugTakenData(data) {
21
- return !!data && typeof data === "object" && data.reason === "slug_taken";
18
+ function resolveHostedWebsiteUrl() {
19
+ return process.env.LIBRETTO_WEBSITE_URL?.trim() || "https://libretto.sh";
20
+ }
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+ function openBrowser(url) {
25
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
26
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
27
+ try {
28
+ const child = spawn(command, args, {
29
+ detached: true,
30
+ stdio: "ignore"
31
+ });
32
+ child.on("error", () => {
33
+ });
34
+ child.unref();
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
22
39
  }
23
40
  async function getCurrentSession(apiUrl, cookie) {
24
41
  try {
@@ -33,33 +50,89 @@ async function getCurrentSession(apiUrl, cookie) {
33
50
  return null;
34
51
  }
35
52
  }
36
- async function pollForVerification(apiUrl, pollIntervalMs = 4e3, maxWaitMs = 10 * 60 * 1e3) {
37
- const start = Date.now();
38
- while (Date.now() - start < maxWaitMs) {
39
- const session = await getCurrentSession(apiUrl);
40
- if (session?.user.emailVerified) return true;
41
- process.stdout.write(".");
42
- await new Promise((r) => setTimeout(r, pollIntervalMs));
53
+ async function runBrowserAuthFlow(options) {
54
+ const login = await orpcCall({
55
+ apiUrl: options.apiUrl,
56
+ path: "/v1/auth/cliLoginCreate",
57
+ unauthenticated: true
58
+ });
59
+ const loginUrl = new URL("/signin", options.websiteUrl);
60
+ loginUrl.searchParams.set("cliLoginId", login.requestId);
61
+ loginUrl.searchParams.set("cliLoginSecret", login.secret);
62
+ if (options.mode === "signup") {
63
+ loginUrl.searchParams.set("mode", "signup");
43
64
  }
65
+ console.log(
66
+ options.mode === "signup" ? "Sign up for Libretto Cloud in your browser:" : "Sign in to Libretto Cloud in your browser:"
67
+ );
68
+ console.log(` ${loginUrl.toString()}`);
44
69
  console.log();
45
- return false;
46
- }
47
- async function persistSignupSession(apiUrl, result) {
48
- const cookie = setCookieToCookieHeader(result.setCookie);
49
- if (!cookie) {
50
- throw new Error("Sign-up did not return a session cookie. Check the server.");
70
+ if (openBrowser(loginUrl.toString())) {
71
+ console.log("Opened the page in your default browser.");
72
+ console.log("If it didn't open, copy the link above into your browser.");
73
+ console.log();
74
+ } else {
75
+ console.log("Copy the link above into your browser.");
76
+ console.log();
51
77
  }
52
- const next = {
53
- apiUrl,
54
- session: {
55
- cookie,
56
- userId: result.userId,
57
- email: result.email,
58
- expiresAt: null
78
+ console.log(
79
+ options.mode === "signup" ? "Waiting for browser sign-up" : "Waiting for browser sign-in"
80
+ );
81
+ const expiresAt = new Date(login.expiresAt).getTime();
82
+ let verificationHintShown = false;
83
+ while (Date.now() < expiresAt) {
84
+ const result = await orpcCall({
85
+ apiUrl: options.apiUrl,
86
+ path: "/v1/auth/cliLoginPoll",
87
+ input: {
88
+ requestId: login.requestId,
89
+ secret: login.secret
90
+ },
91
+ unauthenticated: true
92
+ });
93
+ if (result.status === "expired") {
94
+ throw new Error(
95
+ `Auth request expired. Run \`libretto cloud auth ${options.mode}\` again.`
96
+ );
59
97
  }
60
- };
61
- await writeAuthState(next);
62
- return next;
98
+ if (result.status === "approved") {
99
+ const session = await getCurrentSession(options.apiUrl, result.cookieHeader);
100
+ if (!session?.user?.id) {
101
+ throw new Error(
102
+ "Browser auth succeeded, but the returned session could not be verified."
103
+ );
104
+ }
105
+ const next = {
106
+ apiUrl: options.apiUrl,
107
+ session: {
108
+ cookie: result.cookieHeader,
109
+ userId: result.userId,
110
+ email: result.email,
111
+ expiresAt: session.session.expiresAt ?? result.sessionExpiresAt
112
+ }
113
+ };
114
+ await writeAuthState(next);
115
+ console.log();
116
+ console.log(`Logged in as ${result.email}.`);
117
+ if (!result.emailVerified) {
118
+ console.log(
119
+ "Heads up: your email isn't verified yet. Click the verification link in your inbox to finish setup."
120
+ );
121
+ }
122
+ return;
123
+ }
124
+ if (options.mode === "signup" && !verificationHintShown) {
125
+ console.log();
126
+ console.log("After signing up with email/password, verify your email to finish CLI auth.");
127
+ verificationHintShown = true;
128
+ }
129
+ process.stdout.write(".");
130
+ await sleep(2e3);
131
+ }
132
+ console.log();
133
+ throw new Error(
134
+ `Auth request expired. Run \`libretto cloud auth ${options.mode}\` again.`
135
+ );
63
136
  }
64
137
  async function resolveActiveOrgId(apiUrl, credential) {
65
138
  const { data: orgs } = await betterAuthCall({
@@ -88,149 +161,22 @@ async function issueApiKey(apiUrl, name, credential) {
88
161
  return data;
89
162
  }
90
163
  const signupCommand = SimpleCLI.command({
91
- description: "Create a new hosted-platform account and organization"
164
+ description: "Open the hosted-platform sign-up page"
92
165
  }).input(SimpleCLI.input({ positionals: [], named: {} })).handle(async () => {
93
- const apiUrl = resolveHostedApiUrl();
94
- console.log("Sign up for libretto cloud");
95
- console.log();
96
- console.log("Heads up: a libretto user can only belong to one organization.");
97
- console.log(
98
- "If your team already has a libretto org, ask a teammate for an invite instead \u2014 switching orgs later isn't supported."
99
- );
100
- console.log("Type 'q' at the name prompt to quit if that applies to you.");
101
- console.log();
102
- const name = await prompt("Your name:");
103
- if (name.toLowerCase() === "q" || name.length === 0) {
104
- console.log(
105
- "OK \u2014 ask an existing teammate to run `libretto cloud auth invite <your-email>` and then run `libretto cloud auth accept-invite <slug> <invitation-id>` from this machine."
106
- );
107
- return;
108
- }
109
- const email = await prompt("Your email:");
110
- const password = await promptPassword("Choose a password (8+ chars):");
111
- const orgName = await prompt("Organization name:");
112
- const defaultSlug = slugify(orgName);
113
- let orgSlug = (await prompt("Organization slug:", { defaultValue: defaultSlug })).toLowerCase();
114
- const debugNotificationEmail = await prompt(
115
- "Alert email (for hosted workflow failures):",
116
- { defaultValue: email }
117
- );
118
- console.log();
119
- console.log("Creating account...");
120
- let result;
121
- while (true) {
122
- try {
123
- result = await orpcCall({
124
- apiUrl,
125
- path: "/v1/auth/signupAndCreateOrg",
126
- input: {
127
- name,
128
- email,
129
- password,
130
- organizationName: orgName,
131
- organizationSlug: orgSlug,
132
- debugNotificationEmail
133
- },
134
- unauthenticated: true
135
- });
136
- break;
137
- } catch (e) {
138
- if (e instanceof ApiCallError && e.code === "CONFLICT" && isSlugTakenData(e.data)) {
139
- console.log();
140
- console.log(e.message);
141
- orgSlug = (await prompt("Organization slug:")).toLowerCase();
142
- continue;
143
- }
144
- throw e;
145
- }
146
- }
147
- await persistSignupSession(apiUrl, result);
148
- console.log(`Account created. Verification email sent to ${result.email}.`);
149
- console.log("Click the link in the email to verify, then return here.");
150
- console.log("Waiting for verification");
151
- const verified = await pollForVerification(apiUrl);
152
- if (!verified) {
153
- console.log();
154
- console.log(
155
- "Timed out waiting for email verification. Click the link in the email when you're ready \u2014 your CLI session is already saved, no need to re-run signup."
156
- );
157
- return;
158
- }
159
- console.log();
160
- console.log("Email verified. You're logged in.");
161
- console.log(`Session saved to ${authStatePath()}`);
162
- console.log();
163
- console.log("To generate an API key, run:");
164
- console.log(" libretto cloud auth api-key issue --label <label>");
165
- console.log("Then add LIBRETTO_API_KEY=<key> to your project's .env file.");
166
- });
167
- const loginCommand = SimpleCLI.command({
168
- description: "Sign in to an existing hosted-platform account"
169
- }).input(SimpleCLI.input({ positionals: [], named: {} })).handle(async () => {
170
- const apiUrl = resolveHostedApiUrl();
171
- const email = await prompt("Email:");
172
- const password = await promptPassword("Password:");
173
- const { data, setCookie } = await betterAuthCall({
174
- apiUrl,
175
- path: "/api/auth/sign-in/email",
176
- input: { email, password },
177
- unauthenticated: true
166
+ await runBrowserAuthFlow({
167
+ mode: "signup",
168
+ apiUrl: resolveHostedApiUrl(),
169
+ websiteUrl: resolveHostedWebsiteUrl()
178
170
  });
179
- const cookie = setCookieToCookieHeader(setCookie);
180
- if (!cookie) {
181
- throw new Error("Login response did not include a session cookie.");
182
- }
183
- const session = await getCurrentSession(apiUrl, cookie);
184
- const next = {
185
- apiUrl,
186
- session: {
187
- cookie,
188
- userId: data.user.id,
189
- email: data.user.email,
190
- expiresAt: session?.session.expiresAt ?? null
191
- }
192
- };
193
- await writeAuthState(next);
194
- console.log(`Logged in as ${data.user.email}.`);
195
- if (!data.user.emailVerified) {
196
- console.log(
197
- "Heads up: your email isn't verified yet. Re-sending the verification link to your inbox \u2014 click it to finish setup."
198
- );
199
- try {
200
- await betterAuthCall({
201
- apiUrl,
202
- path: "/api/auth/send-verification-email",
203
- input: {
204
- email: data.user.email,
205
- callbackURL: `${apiUrl}/auth/verified`
206
- },
207
- unauthenticated: true
208
- });
209
- console.log(`Verification email sent to ${data.user.email}.`);
210
- } catch (error) {
211
- const message = error instanceof Error ? error.message : "unknown error";
212
- console.log(
213
- `Couldn't resend the verification email (${message}). Try again, or hit /api/auth/send-verification-email directly.`
214
- );
215
- }
216
- }
217
171
  });
218
- const forgotPasswordCommand = SimpleCLI.command({
219
- description: "Send a password reset email"
172
+ const loginCommand = SimpleCLI.command({
173
+ description: "Open the hosted-platform sign-in page"
220
174
  }).input(SimpleCLI.input({ positionals: [], named: {} })).handle(async () => {
221
- const apiUrl = resolveHostedApiUrl();
222
- const email = await prompt("Email:");
223
- const result = await orpcCall({
224
- apiUrl,
225
- path: "/v1/auth/requestPasswordReset",
226
- input: { email },
227
- unauthenticated: true
175
+ await runBrowserAuthFlow({
176
+ mode: "login",
177
+ apiUrl: resolveHostedApiUrl(),
178
+ websiteUrl: resolveHostedWebsiteUrl()
228
179
  });
229
- if (result.status === "not_found") {
230
- console.log(`No Libretto account exists for ${email}.`);
231
- return;
232
- }
233
- console.log(`Password reset link sent to ${email}.`);
234
180
  });
235
181
  const logoutCommand = SimpleCLI.command({
236
182
  description: "Clear local libretto credentials"
@@ -260,7 +206,7 @@ const inviteCommand = SimpleCLI.command({
260
206
  ],
261
207
  named: {
262
208
  role: SimpleCLI.option(
263
- z.enum(["member", "admin", "owner"]).default("member"),
209
+ z.enum(["member", "owner"]).default("member"),
264
210
  { help: "Role to assign (default: member)." }
265
211
  )
266
212
  }
@@ -293,108 +239,17 @@ const inviteCommand = SimpleCLI.command({
293
239
  const org = orgs?.find((o) => o.id === data.organizationId);
294
240
  const orgName = org?.name ?? "<your-org-name>";
295
241
  const orgSlug = org?.slug ?? "<your-org-slug>";
242
+ const inviteUrl = new URL("/invite", resolveHostedWebsiteUrl());
243
+ inviteUrl.searchParams.set("tenantSlug", orgSlug);
244
+ inviteUrl.searchParams.set("invitationId", data.id);
245
+ inviteUrl.searchParams.set("accept", "1");
296
246
  console.log(`Invitation sent to ${data.email}.`);
297
247
  console.log(`Invitation id: ${data.id}`);
298
248
  console.log(`Organization: ${orgName} (${orgSlug})`);
299
249
  console.log(`Expires at: ${data.expiresAt}`);
300
250
  console.log();
301
- console.log("Tell them to run:");
302
- console.log(
303
- ` libretto cloud auth accept-invite ${orgSlug} ${data.id}`
304
- );
305
- });
306
- const acceptInviteCommand = SimpleCLI.command({
307
- description: "Accept an organization invitation"
308
- }).input(
309
- SimpleCLI.input({
310
- positionals: [
311
- SimpleCLI.positional(
312
- "tenantSlug",
313
- z.string().min(2).max(60).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, {
314
- message: "Slug must be lowercase letters, numbers, and hyphens (no leading/trailing hyphen)."
315
- }),
316
- {
317
- help: "Slug of the organization you're joining. Must match the org slug in the invitation email \u2014 acts as a confirmation step."
318
- }
319
- ),
320
- SimpleCLI.positional("invitationId", z.string().min(1), {
321
- help: "Invitation id from the invite email."
322
- })
323
- ],
324
- named: {}
325
- })
326
- ).handle(async ({ input }) => {
327
- const stored = await readAuthState();
328
- const apiUrl = resolveHostedApiUrl();
329
- const credential = pickCredential(stored);
330
- const expectedTenantSlug = input.tenantSlug;
331
- if (credential.source !== "none") {
332
- const { data: existingOrgs } = await betterAuthCall({
333
- apiUrl,
334
- path: "/api/auth/organization/list",
335
- method: "GET",
336
- credential
337
- });
338
- if (Array.isArray(existingOrgs) && existingOrgs.length > 0) {
339
- throw new Error(
340
- [
341
- "You're already a member of an organization.",
342
- "A libretto user can only belong to one organization at a time.",
343
- "To accept this invite: log out, delete the existing account, and re-run `libretto cloud auth accept-invite` with a new account (or a fresh email)."
344
- ].join("\n")
345
- );
346
- }
347
- const { data: invitation } = await betterAuthCall({
348
- apiUrl,
349
- path: `/api/auth/organization/get-invitation?id=${encodeURIComponent(input.invitationId)}`,
350
- method: "GET",
351
- credential
352
- });
353
- if (!invitation?.organizationSlug || invitation.organizationSlug !== expectedTenantSlug) {
354
- throw new Error(
355
- "Organization slug doesn't match this invitation. Double-check the slug shown in the invitation email."
356
- );
357
- }
358
- await betterAuthCall({
359
- apiUrl,
360
- path: "/api/auth/organization/accept-invitation",
361
- input: { invitationId: input.invitationId },
362
- credential
363
- });
364
- console.log(`Invitation accepted. You're now a member of ${invitation.organizationName}.`);
365
- return;
366
- }
367
- console.log("Accepting invite \u2014 let's create your account.");
368
- const name = await prompt("Your name:");
369
- const password = await promptPassword("Choose a password (8+ chars):");
370
- const result = await orpcCall({
371
- apiUrl,
372
- path: "/v1/auth/acceptInviteAndSignup",
373
- input: {
374
- invitationId: input.invitationId,
375
- tenantSlug: input.tenantSlug,
376
- name,
377
- password
378
- },
379
- unauthenticated: true
380
- });
381
- await persistSignupSession(apiUrl, result);
382
- console.log(`Account created. Verification email sent to ${result.email}.`);
383
- console.log("Click the link in the email and return here.");
384
- console.log("Waiting for verification");
385
- const verified = await pollForVerification(apiUrl);
386
- if (!verified) {
387
- console.log();
388
- console.log(
389
- "Timed out waiting for email verification. Click the link in the email when ready \u2014 your CLI session is already saved."
390
- );
391
- return;
392
- }
393
- console.log();
394
- console.log("Email verified. You're logged in and a member of the organization.");
395
- console.log("To generate an API key, run:");
396
- console.log(" libretto cloud auth api-key issue --label <label>");
397
- console.log("Then add LIBRETTO_API_KEY=<key> to your project's .env file.");
251
+ console.log("Invite link:");
252
+ console.log(` ${inviteUrl.toString()}`);
398
253
  });
399
254
  const apiKeyIssueCommand = SimpleCLI.command({
400
255
  description: "Issue a new API key for the active organization"
@@ -515,10 +370,8 @@ const authCommands = SimpleCLI.group({
515
370
  routes: {
516
371
  signup: signupCommand,
517
372
  login: loginCommand,
518
- "forgot-password": forgotPasswordCommand,
519
373
  logout: logoutCommand,
520
374
  invite: inviteCommand,
521
- "accept-invite": acceptInviteCommand,
522
375
  whoami: whoamiCommand,
523
376
  "api-key": SimpleCLI.group({
524
377
  description: "Manage API keys",
@@ -531,12 +384,10 @@ const authCommands = SimpleCLI.group({
531
384
  }
532
385
  });
533
386
  export {
534
- acceptInviteCommand,
535
387
  apiKeyIssueCommand,
536
388
  apiKeyListCommand,
537
389
  apiKeyRevokeCommand,
538
390
  authCommands,
539
- forgotPasswordCommand,
540
391
  inviteCommand,
541
392
  loginCommand,
542
393
  logoutCommand,
@@ -112,6 +112,7 @@ const openCommand = SimpleCLI.command({
112
112
  await runOpenWithProvider(
113
113
  input.url,
114
114
  providerName,
115
+ input.headless,
115
116
  ctx.session,
116
117
  ctx.logger,
117
118
  resolveRequestedSessionMode(input.readOnly, input.writeAccess),
@@ -1,18 +1,7 @@
1
1
  import { SimpleCLI } from "affordance";
2
- import { orpcCall, resolveApiUrl } from "../core/auth-fetch.js";
2
+ import { orpcCall } from "../core/auth-fetch.js";
3
+ import { withCloudApiKey } from "./shared.js";
3
4
  const CLOUD_CREDENTIAL_ENV_PREFIX = "LIBRETTO_CLOUD_";
4
- function requireApiKeyCredential() {
5
- const apiKey = process.env.LIBRETTO_API_KEY?.trim();
6
- if (!apiKey) {
7
- throw new Error(
8
- "LIBRETTO_API_KEY is required to manage Libretto Cloud credentials. Issue one with `libretto cloud auth api-key issue --label <label>`."
9
- );
10
- }
11
- return {
12
- apiUrl: resolveApiUrl(null),
13
- credential: { source: "env-api-key", apiKey }
14
- };
15
- }
16
5
  function parseEnvCredentials(prefix) {
17
6
  const credentials = {};
18
7
  for (const [key, value] of Object.entries(process.env)) {
@@ -29,22 +18,21 @@ const pushCredentialCommand = SimpleCLI.command({
29
18
  }).input(SimpleCLI.input({
30
19
  positionals: [],
31
20
  named: {}
32
- })).handle(async () => {
21
+ })).use(withCloudApiKey("manage Libretto Cloud credentials")).handle(async ({ ctx }) => {
33
22
  const credentials = parseEnvCredentials(CLOUD_CREDENTIAL_ENV_PREFIX);
34
23
  if (credentials.length === 0) {
35
24
  throw new Error(
36
25
  `No non-empty env vars found with prefix ${CLOUD_CREDENTIAL_ENV_PREFIX}.`
37
26
  );
38
27
  }
39
- const { apiUrl, credential } = requireApiKeyCredential();
40
28
  let created = 0;
41
29
  let updated = 0;
42
30
  for (const item of credentials) {
43
31
  const response = await orpcCall({
44
- apiUrl,
32
+ apiUrl: ctx.apiUrl,
45
33
  path: "/v1/credentials/upsert",
46
34
  input: item,
47
- credential
35
+ credential: ctx.credential
48
36
  });
49
37
  if (response.overwritten) {
50
38
  updated += 1;
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { SimpleCLI } from "affordance";
4
+ import { orpcCall } from "../core/auth-fetch.js";
5
+ import { withCloudApiKey } from "./shared.js";
6
+ const createJobUsage = "Usage: libretto cloud jobs create <workflow> [--params <json> | --params-file <path>]";
7
+ function parseJsonObject(label, raw) {
8
+ let parsed;
9
+ try {
10
+ parsed = JSON.parse(raw);
11
+ } catch (error) {
12
+ throw new Error(
13
+ `Invalid JSON in ${label}: ${error instanceof Error ? error.message : String(error)}`
14
+ );
15
+ }
16
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
17
+ throw new Error(`${label} must be a JSON object.`);
18
+ }
19
+ return parsed;
20
+ }
21
+ function readJsonObjectFile(label, filePath) {
22
+ let content;
23
+ try {
24
+ content = readFileSync(filePath, "utf8");
25
+ } catch {
26
+ throw new Error(
27
+ `Could not read ${label} "${filePath}". Ensure the file exists and is readable.`
28
+ );
29
+ }
30
+ return parseJsonObject(label, content);
31
+ }
32
+ const createCloudJobInput = SimpleCLI.input({
33
+ positionals: [
34
+ SimpleCLI.positional("workflow", z.string().optional(), {
35
+ help: "Deployed workflow name to run"
36
+ })
37
+ ],
38
+ named: {
39
+ params: SimpleCLI.option(z.string().optional(), {
40
+ help: "Inline JSON params object"
41
+ }),
42
+ paramsFile: SimpleCLI.option(z.string().optional(), {
43
+ name: "params-file",
44
+ help: "Path to a JSON params file"
45
+ }),
46
+ credentialId: SimpleCLI.option(z.string().optional(), {
47
+ name: "credential-id",
48
+ help: "Stored cloud credential id to pass to the workflow"
49
+ }),
50
+ timeoutSeconds: SimpleCLI.option(z.coerce.number().int().min(1).optional(), {
51
+ name: "timeout-seconds",
52
+ help: "Job timeout in seconds"
53
+ }),
54
+ headed: SimpleCLI.flag({ help: "Run browser in headed mode" }),
55
+ headless: SimpleCLI.flag({ help: "Run browser in headless mode" }),
56
+ callbackUrl: SimpleCLI.option(z.string().optional(), {
57
+ name: "callback-url",
58
+ help: "Per-job callback URL"
59
+ }),
60
+ callbackSecret: SimpleCLI.option(z.string().optional(), {
61
+ name: "callback-secret",
62
+ help: "Secret used to sign the per-job callback"
63
+ }),
64
+ skipCallbacks: SimpleCLI.flag({
65
+ name: "skip-callbacks",
66
+ help: "Skip stored webhook callbacks for this job"
67
+ }),
68
+ residentialProxy: SimpleCLI.option(z.string().optional(), {
69
+ name: "residential-proxy",
70
+ help: "Residential proxy config as a JSON object"
71
+ })
72
+ }
73
+ }).refine((input) => Boolean(input.workflow), createJobUsage).refine(
74
+ (input) => !(input.params && input.paramsFile),
75
+ "Pass either --params or --params-file, not both."
76
+ ).refine(
77
+ (input) => !(input.headed && input.headless),
78
+ "Cannot pass both --headed and --headless."
79
+ ).refine(
80
+ (input) => !input.callbackUrl && !input.callbackSecret || Boolean(input.callbackUrl && input.callbackSecret),
81
+ "Pass both --callback-url and --callback-secret, or omit both."
82
+ );
83
+ const createCloudJobCommand = SimpleCLI.command({
84
+ description: "Create a Libretto Cloud job for a deployed workflow"
85
+ }).input(createCloudJobInput).use(withCloudApiKey("create Libretto Cloud jobs")).handle(async ({ input, ctx }) => {
86
+ const params = input.paramsFile ? readJsonObjectFile("--params-file", input.paramsFile) : input.params ? parseJsonObject("--params", input.params) : {};
87
+ const residentialProxy = input.residentialProxy ? parseJsonObject("--residential-proxy", input.residentialProxy) : void 0;
88
+ const payload = {
89
+ workflow: input.workflow,
90
+ params
91
+ };
92
+ if (input.credentialId) payload.credential_id = input.credentialId;
93
+ if (input.timeoutSeconds !== void 0) {
94
+ payload.timeout_seconds = input.timeoutSeconds;
95
+ }
96
+ if (input.headed) payload.headless = false;
97
+ if (input.headless) payload.headless = true;
98
+ if (input.callbackUrl) payload.callback_url = input.callbackUrl;
99
+ if (input.callbackSecret) payload.callback_secret = input.callbackSecret;
100
+ if (input.skipCallbacks) payload.skip_callbacks = true;
101
+ if (residentialProxy !== void 0) {
102
+ payload.residential_proxy = residentialProxy;
103
+ }
104
+ const response = await orpcCall({
105
+ apiUrl: ctx.apiUrl,
106
+ path: "/v1/jobs/create",
107
+ input: payload,
108
+ credential: ctx.credential
109
+ });
110
+ console.log(`Job created: ${response.job_id}`);
111
+ console.log(`Status: ${response.status}`);
112
+ console.log(response.message);
113
+ return response.job_id;
114
+ });
115
+ const cloudJobCommands = SimpleCLI.group({
116
+ description: "Create and manage hosted jobs",
117
+ routes: {
118
+ create: createCloudJobCommand
119
+ }
120
+ });
121
+ export {
122
+ cloudJobCommands,
123
+ createCloudJobCommand,
124
+ createCloudJobInput
125
+ };