libretto 0.6.12 → 0.6.14
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/README.md +3 -8
- package/README.template.md +3 -8
- package/dist/cli/cli.js +0 -23
- package/dist/cli/commands/auth.js +24 -33
- package/dist/cli/commands/billing.js +3 -5
- package/dist/cli/commands/browser.js +4 -13
- package/dist/cli/commands/deploy.js +54 -45
- package/dist/cli/commands/execution.js +6 -3
- package/dist/cli/commands/experiments.js +1 -1
- package/dist/cli/commands/setup.js +2 -295
- package/dist/cli/commands/shared.js +1 -1
- package/dist/cli/commands/snapshot.js +10 -100
- package/dist/cli/commands/status.js +2 -42
- package/dist/cli/core/auth-fetch.js +11 -6
- package/dist/cli/core/browser.js +13 -8
- package/dist/cli/core/config.js +3 -6
- package/dist/cli/core/daemon/daemon.js +88 -74
- package/dist/cli/core/daemon/exec-repl.js +133 -0
- package/dist/cli/core/daemon/exec.js +6 -21
- package/dist/cli/core/daemon/ipc.js +47 -4
- package/dist/cli/core/daemon/ipc.spec.js +21 -0
- package/dist/cli/core/daemon/snapshot.js +2 -29
- package/dist/cli/core/exec-compiler.js +8 -3
- package/dist/cli/core/experiments.js +1 -28
- package/dist/cli/core/providers/index.js +13 -4
- package/dist/cli/core/providers/libretto-cloud.js +178 -26
- package/dist/cli/index.js +0 -2
- package/dist/cli/router.js +9 -6
- package/dist/shared/instrumentation/instrument.js +4 -4
- package/dist/shared/ipc/socket-transport.d.ts +2 -1
- package/dist/shared/ipc/socket-transport.js +16 -5
- package/dist/shared/ipc/socket-transport.spec.js +5 -0
- package/docs/releasing.md +8 -6
- package/package.json +3 -2
- package/skills/libretto/SKILL.md +49 -47
- package/skills/libretto/references/code-generation-rules.md +6 -0
- package/skills/libretto/references/configuration-file-reference.md +14 -12
- package/skills/libretto/references/pages-and-page-targeting.md +1 -1
- package/skills/libretto/references/site-security-review.md +6 -6
- package/skills/libretto-readonly/SKILL.md +2 -9
- package/src/cli/cli.ts +0 -24
- package/src/cli/commands/auth.ts +24 -33
- package/src/cli/commands/billing.ts +3 -5
- package/src/cli/commands/browser.ts +6 -16
- package/src/cli/commands/deploy.ts +55 -49
- package/src/cli/commands/execution.ts +6 -3
- package/src/cli/commands/experiments.ts +1 -1
- package/src/cli/commands/setup.ts +2 -381
- package/src/cli/commands/shared.ts +1 -1
- package/src/cli/commands/snapshot.ts +9 -137
- package/src/cli/commands/status.ts +2 -50
- package/src/cli/core/auth-fetch.ts +9 -4
- package/src/cli/core/browser.ts +15 -8
- package/src/cli/core/config.ts +3 -6
- package/src/cli/core/daemon/daemon.ts +106 -76
- package/src/cli/core/daemon/exec-repl.ts +189 -0
- package/src/cli/core/daemon/exec.ts +8 -43
- package/src/cli/core/daemon/ipc.spec.ts +27 -0
- package/src/cli/core/daemon/ipc.ts +81 -23
- package/src/cli/core/daemon/snapshot.ts +1 -43
- package/src/cli/core/exec-compiler.ts +8 -3
- package/src/cli/core/experiments.ts +9 -38
- package/src/cli/core/providers/index.ts +17 -4
- package/src/cli/core/providers/libretto-cloud.ts +224 -36
- package/src/cli/core/resolve-model.ts +5 -0
- package/src/cli/core/workflow-runtime.ts +1 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/router.ts +9 -6
- package/src/shared/instrumentation/instrument.ts +4 -4
- package/src/shared/ipc/socket-transport.spec.ts +6 -0
- package/src/shared/ipc/socket-transport.ts +20 -5
- package/dist/cli/commands/ai.js +0 -110
- package/dist/cli/core/ai-model.js +0 -195
- package/dist/cli/core/api-snapshot-analyzer.js +0 -86
- package/dist/cli/core/snapshot-analyzer.js +0 -667
- package/dist/cli/framework/simple-cli.js +0 -880
- package/scripts/summarize-evals.mjs +0 -135
- package/src/cli/commands/ai.ts +0 -144
- package/src/cli/core/ai-model.ts +0 -301
- package/src/cli/core/api-snapshot-analyzer.ts +0 -110
- package/src/cli/core/snapshot-analyzer.ts +0 -856
- package/src/cli/framework/simple-cli.ts +0 -1459
|
@@ -63,7 +63,8 @@ import {
|
|
|
63
63
|
} from "../browser.js";
|
|
64
64
|
import { handlePages } from "./pages.js";
|
|
65
65
|
import { handleExec, handleReadonlyExec } from "./exec.js";
|
|
66
|
-
import {
|
|
66
|
+
import { DaemonExecRepl } from "./exec-repl.js";
|
|
67
|
+
import { handleCompactSnapshot } from "./snapshot.js";
|
|
67
68
|
import { librettoCommand } from "../../../shared/package-manager.js";
|
|
68
69
|
import type { Snapshot } from "../../../shared/snapshot/types.js";
|
|
69
70
|
import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
|
|
@@ -82,7 +83,7 @@ import {
|
|
|
82
83
|
} from "./config.js";
|
|
83
84
|
import type { Experiments } from "../experiments.js";
|
|
84
85
|
import { getCloudProviderApi } from "../providers/index.js";
|
|
85
|
-
import type { ProviderApi } from "../providers/types.js";
|
|
86
|
+
import type { ProviderApi, ProviderSession } from "../providers/types.js";
|
|
86
87
|
import {
|
|
87
88
|
getAbsoluteIntegrationPath,
|
|
88
89
|
loadDefaultWorkflow,
|
|
@@ -160,7 +161,7 @@ const REQUEST_TIMEOUT_MS = 60_000;
|
|
|
160
161
|
|
|
161
162
|
class BrowserDaemon {
|
|
162
163
|
readonly logger: LoggerApi;
|
|
163
|
-
private readonly
|
|
164
|
+
private readonly execRepl: DaemonExecRepl;
|
|
164
165
|
private readonly pageById = new Map<string, Page>();
|
|
165
166
|
private readonly shutdownHandlers: ShutdownHandler[] = [];
|
|
166
167
|
private readonly connectedClis = new Set<IpcPeer<DaemonToCliApi>>();
|
|
@@ -183,6 +184,10 @@ class BrowserDaemon {
|
|
|
183
184
|
},
|
|
184
185
|
) {
|
|
185
186
|
this.logger = logger.withScope("child");
|
|
187
|
+
this.execRepl = new DaemonExecRepl({
|
|
188
|
+
browser: this.browser,
|
|
189
|
+
context: this.context,
|
|
190
|
+
});
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
private trackPage(page: Page): string {
|
|
@@ -230,6 +235,7 @@ class BrowserDaemon {
|
|
|
230
235
|
name: string;
|
|
231
236
|
sessionId: string;
|
|
232
237
|
};
|
|
238
|
+
beforeReady?: () => void;
|
|
233
239
|
}): Promise<BrowserDaemon> {
|
|
234
240
|
const {
|
|
235
241
|
session,
|
|
@@ -242,6 +248,7 @@ class BrowserDaemon {
|
|
|
242
248
|
navigateUrl,
|
|
243
249
|
readyProvider,
|
|
244
250
|
providerSession,
|
|
251
|
+
beforeReady,
|
|
245
252
|
} = args;
|
|
246
253
|
|
|
247
254
|
await mkdir(getSessionDir(session), { recursive: true });
|
|
@@ -271,9 +278,7 @@ class BrowserDaemon {
|
|
|
271
278
|
});
|
|
272
279
|
}
|
|
273
280
|
|
|
274
|
-
|
|
275
|
-
await context.addInitScript(installPageStabilityWaiter);
|
|
276
|
-
}
|
|
281
|
+
await context.addInitScript(installPageStabilityWaiter);
|
|
277
282
|
|
|
278
283
|
// IPC server — typed handlers are attached per client connection so one
|
|
279
284
|
// daemon lifetime can serve multiple CLI invocations.
|
|
@@ -295,19 +300,15 @@ class BrowserDaemon {
|
|
|
295
300
|
wrapPageForActionLogging(p, session);
|
|
296
301
|
daemon.trackPage(p);
|
|
297
302
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
);
|
|
304
|
-
}
|
|
303
|
+
await Promise.all(
|
|
304
|
+
initialPages.map((initialPage) =>
|
|
305
|
+
daemon.installCompactSnapshotWaiter(initialPage),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
305
308
|
context.on("page", (newPage) => {
|
|
306
309
|
wrapPageForActionLogging(newPage, session);
|
|
307
310
|
daemon.trackPage(newPage);
|
|
308
|
-
|
|
309
|
-
void daemon.installCompactSnapshotWaiter(newPage);
|
|
310
|
-
}
|
|
311
|
+
void daemon.installCompactSnapshotWaiter(newPage);
|
|
311
312
|
});
|
|
312
313
|
|
|
313
314
|
// Navigate after telemetry is installed (so we capture the initial
|
|
@@ -352,6 +353,7 @@ class BrowserDaemon {
|
|
|
352
353
|
});
|
|
353
354
|
|
|
354
355
|
await listenOnIpcSocket(ipcServer, socketPath);
|
|
356
|
+
beforeReady?.();
|
|
355
357
|
process.send?.({ type: "ready", socketPath, provider: readyProvider });
|
|
356
358
|
daemon.logger.info("ipc-server-listening", { socketPath });
|
|
357
359
|
|
|
@@ -477,8 +479,13 @@ class BrowserDaemon {
|
|
|
477
479
|
}): Promise<BrowserDaemon> {
|
|
478
480
|
const { session, browser: config } = args;
|
|
479
481
|
const provider = getCloudProviderApi(config.providerName);
|
|
480
|
-
|
|
482
|
+
let providerSession: ProviderSession | undefined;
|
|
483
|
+
const startupCleanup = createProviderStartupCleanup({
|
|
484
|
+
provider,
|
|
485
|
+
getProviderSession: () => providerSession,
|
|
486
|
+
});
|
|
481
487
|
try {
|
|
488
|
+
providerSession = await provider.createSession();
|
|
482
489
|
const browser = await chromium.connectOverCDP(
|
|
483
490
|
providerSession.cdpEndpoint,
|
|
484
491
|
);
|
|
@@ -512,6 +519,7 @@ class BrowserDaemon {
|
|
|
512
519
|
name: config.providerName,
|
|
513
520
|
sessionId: providerSession.sessionId,
|
|
514
521
|
},
|
|
522
|
+
beforeReady: startupCleanup.dispose,
|
|
515
523
|
});
|
|
516
524
|
|
|
517
525
|
daemon.logger.info("child-provider-connected", {
|
|
@@ -524,7 +532,10 @@ class BrowserDaemon {
|
|
|
524
532
|
|
|
525
533
|
return daemon;
|
|
526
534
|
} catch (error) {
|
|
527
|
-
|
|
535
|
+
startupCleanup.dispose();
|
|
536
|
+
if (providerSession) {
|
|
537
|
+
await provider.closeSession(providerSession.sessionId);
|
|
538
|
+
}
|
|
528
539
|
throw error;
|
|
529
540
|
}
|
|
530
541
|
}
|
|
@@ -659,40 +670,23 @@ class BrowserDaemon {
|
|
|
659
670
|
private async runSnapshot(
|
|
660
671
|
args: Parameters<CliToDaemonApi["snapshot"]>[0],
|
|
661
672
|
): Promise<ReturnType<CliToDaemonApi["snapshot"]>> {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
`Close and reopen the session after running ${librettoCommand("experiments enable compact-snapshot-format")}.`,
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
const targetPage = this.resolveTargetPage(args.pageId);
|
|
670
|
-
const result = await this.withRequestTimeout(() =>
|
|
671
|
-
handleCompactSnapshot(
|
|
672
|
-
targetPage,
|
|
673
|
-
this.session,
|
|
674
|
-
this.logger,
|
|
675
|
-
{
|
|
676
|
-
pageId: args.pageId,
|
|
677
|
-
cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
|
|
678
|
-
useCachedSnapshot: args.useCachedSnapshot,
|
|
679
|
-
},
|
|
680
|
-
),
|
|
681
|
-
);
|
|
682
|
-
if (!args.useCachedSnapshot) {
|
|
683
|
-
this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
|
|
684
|
-
}
|
|
685
|
-
return result;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
return this.withRequestTimeout(() =>
|
|
689
|
-
handleSnapshot(
|
|
690
|
-
this.resolveTargetPage(args.pageId),
|
|
673
|
+
const targetPage = this.resolveTargetPage(args.pageId);
|
|
674
|
+
const result = await this.withRequestTimeout(() =>
|
|
675
|
+
handleCompactSnapshot(
|
|
676
|
+
targetPage,
|
|
691
677
|
this.session,
|
|
692
678
|
this.logger,
|
|
693
|
-
|
|
679
|
+
{
|
|
680
|
+
pageId: args.pageId,
|
|
681
|
+
cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
|
|
682
|
+
useCachedSnapshot: args.useCachedSnapshot,
|
|
683
|
+
},
|
|
694
684
|
),
|
|
695
685
|
);
|
|
686
|
+
if (!args.useCachedSnapshot) {
|
|
687
|
+
this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
|
|
688
|
+
}
|
|
689
|
+
return result;
|
|
696
690
|
}
|
|
697
691
|
|
|
698
692
|
private async withRequestTimeout<T>(
|
|
@@ -720,26 +714,7 @@ class BrowserDaemon {
|
|
|
720
714
|
private async runExec(
|
|
721
715
|
args: Parameters<CliToDaemonApi["exec"]>[0],
|
|
722
716
|
): Promise<DaemonExecResult> {
|
|
723
|
-
|
|
724
|
-
return this.runCompactExec(args);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
try {
|
|
728
|
-
const data = await this.withRequestTimeout(() =>
|
|
729
|
-
handleExec(
|
|
730
|
-
this.resolveTargetPage(args.pageId),
|
|
731
|
-
args.code,
|
|
732
|
-
this.context,
|
|
733
|
-
this.browser,
|
|
734
|
-
this.execState,
|
|
735
|
-
this.session,
|
|
736
|
-
args.visualize,
|
|
737
|
-
),
|
|
738
|
-
);
|
|
739
|
-
return { ok: true, data };
|
|
740
|
-
} catch (error) {
|
|
741
|
-
return this.createExecErrorResult(error);
|
|
742
|
-
}
|
|
717
|
+
return this.runCompactExec(args);
|
|
743
718
|
}
|
|
744
719
|
|
|
745
720
|
private async runCompactExec(
|
|
@@ -755,10 +730,7 @@ class BrowserDaemon {
|
|
|
755
730
|
const result = await handleExec(
|
|
756
731
|
page,
|
|
757
732
|
args.code,
|
|
758
|
-
this.
|
|
759
|
-
this.browser,
|
|
760
|
-
this.execState,
|
|
761
|
-
this.session,
|
|
733
|
+
this.execRepl,
|
|
762
734
|
args.visualize,
|
|
763
735
|
);
|
|
764
736
|
|
|
@@ -831,17 +803,17 @@ class BrowserDaemon {
|
|
|
831
803
|
context: this.context,
|
|
832
804
|
logger: this.logger,
|
|
833
805
|
onLog: (event) => {
|
|
834
|
-
this.broadcast("workflowOutput", event);
|
|
806
|
+
void this.broadcast("workflowOutput", event);
|
|
835
807
|
},
|
|
836
808
|
onOutcome: (outcome) => {
|
|
837
809
|
if (outcome.state === "paused") {
|
|
838
|
-
this.broadcast("workflowPaused", {
|
|
810
|
+
void this.broadcast("workflowPaused", {
|
|
839
811
|
pausedAt: outcome.pausedAt,
|
|
840
812
|
url: outcome.url,
|
|
841
813
|
});
|
|
842
814
|
return;
|
|
843
815
|
}
|
|
844
|
-
this.broadcast(
|
|
816
|
+
void this.broadcast(
|
|
845
817
|
"workflowFinished",
|
|
846
818
|
outcome.result === "completed"
|
|
847
819
|
? { result: "completed", completedAt: outcome.completedAt }
|
|
@@ -898,6 +870,62 @@ class BrowserDaemon {
|
|
|
898
870
|
}
|
|
899
871
|
}
|
|
900
872
|
|
|
873
|
+
function createProviderStartupCleanup(args: {
|
|
874
|
+
provider: ProviderApi;
|
|
875
|
+
getProviderSession: () => ProviderSession | undefined;
|
|
876
|
+
}): { dispose: () => void } {
|
|
877
|
+
let disposed = false;
|
|
878
|
+
let fallbackExit: NodeJS.Timeout | undefined;
|
|
879
|
+
|
|
880
|
+
const requestClose = (reason: string): void => {
|
|
881
|
+
if (disposed) return;
|
|
882
|
+
disposed = true;
|
|
883
|
+
// Startup cancellation should preserve the signal-style exit code even if
|
|
884
|
+
// provider.createSession() later rejects through the normal startup path.
|
|
885
|
+
process.exitCode = reason === "received SIGINT" ? 130 : 1;
|
|
886
|
+
const providerSession = args.getProviderSession();
|
|
887
|
+
if (!providerSession) {
|
|
888
|
+
// If cancellation lands while createSession() is still awaiting provider
|
|
889
|
+
// capacity, provider-specific signal handlers may still need a tick to
|
|
890
|
+
// close a queued session id that this daemon has not received yet.
|
|
891
|
+
fallbackExit = setTimeout(() => {
|
|
892
|
+
process.exit(process.exitCode);
|
|
893
|
+
}, 5_000);
|
|
894
|
+
fallbackExit.unref();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
void args.provider
|
|
899
|
+
.closeSession(providerSession.sessionId)
|
|
900
|
+
.catch(() => {})
|
|
901
|
+
.finally(() => {
|
|
902
|
+
process.exit(reason === "received SIGINT" ? 130 : 1);
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const onDisconnect = (): void => requestClose("parent command disconnected");
|
|
907
|
+
const onSigint = (): void => requestClose("received SIGINT");
|
|
908
|
+
const onSigterm = (): void => requestClose("received SIGTERM");
|
|
909
|
+
|
|
910
|
+
// These handlers only cover the pre-ready window. Once the daemon is ready,
|
|
911
|
+
// normal shutdown owns provider cleanup through BrowserDaemon.shutdown().
|
|
912
|
+
if (typeof process.send === "function") {
|
|
913
|
+
process.once("disconnect", onDisconnect);
|
|
914
|
+
}
|
|
915
|
+
process.once("SIGINT", onSigint);
|
|
916
|
+
process.once("SIGTERM", onSigterm);
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
dispose: () => {
|
|
920
|
+
disposed = true;
|
|
921
|
+
if (fallbackExit) clearTimeout(fallbackExit);
|
|
922
|
+
process.off("disconnect", onDisconnect);
|
|
923
|
+
process.off("SIGINT", onSigint);
|
|
924
|
+
process.off("SIGTERM", onSigterm);
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
901
929
|
// ── Main ───────────────────────────────────────────────────────────────
|
|
902
930
|
|
|
903
931
|
async function main(): Promise<void> {
|
|
@@ -999,7 +1027,9 @@ function reportStartupError(error: unknown): never {
|
|
|
999
1027
|
message: error.message,
|
|
1000
1028
|
});
|
|
1001
1029
|
}
|
|
1002
|
-
process.exit(
|
|
1030
|
+
process.exit(
|
|
1031
|
+
process.exitCode && process.exitCode !== 0 ? process.exitCode : 1,
|
|
1032
|
+
);
|
|
1003
1033
|
}
|
|
1004
1034
|
|
|
1005
1035
|
try {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as repl from "node:repl";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { format, formatWithOptions, type InspectOptions } from "node:util";
|
|
4
|
+
import { stripTypeScriptExecCode } from "../exec-compiler.js";
|
|
5
|
+
|
|
6
|
+
const PROMPT = "__LIBRETTO_EXEC_REPL_READY__";
|
|
7
|
+
const TOP_LEVEL_RETURN_HINT =
|
|
8
|
+
"Hint: top-level return isn't supported because exec is a REPL-style environment. Use the expression value instead, for example: await page.title()";
|
|
9
|
+
const NO_RESULT = Symbol("NO_RESULT");
|
|
10
|
+
|
|
11
|
+
type ReplOutput = {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DaemonExecReplResult = {
|
|
17
|
+
ok: true;
|
|
18
|
+
result: unknown;
|
|
19
|
+
output: ReplOutput;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DaemonExecReplFailure = {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: Error;
|
|
25
|
+
output: ReplOutput;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DaemonExecReplResponse = DaemonExecReplResult | DaemonExecReplFailure;
|
|
29
|
+
|
|
30
|
+
type ActiveEval = {
|
|
31
|
+
output: string;
|
|
32
|
+
consoleOutput: ReplOutput;
|
|
33
|
+
resolve: (value: DaemonExecReplResponse) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function getErrorMessage(error: unknown): string {
|
|
37
|
+
if (error instanceof Error) return error.message;
|
|
38
|
+
if (
|
|
39
|
+
typeof error === "object" &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
"message" in error &&
|
|
42
|
+
typeof error.message === "string"
|
|
43
|
+
) {
|
|
44
|
+
return error.message;
|
|
45
|
+
}
|
|
46
|
+
return String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isTopLevelReturnError(error: unknown): boolean {
|
|
50
|
+
const message = getErrorMessage(error);
|
|
51
|
+
return (
|
|
52
|
+
message.includes("Illegal return statement") ||
|
|
53
|
+
message.includes("Return statement is not allowed here")
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isErrorLike(value: unknown): boolean {
|
|
58
|
+
return (
|
|
59
|
+
value instanceof Error ||
|
|
60
|
+
(typeof value === "object" &&
|
|
61
|
+
value !== null &&
|
|
62
|
+
"name" in value &&
|
|
63
|
+
"message" in value &&
|
|
64
|
+
typeof value.name === "string" &&
|
|
65
|
+
typeof value.message === "string")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toError(value: unknown): Error {
|
|
70
|
+
return value instanceof Error ? value : new Error(getErrorMessage(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appendTopLevelReturnHint(error: unknown): Error {
|
|
74
|
+
const message = getErrorMessage(error);
|
|
75
|
+
if (message.includes(TOP_LEVEL_RETURN_HINT)) {
|
|
76
|
+
return error instanceof Error ? error : new Error(message);
|
|
77
|
+
}
|
|
78
|
+
return new SyntaxError(`${message}\n\n${TOP_LEVEL_RETURN_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createBufferedConsole(): { console: Console; output: ReplOutput } {
|
|
82
|
+
const output: ReplOutput = { stdout: "", stderr: "" };
|
|
83
|
+
const writeStdout = (...args: unknown[]) => {
|
|
84
|
+
output.stdout += `${format(...args)}\n`;
|
|
85
|
+
};
|
|
86
|
+
const writeStderr = (...args: unknown[]) => {
|
|
87
|
+
output.stderr += `${format(...args)}\n`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const bufferedConsole = {
|
|
91
|
+
...globalThis.console,
|
|
92
|
+
log: writeStdout,
|
|
93
|
+
info: writeStdout,
|
|
94
|
+
debug: writeStdout,
|
|
95
|
+
dir: (value?: unknown, options?: InspectOptions) => {
|
|
96
|
+
output.stdout += `${formatWithOptions(options ?? {}, value)}\n`;
|
|
97
|
+
},
|
|
98
|
+
warn: writeStderr,
|
|
99
|
+
error: writeStderr,
|
|
100
|
+
} satisfies Console;
|
|
101
|
+
|
|
102
|
+
return { console: bufferedConsole, output };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class DaemonExecRepl {
|
|
106
|
+
private readonly replServer: repl.REPLServer;
|
|
107
|
+
private readonly input = new PassThrough();
|
|
108
|
+
private readonly output = new PassThrough();
|
|
109
|
+
private readyResolve: (() => void) | undefined;
|
|
110
|
+
private readonly ready: Promise<void>;
|
|
111
|
+
private activeEval: ActiveEval | undefined;
|
|
112
|
+
private lastResult: unknown = NO_RESULT;
|
|
113
|
+
|
|
114
|
+
constructor(globals: Record<string, unknown>) {
|
|
115
|
+
this.ready = new Promise((resolve) => {
|
|
116
|
+
this.readyResolve = resolve;
|
|
117
|
+
});
|
|
118
|
+
this.output.on("data", (chunk: Buffer | string) => {
|
|
119
|
+
this.handleOutput(String(chunk));
|
|
120
|
+
});
|
|
121
|
+
this.replServer = repl.start({
|
|
122
|
+
prompt: PROMPT,
|
|
123
|
+
input: this.input,
|
|
124
|
+
output: this.output,
|
|
125
|
+
terminal: true,
|
|
126
|
+
useGlobal: false,
|
|
127
|
+
writer: (value: unknown) => {
|
|
128
|
+
this.lastResult = value;
|
|
129
|
+
return "";
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
Object.assign(this.replServer.context, globals);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async run(
|
|
136
|
+
code: string,
|
|
137
|
+
globals: Record<string, unknown>,
|
|
138
|
+
): Promise<DaemonExecReplResponse> {
|
|
139
|
+
Object.assign(this.replServer.context, globals);
|
|
140
|
+
|
|
141
|
+
let jsCode: string;
|
|
142
|
+
try {
|
|
143
|
+
jsCode = stripTypeScriptExecCode(code);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: isTopLevelReturnError(error)
|
|
148
|
+
? appendTopLevelReturnHint(error)
|
|
149
|
+
: toError(error),
|
|
150
|
+
output: { stdout: "", stderr: "" },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.ready;
|
|
155
|
+
const buffered = createBufferedConsole();
|
|
156
|
+
Object.assign(this.replServer.context, { console: buffered.console });
|
|
157
|
+
return await new Promise<DaemonExecReplResponse>((resolve) => {
|
|
158
|
+
this.activeEval = { output: "", consoleOutput: buffered.output, resolve };
|
|
159
|
+
this.lastResult = NO_RESULT;
|
|
160
|
+
this.input.write(`.editor\n${jsCode}\n\x04`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private handleOutput(chunk: string): void {
|
|
165
|
+
const active = this.activeEval;
|
|
166
|
+
if (!active) {
|
|
167
|
+
if (chunk.includes(PROMPT)) {
|
|
168
|
+
this.readyResolve?.();
|
|
169
|
+
this.readyResolve = undefined;
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
active.output += chunk;
|
|
175
|
+
if (!active.output.includes(PROMPT)) return;
|
|
176
|
+
|
|
177
|
+
this.activeEval = undefined;
|
|
178
|
+
const result = this.lastResult === NO_RESULT ? undefined : this.lastResult;
|
|
179
|
+
const output = active.consoleOutput;
|
|
180
|
+
if (isErrorLike(result)) {
|
|
181
|
+
const error = isTopLevelReturnError(result)
|
|
182
|
+
? appendTopLevelReturnHint(result)
|
|
183
|
+
: toError(result);
|
|
184
|
+
active.resolve({ ok: false, error, output });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
active.resolve({ ok: true, result, output });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
2
|
import { format, formatWithOptions, type InspectOptions } from "node:util";
|
|
3
3
|
import { installInstrumentation } from "../../../shared/instrumentation/index.js";
|
|
4
4
|
import { compileExecFunction } from "../exec-compiler.js";
|
|
5
5
|
import { createReadonlyExecHelpers } from "../readonly-exec.js";
|
|
6
|
-
import {
|
|
6
|
+
import type { DaemonExecRepl } from "./exec-repl.js";
|
|
7
7
|
|
|
8
8
|
type ExecOutput = {
|
|
9
9
|
stdout: string;
|
|
@@ -52,58 +52,23 @@ function createBufferedConsole(): { console: Console; output: ExecOutput } {
|
|
|
52
52
|
export async function handleExec(
|
|
53
53
|
targetPage: Page,
|
|
54
54
|
code: string,
|
|
55
|
-
|
|
56
|
-
browser: Browser,
|
|
57
|
-
execState: Record<string, unknown>,
|
|
58
|
-
session: string,
|
|
55
|
+
execRepl: DaemonExecRepl,
|
|
59
56
|
visualize?: boolean,
|
|
60
57
|
): Promise<ExecResponse> {
|
|
61
|
-
const buffered = createBufferedConsole();
|
|
62
|
-
|
|
63
58
|
if (visualize) {
|
|
64
59
|
await installInstrumentation(targetPage, { visualize: true });
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
const networkLog = (
|
|
68
|
-
opts: {
|
|
69
|
-
last?: number;
|
|
70
|
-
filter?: string;
|
|
71
|
-
method?: string;
|
|
72
|
-
pageId?: string;
|
|
73
|
-
} = {},
|
|
74
|
-
) => readNetworkLog(session, opts);
|
|
75
|
-
|
|
76
|
-
const actionLog = (
|
|
77
|
-
opts: {
|
|
78
|
-
last?: number;
|
|
79
|
-
filter?: string;
|
|
80
|
-
action?: string;
|
|
81
|
-
source?: string;
|
|
82
|
-
pageId?: string;
|
|
83
|
-
} = {},
|
|
84
|
-
) => readActionLog(session, opts);
|
|
85
|
-
|
|
86
62
|
const helpers = {
|
|
87
63
|
page: targetPage,
|
|
88
|
-
|
|
89
|
-
browser,
|
|
90
|
-
state: execState,
|
|
91
|
-
console: buffered.console,
|
|
92
|
-
networkLog,
|
|
93
|
-
actionLog,
|
|
64
|
+
frame: targetPage.mainFrame(),
|
|
94
65
|
};
|
|
95
66
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const result = await fn(...Object.values(helpers));
|
|
100
|
-
return { result, output: buffered.output };
|
|
101
|
-
} catch (error) {
|
|
102
|
-
throw new DaemonExecError(
|
|
103
|
-
error instanceof Error ? error.message : String(error),
|
|
104
|
-
buffered.output,
|
|
105
|
-
);
|
|
67
|
+
const result = await execRepl.run(code, helpers);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
throw new DaemonExecError(result.error.message, result.output);
|
|
106
70
|
}
|
|
71
|
+
return { result: result.result, output: result.output };
|
|
107
72
|
}
|
|
108
73
|
|
|
109
74
|
export async function handleReadonlyExec(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { getDaemonSocketPath } from "./ipc.js";
|
|
3
|
+
|
|
4
|
+
describe("daemon IPC endpoint paths", () => {
|
|
5
|
+
test("uses a Windows named pipe path on Windows", () => {
|
|
6
|
+
const socketPath = getDaemonSocketPath("windows-session", "win32");
|
|
7
|
+
|
|
8
|
+
expect(socketPath).toMatch(/^\\\\\.\\pipe\\libretto-[a-f0-9]{12}$/);
|
|
9
|
+
expect(socketPath).not.toContain("/tmp/");
|
|
10
|
+
expect(socketPath).not.toContain(".sock");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("uses a short Unix socket path on Unix-like platforms", () => {
|
|
14
|
+
const socketPath = getDaemonSocketPath("unix-session", "linux");
|
|
15
|
+
|
|
16
|
+
expect(socketPath).toMatch(/^\/tmp\/libretto-.+-[a-f0-9]{12}\.sock$/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("keeps daemon IPC endpoints deterministic per session", () => {
|
|
20
|
+
const firstPath = getDaemonSocketPath("stable-session", "linux");
|
|
21
|
+
const secondPath = getDaemonSocketPath("stable-session", "linux");
|
|
22
|
+
const otherPath = getDaemonSocketPath("other-session", "linux");
|
|
23
|
+
|
|
24
|
+
expect(secondPath).toBe(firstPath);
|
|
25
|
+
expect(otherPath).not.toBe(firstPath);
|
|
26
|
+
});
|
|
27
|
+
});
|