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.
Files changed (60) 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 +99 -136
  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 +128 -202
  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/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. package/src/cli/core/browser-daemon.ts +0 -198
@@ -1,12 +1,9 @@
1
1
  import {
2
2
  chromium
3
3
  } from "playwright";
4
- import { openSync, closeSync, existsSync } from "node:fs";
4
+ import { existsSync, unlinkSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
- import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { createRequire } from "node:module";
8
6
  import { createServer } from "node:net";
9
- import { spawn } from "node:child_process";
10
7
  import { PROFILES_DIR } from "./context.js";
11
8
  import { readLibrettoConfig } from "./config.js";
12
9
  import {
@@ -20,6 +17,7 @@ import {
20
17
  writeSessionState
21
18
  } from "./session.js";
22
19
  import { getCloudProviderApi } from "./providers/index.js";
20
+ import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
23
21
  const CLOSE_WAIT_MS = 1500;
24
22
  const FORCE_CLOSE_WAIT_MS = 300;
25
23
  async function pickFreePort() {
@@ -143,20 +141,6 @@ async function resolvePageReferences(pages) {
143
141
  );
144
142
  return refs;
145
143
  }
146
- async function listOpenPages(session, logger) {
147
- const { browser, page: activePage } = await connect(session, logger);
148
- try {
149
- const pages = browser.contexts().flatMap((ctx) => ctx.pages()).filter(isOperationalPage);
150
- const pageRefs = await resolvePageReferences(pages);
151
- return pageRefs.map(({ id, page }) => ({
152
- id,
153
- url: page.url(),
154
- active: page === activePage
155
- }));
156
- } finally {
157
- disconnectBrowser(browser, logger, session);
158
- }
159
- }
160
144
  async function connect(session, logger, timeoutMs = 1e4, options) {
161
145
  logger.info("connect", { session, timeoutMs });
162
146
  const state = readSessionStateOrThrow(session);
@@ -242,7 +226,15 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
242
226
  }
243
227
  async function runPages(session, logger) {
244
228
  logger.info("pages-start", { session });
245
- const pageSummaries = await listOpenPages(session, logger);
229
+ const state = readSessionStateOrThrow(session);
230
+ let pageSummaries;
231
+ if (!state.daemonSocketPath) {
232
+ throw new Error(
233
+ `Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session: libretto close --session ${session}`
234
+ );
235
+ }
236
+ const client = new DaemonClient(state.daemonSocketPath);
237
+ pageSummaries = await client.pages();
246
238
  if (pageSummaries.length === 0) {
247
239
  console.log("No pages found.");
248
240
  return;
@@ -302,8 +294,17 @@ async function runOpen(rawUrl, headed, session, logger, options) {
302
294
  const port = await pickFreePort();
303
295
  const runLogPath = logFileForSession(session);
304
296
  const browserMode = headed ? "headed" : "headless";
297
+ const authDomain = options?.authProfileDomain ? normalizeDomain(normalizeUrl(options.authProfileDomain)) : void 0;
298
+ if (authDomain) {
299
+ const authProfilePath = getProfilePath(authDomain);
300
+ if (!existsSync(authProfilePath)) {
301
+ throw new Error(
302
+ `No saved auth profile for "${authDomain}". Save one first: libretto open https://${authDomain} --headed --session <name>, log in, then run: libretto save ${authDomain} --session <name>`
303
+ );
304
+ }
305
+ }
305
306
  const supportsSavedProfile = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
306
- const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : void 0;
307
+ const domain = authDomain ?? (supportsSavedProfile ? normalizeDomain(parsedUrl) : void 0);
307
308
  const profilePath = domain ? getProfilePath(domain) : void 0;
308
309
  const useProfile = domain ? hasProfile(domain) : false;
309
310
  logger.info("open-launching", {
@@ -319,106 +320,46 @@ async function runOpen(rawUrl, headed, session, logger, options) {
319
320
  console.log(`Loading saved profile for ${domain}`);
320
321
  }
321
322
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
322
- const daemonEntryPath = fileURLToPath(
323
- new URL("./browser-daemon.js", import.meta.url)
324
- );
325
- const require2 = createRequire(import.meta.url);
326
- const tsxImportPath = pathToFileURL(require2.resolve("tsx/esm")).href;
327
- const daemonConfig = {
328
- port,
329
- url,
323
+ const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
324
+ config: {
325
+ port,
326
+ url,
327
+ session,
328
+ headed,
329
+ viewport,
330
+ storageStatePath: useProfile ? profilePath : void 0,
331
+ windowPosition
332
+ },
330
333
  session,
331
- headed,
332
- viewport,
333
- storageStatePath: useProfile ? profilePath : void 0,
334
- windowPosition
335
- };
336
- const childStderrFd = openSync(runLogPath, "a");
337
- const child = spawn(
338
- process.execPath,
339
- ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
340
- {
341
- detached: true,
342
- stdio: ["ignore", "ignore", childStderrFd]
343
- }
344
- );
345
- child.unref();
346
- closeSync(childStderrFd);
347
- logger.info("open-child-spawned", { pid: child.pid, port, session });
348
- let childSpawnError = null;
349
- let childEarlyExit = null;
350
- child.on("error", (err) => {
351
- childSpawnError = err;
352
- logger.error("open-child-spawn-error", { error: err, session, port });
334
+ logger,
335
+ logPath: runLogPath,
336
+ // The daemon launches Chromium, installs telemetry, navigates to
337
+ // the URL, and only then starts IPC. Navigation alone can take up
338
+ // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
339
+ // must cover launch + navigation.
340
+ ipcTimeoutMs: 6e4
353
341
  });
354
- child.on("exit", (code, signal) => {
355
- childEarlyExit = { code, signal };
356
- logger.warn("open-child-exited", {
357
- code,
358
- signal,
359
- session,
342
+ writeSessionState(
343
+ {
360
344
  port,
361
- pid: child.pid
362
- });
363
- });
364
- const cdpPollIntervalMs = 500;
365
- const cdpMaxAttempts = 30;
366
- const cdpStartupTimeoutMs = cdpPollIntervalMs * cdpMaxAttempts;
367
- for (let i = 0; i < cdpMaxAttempts; i++) {
368
- const spawnError = childSpawnError;
369
- if (spawnError !== null) {
370
- const errWithCode = spawnError;
371
- const hint = errWithCode.code === "ENOENT" ? " Ensure Node.js is available in PATH for child processes." : "";
372
- throw new Error(
373
- `Failed to launch browser child process: ${spawnError.message}.${hint} Check logs: ${runLogPath}`
374
- );
375
- }
376
- const earlyExit = childEarlyExit;
377
- if (earlyExit !== null) {
378
- const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
379
- throw new Error(
380
- `Browser child process exited before startup (status: ${status}). Check logs: ${runLogPath}`
381
- );
382
- }
383
- await new Promise((r) => setTimeout(r, cdpPollIntervalMs));
384
- const ready = await fetch(`http://127.0.0.1:${port}/json/version`).then(() => true).catch(() => false);
385
- if (i > 0 && i % 5 === 0) {
386
- logger.info("open-waiting-for-cdp", { attempt: i, port, session });
387
- }
388
- if (ready) {
389
- writeSessionState(
390
- {
391
- port,
392
- pid: child.pid,
393
- session,
394
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
395
- status: "active",
396
- mode: accessMode,
397
- viewport
398
- },
399
- logger
400
- );
401
- logger.info("open-success", {
402
- url,
403
- mode: browserMode,
404
- session,
405
- port,
406
- pid: child.pid
407
- });
408
- console.log(`Browser open (${browserMode}): ${url}`);
409
- await new Promise((r) => setTimeout(r, 2e3));
410
- return;
411
- }
412
- }
413
- logger.error("open-timeout", {
345
+ pid,
346
+ session,
347
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
348
+ status: "active",
349
+ mode: accessMode,
350
+ viewport,
351
+ daemonSocketPath
352
+ },
353
+ logger
354
+ );
355
+ logger.info("open-success", {
356
+ url,
357
+ mode: browserMode,
414
358
  session,
415
359
  port,
416
- pid: child.pid,
417
- attempts: cdpMaxAttempts
360
+ pid
418
361
  });
419
- throw new Error(
420
- `Failed to connect to browser after ${Math.ceil(cdpStartupTimeoutMs / 1e3)}s. Check startup logs: ${runLogPath}`
421
- );
362
+ console.log(`Browser open (${browserMode}): ${url}`);
422
363
  }
423
364
  async function runOpenWithProvider(rawUrl, providerName, provider, session, logger, accessMode = "write-access") {
424
365
  const parsedUrl = normalizeUrl(rawUrl);
@@ -438,64 +379,38 @@ async function runOpenWithProvider(rawUrl, providerName, provider, session, logg
438
379
  console.log(`View live session: ${providerSession.liveViewUrl}`);
439
380
  }
440
381
  console.log(`Connecting to ${providerName} browser...`);
441
- let browser = null;
442
- try {
443
- browser = await tryConnectToCDP(
444
- providerSession.cdpEndpoint,
445
- logger,
446
- 3e4
447
- );
448
- if (!browser) {
449
- throw new Error(
450
- `Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`
451
- );
452
- }
453
- const contexts = browser.contexts();
454
- let page;
455
- if (contexts.length > 0 && contexts[0].pages().length > 0) {
456
- page = contexts[0].pages()[0];
457
- } else {
458
- const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
459
- page = await context.newPage();
460
- }
461
- await page.goto(url);
462
- logger.info("open-provider-navigated", { url, session });
463
- writeSessionState(
464
- {
465
- port: 0,
466
- cdpEndpoint: providerSession.cdpEndpoint,
467
- session,
468
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
469
- status: "active",
470
- mode: accessMode,
471
- provider: {
472
- name: providerName,
473
- sessionId: providerSession.sessionId
474
- }
475
- },
476
- logger
477
- );
478
- disconnectBrowser(browser, logger, session);
479
- } catch (err) {
480
- if (browser) {
481
- disconnectBrowser(browser, logger, session);
482
- }
483
- logger.warn("open-provider-cleanup-after-error", {
484
- provider: providerName,
485
- sessionId: providerSession.sessionId,
486
- error: err
487
- });
488
- try {
489
- await provider.closeSession(providerSession.sessionId);
490
- } catch (cleanupErr) {
491
- logger.warn("open-provider-cleanup-failed", {
492
- provider: providerName,
493
- sessionId: providerSession.sessionId,
494
- error: cleanupErr
495
- });
496
- }
497
- throw err;
498
- }
382
+ const runLogPath = logFileForSession(session);
383
+ const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
384
+ config: {
385
+ mode: "connect",
386
+ session,
387
+ cdpEndpoint: providerSession.cdpEndpoint,
388
+ url
389
+ },
390
+ session,
391
+ logger,
392
+ logPath: runLogPath,
393
+ // Remote CDP connection + navigation; must cover both.
394
+ ipcTimeoutMs: 6e4,
395
+ onFailure: () => provider.closeSession(providerSession.sessionId)
396
+ });
397
+ writeSessionState(
398
+ {
399
+ port: 0,
400
+ pid,
401
+ cdpEndpoint: providerSession.cdpEndpoint,
402
+ session,
403
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
404
+ status: "active",
405
+ mode: accessMode,
406
+ daemonSocketPath,
407
+ provider: {
408
+ name: providerName,
409
+ sessionId: providerSession.sessionId
410
+ }
411
+ },
412
+ logger
413
+ );
499
414
  logger.info("open-provider-success", {
500
415
  url,
501
416
  provider: providerName,
@@ -576,6 +491,11 @@ async function runClose(session, logger) {
576
491
  console.log(`No browser running for session "${session}".`);
577
492
  return;
578
493
  }
494
+ if (state.pid != null) {
495
+ logger.info("close-killing", { session, pid: state.pid, port: state.port });
496
+ sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
497
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
498
+ }
579
499
  let replayUrl;
580
500
  if (state.provider) {
581
501
  logger.info("close-provider", {
@@ -599,13 +519,8 @@ async function runClose(session, logger) {
599
519
  `Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`
600
520
  );
601
521
  }
602
- } else {
603
- logger.info("close-killing", { session, pid: state.pid, port: state.port });
604
- if (state.pid != null) {
605
- sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
606
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
607
- }
608
522
  }
523
+ unlinkDaemonSocket(state.daemonSocketPath, logger, session);
609
524
  clearSessionState(session, logger);
610
525
  logger.info("close-success", { session, replayUrl });
611
526
  console.log(`Browser closed (session: ${session}).`);
@@ -650,16 +565,32 @@ function resolveClosableSessions(logger) {
650
565
  session,
651
566
  pid: state.pid,
652
567
  port: state.port,
653
- provider: state.provider
568
+ provider: state.provider,
569
+ daemonSocketPath: state.daemonSocketPath
654
570
  });
655
571
  }
656
572
  return { closable, clearedUnreadableStates };
657
573
  }
574
+ function unlinkDaemonSocket(socketPath, logger, session) {
575
+ if (!socketPath) return;
576
+ try {
577
+ unlinkSync(socketPath);
578
+ } catch (err) {
579
+ if (err.code !== "ENOENT") {
580
+ logger.warn("close-socket-unlink-failed", {
581
+ session,
582
+ socketPath,
583
+ error: err
584
+ });
585
+ }
586
+ }
587
+ }
658
588
  function clearStoppedSessionStates(sessions, logger, skip) {
659
589
  let cleared = 0;
660
590
  for (const session of sessions) {
661
591
  if (skip?.has(session.session)) continue;
662
592
  if (session.pid == null || !isPidRunning(session.pid)) {
593
+ unlinkDaemonSocket(session.daemonSocketPath, logger, session.session);
663
594
  clearSessionState(session.session, logger);
664
595
  cleared += 1;
665
596
  }
@@ -713,20 +644,18 @@ async function runCloseAll(logger, options) {
713
644
  }
714
645
  }
715
646
  for (const target of closable) {
716
- if (target.provider) continue;
647
+ if (target.pid == null) continue;
717
648
  logger.info("close-all-sigterm", {
718
649
  session: target.session,
719
650
  pid: target.pid,
720
651
  port: target.port
721
652
  });
722
- if (target.pid != null) {
723
- sendSignalToProcessGroupOrPid(
724
- target.pid,
725
- "SIGTERM",
726
- logger,
727
- target.session
728
- );
729
- }
653
+ sendSignalToProcessGroupOrPid(
654
+ target.pid,
655
+ "SIGTERM",
656
+ logger,
657
+ target.session
658
+ );
730
659
  }
731
660
  await waitForCloseSignalWindow(CLOSE_WAIT_MS);
732
661
  let survivors = closable.filter(
@@ -845,36 +774,34 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
845
774
  endpoint
846
775
  });
847
776
  }
848
- const browser = await tryConnectToCDP(endpoint, logger, 1e4);
849
- if (!browser) {
850
- throw new Error(
851
- `CDP endpoint at ${endpoint} is reachable but Playwright could not connect. Check that the URL is a Chrome DevTools Protocol endpoint.`
852
- );
853
- }
854
- const pages = resolveOperationalPages(browser);
855
- logger.info("connect-pages", {
777
+ const runLogPath = logFileForSession(session);
778
+ const { pid, socketPath: daemonSocketPath, client } = await spawnSessionDaemon({
779
+ config: { mode: "connect", session, cdpEndpoint: endpoint },
856
780
  session,
857
- pageCount: pages.length,
858
- urls: pages.map((p) => p.url())
781
+ logger,
782
+ logPath: runLogPath,
783
+ ipcTimeoutMs: 1e4
859
784
  });
860
- disconnectBrowser(browser, logger, session);
861
785
  writeSessionState(
862
786
  {
863
787
  port,
788
+ pid,
864
789
  cdpEndpoint: endpoint,
865
790
  session,
866
791
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
867
792
  status: "active",
868
- mode: accessMode
793
+ mode: accessMode,
794
+ daemonSocketPath
869
795
  },
870
796
  logger
871
797
  );
798
+ const pages = await client.pages();
872
799
  logger.info("connect-success", { cdpUrl: endpoint, session, port });
873
800
  console.log(`Connected to ${endpoint} (session: ${session})`);
874
801
  console.log(` Pages found: ${pages.length}`);
875
802
  if (pages.length > 0) {
876
803
  for (const p of pages.slice(0, 5)) {
877
- console.log(` ${p.url()}`);
804
+ console.log(` ${p.url}`);
878
805
  }
879
806
  if (pages.length > 5) {
880
807
  console.log(` ... and ${pages.length - 5} more`);
@@ -895,7 +822,6 @@ export {
895
822
  getProfilePath,
896
823
  getScreenshotBaseName,
897
824
  hasProfile,
898
- listOpenPages,
899
825
  normalizeDomain,
900
826
  normalizeUrl,
901
827
  resolvePath,
@@ -0,0 +1,6 @@
1
+ function isConnectConfig(config) {
2
+ return "mode" in config && config.mode === "connect";
3
+ }
4
+ export {
5
+ isConnectConfig
6
+ };