libretto 0.5.5 → 0.6.0
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 +23 -10
- package/README.template.md +23 -10
- package/dist/cli/cli.js +10 -0
- package/dist/cli/commands/ai.js +77 -2
- package/dist/cli/commands/browser.js +98 -8
- package/dist/cli/commands/execution.js +152 -56
- package/dist/cli/commands/setup.js +390 -0
- package/dist/cli/commands/snapshot.js +2 -2
- package/dist/cli/commands/status.js +62 -0
- package/dist/cli/core/{snapshot-api-config.js → ai-model.js} +81 -7
- package/dist/cli/core/api-snapshot-analyzer.js +7 -5
- package/dist/cli/core/browser.js +202 -36
- package/dist/cli/core/{ai-config.js → config.js} +14 -79
- package/dist/cli/core/context.js +1 -25
- package/dist/cli/core/deploy-artifact.js +121 -61
- package/dist/cli/core/providers/browserbase.js +53 -0
- package/dist/cli/core/providers/index.js +48 -0
- package/dist/cli/core/providers/kernel.js +46 -0
- package/dist/cli/core/providers/libretto-cloud.js +58 -0
- package/dist/cli/core/readonly-exec.js +231 -0
- package/dist/{shared/llm/client.js → cli/core/resolve-model.js} +4 -68
- package/dist/cli/core/session.js +53 -0
- package/dist/cli/core/skill-version.js +73 -0
- package/dist/cli/core/telemetry.js +1 -54
- package/dist/cli/index.js +1 -7
- package/dist/cli/router.js +4 -4
- package/dist/cli/workers/run-integration-runtime.js +19 -13
- package/dist/cli/workers/run-integration-worker-protocol.js +5 -2
- package/dist/index.d.ts +2 -4
- package/dist/index.js +2 -2
- package/dist/runtime/extract/extract.d.ts +2 -2
- package/dist/runtime/extract/extract.js +4 -2
- package/dist/runtime/extract/index.d.ts +1 -1
- package/dist/runtime/recovery/agent.d.ts +2 -3
- package/dist/runtime/recovery/agent.js +5 -3
- package/dist/runtime/recovery/errors.d.ts +2 -3
- package/dist/runtime/recovery/errors.js +4 -2
- package/dist/runtime/recovery/index.d.ts +1 -2
- package/dist/runtime/recovery/recovery.d.ts +2 -3
- package/dist/runtime/recovery/recovery.js +3 -3
- package/dist/shared/debug/pause.js +4 -21
- package/dist/shared/run/api.d.ts +2 -0
- package/dist/shared/run/browser.d.ts +9 -1
- package/dist/shared/run/browser.js +43 -3
- package/dist/shared/state/index.d.ts +1 -1
- package/dist/shared/state/index.js +2 -0
- package/dist/shared/state/session-state.d.ts +20 -1
- package/dist/shared/state/session-state.js +12 -2
- package/dist/shared/workflow/workflow.d.ts +2 -1
- package/dist/shared/workflow/workflow.js +16 -9
- package/package.json +17 -16
- package/scripts/postinstall.mjs +13 -11
- package/scripts/skills-libretto.mjs +14 -4
- package/skills/AGENTS.md +11 -0
- package/skills/libretto/SKILL.md +30 -9
- package/skills/libretto/references/auth-profiles.md +1 -1
- package/skills/libretto/references/code-generation-rules.md +3 -3
- package/skills/libretto/references/configuration-file-reference.md +11 -6
- package/skills/libretto-readonly/SKILL.md +95 -0
- package/src/cli/cli.ts +10 -0
- package/src/cli/commands/ai.ts +111 -1
- package/src/cli/commands/browser.ts +111 -9
- package/src/cli/commands/execution.ts +181 -74
- package/src/cli/commands/setup.ts +516 -0
- package/src/cli/commands/snapshot.ts +2 -2
- package/src/cli/commands/status.ts +79 -0
- package/src/cli/core/{snapshot-api-config.ts → ai-model.ts} +154 -14
- package/src/cli/core/api-snapshot-analyzer.ts +7 -5
- package/src/cli/core/browser.ts +242 -35
- package/src/cli/core/{ai-config.ts → config.ts} +14 -108
- package/src/cli/core/context.ts +1 -45
- package/src/cli/core/deploy-artifact.ts +141 -71
- package/src/cli/core/providers/browserbase.ts +57 -0
- package/src/cli/core/providers/index.ts +62 -0
- package/src/cli/core/providers/kernel.ts +49 -0
- package/src/cli/core/providers/libretto-cloud.ts +61 -0
- package/src/cli/core/providers/types.ts +9 -0
- package/src/cli/core/readonly-exec.ts +284 -0
- package/src/{shared/llm/client.ts → cli/core/resolve-model.ts} +3 -85
- package/src/cli/core/session.ts +75 -2
- package/src/cli/core/skill-version.ts +93 -0
- package/src/cli/core/telemetry.ts +0 -52
- package/src/cli/index.ts +0 -6
- package/src/cli/router.ts +4 -4
- package/src/cli/workers/run-integration-runtime.ts +18 -16
- package/src/cli/workers/run-integration-worker-protocol.ts +4 -1
- package/src/index.ts +1 -7
- package/src/runtime/extract/extract.ts +6 -5
- package/src/runtime/recovery/agent.ts +5 -4
- package/src/runtime/recovery/errors.ts +4 -3
- package/src/runtime/recovery/recovery.ts +4 -4
- package/src/shared/debug/pause.ts +4 -23
- package/src/shared/run/browser.ts +50 -1
- package/src/shared/state/index.ts +2 -0
- package/src/shared/state/session-state.ts +10 -0
- package/src/shared/workflow/workflow.ts +24 -13
- package/dist/cli/commands/init.js +0 -286
- package/dist/cli/commands/logs.js +0 -117
- package/dist/shared/llm/ai-sdk-adapter.d.ts +0 -22
- package/dist/shared/llm/ai-sdk-adapter.js +0 -49
- package/dist/shared/llm/client.d.ts +0 -13
- package/dist/shared/llm/index.d.ts +0 -5
- package/dist/shared/llm/index.js +0 -6
- package/dist/shared/llm/types.d.ts +0 -67
- package/src/cli/commands/init.ts +0 -331
- package/src/cli/commands/logs.ts +0 -128
- package/src/shared/llm/ai-sdk-adapter.ts +0 -81
- package/src/shared/llm/index.ts +0 -3
- package/src/shared/llm/types.ts +0 -63
- /package/dist/{shared/llm → cli/core/providers}/types.js +0 -0
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { type AiConfig, readAiConfig } from "./
|
|
3
|
+
import { type AiConfig, readAiConfig } from "./config.js";
|
|
4
4
|
import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
|
|
5
5
|
import {
|
|
6
6
|
hasProviderCredentials,
|
|
7
7
|
parseModel,
|
|
8
8
|
type Provider,
|
|
9
|
-
} from "
|
|
9
|
+
} from "./resolve-model.js";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// ── Default models ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SNAPSHOT_MODELS = {
|
|
12
14
|
openai: "openai/gpt-5.4",
|
|
13
15
|
anthropic: "anthropic/claude-sonnet-4-6",
|
|
14
16
|
google: "google/gemini-3-flash-preview",
|
|
15
|
-
vertex: "vertex/gemini-2.5-
|
|
17
|
+
vertex: "vertex/gemini-2.5-flash",
|
|
16
18
|
} as const satisfies Record<Provider, string>;
|
|
17
19
|
|
|
20
|
+
// ── Source detection ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detect which specific env var provides credentials for a provider.
|
|
24
|
+
* Returns the env var name (e.g. "OPENAI_API_KEY", "GEMINI_API_KEY"),
|
|
25
|
+
* or null if no credential is found.
|
|
26
|
+
*/
|
|
27
|
+
function detectProviderEnvVar(
|
|
28
|
+
provider: Provider,
|
|
29
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
30
|
+
): string | null {
|
|
31
|
+
switch (provider) {
|
|
32
|
+
case "openai":
|
|
33
|
+
return env.OPENAI_API_KEY?.trim() ? "OPENAI_API_KEY" : null;
|
|
34
|
+
case "anthropic":
|
|
35
|
+
return env.ANTHROPIC_API_KEY?.trim() ? "ANTHROPIC_API_KEY" : null;
|
|
36
|
+
case "google":
|
|
37
|
+
if (env.GEMINI_API_KEY?.trim()) return "GEMINI_API_KEY";
|
|
38
|
+
if (env.GOOGLE_GENERATIVE_AI_API_KEY?.trim())
|
|
39
|
+
return "GOOGLE_GENERATIVE_AI_API_KEY";
|
|
40
|
+
return null;
|
|
41
|
+
case "vertex":
|
|
42
|
+
if (env.GOOGLE_CLOUD_PROJECT?.trim()) return "GOOGLE_CLOUD_PROJECT";
|
|
43
|
+
if (env.GCLOUD_PROJECT?.trim()) return "GCLOUD_PROJECT";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Snapshot model resolution ───────────────────────────────────────────────
|
|
49
|
+
|
|
18
50
|
export type SnapshotApiModelSelection = {
|
|
19
51
|
model: string;
|
|
20
52
|
provider: Provider;
|
|
21
|
-
source:
|
|
22
|
-
| "config"
|
|
23
|
-
| "env:auto-openai"
|
|
24
|
-
| "env:auto-anthropic"
|
|
25
|
-
| "env:auto-google"
|
|
26
|
-
| "env:auto-vertex";
|
|
53
|
+
source: "config" | `env:${string}`;
|
|
27
54
|
};
|
|
28
55
|
|
|
29
56
|
export class SnapshotApiUnavailableError extends Error {
|
|
@@ -67,7 +94,7 @@ function noSnapshotApiConfiguredMessage(): string {
|
|
|
67
94
|
return [
|
|
68
95
|
"Failed to analyze snapshot because no snapshot analyzer is configured.",
|
|
69
96
|
`Add OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT to .env or as a shell environment variable, or choose a default model with \`${defaultModelCommandLine()}\`.`,
|
|
70
|
-
"For more info, run `npx libretto
|
|
97
|
+
"For more info, run `npx libretto setup`.",
|
|
71
98
|
].join(" ");
|
|
72
99
|
}
|
|
73
100
|
|
|
@@ -81,10 +108,12 @@ function missingProviderSnapshotMessage(
|
|
|
81
108
|
return [
|
|
82
109
|
`Failed to analyze snapshot because ${selection.provider} is configured${configuredSource}, but ${providerMissingCredentialSummary(selection.provider)}.`,
|
|
83
110
|
providerSetupSentence(selection.provider),
|
|
84
|
-
"For more info, run `npx libretto
|
|
111
|
+
"For more info, run `npx libretto setup`.",
|
|
85
112
|
].join(" ");
|
|
86
113
|
}
|
|
87
114
|
|
|
115
|
+
// ── Dotenv loading ──────────────────────────────────────────────────────────
|
|
116
|
+
|
|
88
117
|
function readWorktreeEnvPath(): string | null {
|
|
89
118
|
const gitPath = join(REPO_ROOT, ".git");
|
|
90
119
|
if (!existsSync(gitPath)) return null;
|
|
@@ -165,6 +194,8 @@ export function parseDotEnvAssignment(
|
|
|
165
194
|
return { key, value };
|
|
166
195
|
}
|
|
167
196
|
|
|
197
|
+
// ── Model resolution ────────────────────────────────────────────────────────
|
|
198
|
+
|
|
168
199
|
function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
|
|
169
200
|
const providersInPriorityOrder: Provider[] = [
|
|
170
201
|
"openai",
|
|
@@ -174,11 +205,12 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
|
|
|
174
205
|
];
|
|
175
206
|
|
|
176
207
|
for (const provider of providersInPriorityOrder) {
|
|
177
|
-
|
|
208
|
+
const envVar = detectProviderEnvVar(provider);
|
|
209
|
+
if (!envVar) continue;
|
|
178
210
|
return {
|
|
179
211
|
model: DEFAULT_SNAPSHOT_MODELS[provider],
|
|
180
212
|
provider,
|
|
181
|
-
source: `env
|
|
213
|
+
source: `env:${envVar}`,
|
|
182
214
|
};
|
|
183
215
|
}
|
|
184
216
|
|
|
@@ -229,3 +261,111 @@ export function resolveSnapshotApiModelOrThrow(
|
|
|
229
261
|
export function isSnapshotApiUnavailableError(error: unknown): boolean {
|
|
230
262
|
return error instanceof SnapshotApiUnavailableError;
|
|
231
263
|
}
|
|
264
|
+
|
|
265
|
+
// ── AI setup status ─────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Workspace AI setup health states.
|
|
269
|
+
*
|
|
270
|
+
* - `ready`: a usable model was resolved and the matching provider has credentials.
|
|
271
|
+
* - `configured-missing-credentials`: config pins a provider whose credentials are absent.
|
|
272
|
+
* - `invalid-config`: `.libretto/config.json` exists but fails schema validation.
|
|
273
|
+
* - `unconfigured`: no config and no env credentials detected.
|
|
274
|
+
*/
|
|
275
|
+
export type AiSetupStatus =
|
|
276
|
+
| {
|
|
277
|
+
kind: "ready";
|
|
278
|
+
model: string;
|
|
279
|
+
provider: Provider;
|
|
280
|
+
source: "config" | `env:${string}`;
|
|
281
|
+
}
|
|
282
|
+
| {
|
|
283
|
+
kind: "configured-missing-credentials";
|
|
284
|
+
model: string;
|
|
285
|
+
provider: Provider;
|
|
286
|
+
}
|
|
287
|
+
| { kind: "invalid-config"; message: string }
|
|
288
|
+
| { kind: "unconfigured" };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read AI config without throwing on invalid files.
|
|
292
|
+
* Returns the config or an error message.
|
|
293
|
+
*/
|
|
294
|
+
function readAiConfigSafely(
|
|
295
|
+
configPath: string,
|
|
296
|
+
): { ok: true; config: AiConfig | null } | { ok: false; message: string } {
|
|
297
|
+
try {
|
|
298
|
+
return { ok: true, config: readAiConfig(configPath) };
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
message: err instanceof Error ? err.message : String(err),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolve the workspace's current AI setup health.
|
|
309
|
+
*
|
|
310
|
+
* Uses the existing config reader and snapshot model resolver, but wraps
|
|
311
|
+
* them to distinguish broken states (invalid config, missing credentials)
|
|
312
|
+
* that the throwing APIs collapse into errors.
|
|
313
|
+
*
|
|
314
|
+
* 1. If config read throws → `invalid-config`.
|
|
315
|
+
* 2. If config has an `ai` block → check credentials for that provider.
|
|
316
|
+
* 3. If no config or no `ai` block → auto-detect from env via existing resolver.
|
|
317
|
+
*/
|
|
318
|
+
export function resolveAiSetupStatus(
|
|
319
|
+
configPath: string = LIBRETTO_CONFIG_PATH,
|
|
320
|
+
): AiSetupStatus {
|
|
321
|
+
loadSnapshotEnv();
|
|
322
|
+
|
|
323
|
+
const configResult = readAiConfigSafely(configPath);
|
|
324
|
+
|
|
325
|
+
if (!configResult.ok) {
|
|
326
|
+
return { kind: "invalid-config", message: configResult.message };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Config exists with an ai block — use it directly to check credentials
|
|
330
|
+
if (configResult.config) {
|
|
331
|
+
let selection: SnapshotApiModelSelection | null;
|
|
332
|
+
try {
|
|
333
|
+
selection = resolveSnapshotApiModel(configResult.config);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
return {
|
|
336
|
+
kind: "invalid-config",
|
|
337
|
+
message: err instanceof Error ? err.message : String(err),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (!selection) {
|
|
341
|
+
// Should not happen when config has a model, but handle gracefully
|
|
342
|
+
return { kind: "unconfigured" };
|
|
343
|
+
}
|
|
344
|
+
if (hasProviderCredentials(selection.provider)) {
|
|
345
|
+
return {
|
|
346
|
+
kind: "ready",
|
|
347
|
+
model: selection.model,
|
|
348
|
+
provider: selection.provider,
|
|
349
|
+
source: selection.source,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
kind: "configured-missing-credentials",
|
|
354
|
+
model: selection.model,
|
|
355
|
+
provider: selection.provider,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// No ai config — fall back to env auto-detect via existing resolver
|
|
360
|
+
const envSelection = resolveSnapshotApiModel(null);
|
|
361
|
+
if (envSelection && hasProviderCredentials(envSelection.provider)) {
|
|
362
|
+
return {
|
|
363
|
+
kind: "ready",
|
|
364
|
+
model: envSelection.model,
|
|
365
|
+
provider: envSelection.provider,
|
|
366
|
+
source: envSelection.source,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { kind: "unconfigured" };
|
|
371
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import { generateObject } from "ai";
|
|
12
|
+
import { resolveModel } from "./resolve-model.js";
|
|
12
13
|
import {
|
|
13
14
|
InterpretResultSchema,
|
|
14
15
|
buildInlinePromptSelection,
|
|
@@ -17,8 +18,8 @@ import {
|
|
|
17
18
|
type InterpretResult,
|
|
18
19
|
type InterpretArgs,
|
|
19
20
|
} from "./snapshot-analyzer.js";
|
|
20
|
-
import { readAiConfig, type AiConfig } from "./
|
|
21
|
-
import { resolveSnapshotApiModelOrThrow } from "./
|
|
21
|
+
import { readAiConfig, type AiConfig } from "./config.js";
|
|
22
|
+
import { resolveSnapshotApiModelOrThrow } from "./ai-model.js";
|
|
22
23
|
|
|
23
24
|
export async function runApiInterpret(
|
|
24
25
|
args: InterpretArgs,
|
|
@@ -64,9 +65,10 @@ export async function runApiInterpret(
|
|
|
64
65
|
const imageMimeType = getMimeType(args.pngPath);
|
|
65
66
|
const imageBytes = Buffer.from(imageBase64, "base64");
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const model = await resolveModel(selection.model);
|
|
68
69
|
|
|
69
|
-
const result = await
|
|
70
|
+
const { object: result } = await generateObject({
|
|
71
|
+
model,
|
|
70
72
|
schema: InterpretResultSchema,
|
|
71
73
|
messages: [
|
|
72
74
|
{
|
package/src/cli/core/browser.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { createRequire } from "node:module";
|
|
|
12
12
|
import { createServer } from "node:net";
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
14
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
15
|
+
import type { SessionAccessMode } from "../../shared/state/index.js";
|
|
15
16
|
import {
|
|
16
17
|
filterSemanticClasses,
|
|
17
18
|
INTERACTIVE_ROLE_NAMES,
|
|
@@ -25,16 +26,19 @@ import {
|
|
|
25
26
|
getSessionNetworkLogPath,
|
|
26
27
|
PROFILES_DIR,
|
|
27
28
|
} from "./context.js";
|
|
28
|
-
import { readLibrettoConfig } from "./
|
|
29
|
+
import { readLibrettoConfig } from "./config.js";
|
|
29
30
|
import {
|
|
30
31
|
assertSessionAvailableForStart,
|
|
31
32
|
clearSessionState,
|
|
33
|
+
isPidRunning,
|
|
32
34
|
listSessionsWithStateFile,
|
|
33
35
|
readSessionStateOrThrow,
|
|
34
36
|
logFileForSession,
|
|
35
37
|
readSessionState,
|
|
36
38
|
writeSessionState,
|
|
37
39
|
} from "./session.js";
|
|
40
|
+
import type { ProviderApi } from "./providers/types.js";
|
|
41
|
+
import { getCloudProviderApi } from "./providers/index.js";
|
|
38
42
|
import { installSessionTelemetry } from "./session-telemetry.js";
|
|
39
43
|
|
|
40
44
|
const CLOSE_WAIT_MS = 1_500;
|
|
@@ -256,6 +260,16 @@ export async function connect(
|
|
|
256
260
|
endpoint,
|
|
257
261
|
pid: state.pid,
|
|
258
262
|
});
|
|
263
|
+
// Provider sessions have no local PID to check liveness.
|
|
264
|
+
// Don't destroy the remote session on a transient failure —
|
|
265
|
+
// let the user retry or explicitly close.
|
|
266
|
+
if (state.provider) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. ` +
|
|
269
|
+
`The remote session may still be active. Try again, or close with: libretto close --session ${session}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
259
273
|
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
260
274
|
clearSessionState(session, logger);
|
|
261
275
|
throw new Error(
|
|
@@ -398,13 +412,24 @@ export async function runOpen(
|
|
|
398
412
|
headed: boolean,
|
|
399
413
|
session: string,
|
|
400
414
|
logger: LoggerApi,
|
|
401
|
-
options?: {
|
|
415
|
+
options?: {
|
|
416
|
+
viewport?: { width: number; height: number };
|
|
417
|
+
accessMode?: SessionAccessMode;
|
|
418
|
+
},
|
|
402
419
|
): Promise<void> {
|
|
403
420
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
404
421
|
const url = parsedUrl.href;
|
|
405
422
|
const viewport = resolveViewport(options?.viewport, logger);
|
|
423
|
+
const accessMode = options?.accessMode ?? "write-access";
|
|
406
424
|
const windowPosition = headed ? resolveWindowPosition(logger) : undefined;
|
|
407
|
-
logger.info("open-start", {
|
|
425
|
+
logger.info("open-start", {
|
|
426
|
+
url,
|
|
427
|
+
headed,
|
|
428
|
+
session,
|
|
429
|
+
viewport,
|
|
430
|
+
windowPosition,
|
|
431
|
+
accessMode,
|
|
432
|
+
});
|
|
408
433
|
assertSessionAvailableForStart(session, logger);
|
|
409
434
|
|
|
410
435
|
const port = await pickFreePort();
|
|
@@ -674,6 +699,7 @@ await new Promise(() => {});
|
|
|
674
699
|
session,
|
|
675
700
|
startedAt: new Date().toISOString(),
|
|
676
701
|
status: "active",
|
|
702
|
+
mode: accessMode,
|
|
677
703
|
viewport,
|
|
678
704
|
},
|
|
679
705
|
logger,
|
|
@@ -703,6 +729,106 @@ await new Promise(() => {});
|
|
|
703
729
|
);
|
|
704
730
|
}
|
|
705
731
|
|
|
732
|
+
export async function runOpenWithProvider(
|
|
733
|
+
rawUrl: string,
|
|
734
|
+
providerName: string,
|
|
735
|
+
provider: ProviderApi,
|
|
736
|
+
session: string,
|
|
737
|
+
logger: LoggerApi,
|
|
738
|
+
accessMode: SessionAccessMode = "write-access",
|
|
739
|
+
): Promise<void> {
|
|
740
|
+
const parsedUrl = normalizeUrl(rawUrl);
|
|
741
|
+
const url = parsedUrl.href;
|
|
742
|
+
logger.info("open-provider-start", { url, provider: providerName, session });
|
|
743
|
+
|
|
744
|
+
console.log(
|
|
745
|
+
`Creating ${providerName} browser session (session: ${session})...`,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const providerSession = await provider.createSession();
|
|
749
|
+
logger.info("open-provider-session-created", {
|
|
750
|
+
provider: providerName,
|
|
751
|
+
sessionId: providerSession.sessionId,
|
|
752
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
console.log(`Connecting to ${providerName} browser...`);
|
|
756
|
+
|
|
757
|
+
let browser: Browser | null = null;
|
|
758
|
+
try {
|
|
759
|
+
browser = await tryConnectToCDP(
|
|
760
|
+
providerSession.cdpEndpoint,
|
|
761
|
+
logger,
|
|
762
|
+
30_000,
|
|
763
|
+
);
|
|
764
|
+
if (!browser) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const contexts = browser.contexts();
|
|
771
|
+
let page: Page;
|
|
772
|
+
if (contexts.length > 0 && contexts[0].pages().length > 0) {
|
|
773
|
+
page = contexts[0].pages()[0];
|
|
774
|
+
} else {
|
|
775
|
+
const context =
|
|
776
|
+
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
777
|
+
page = await context.newPage();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await page.goto(url);
|
|
781
|
+
logger.info("open-provider-navigated", { url, session });
|
|
782
|
+
|
|
783
|
+
// Cloud sessions have no local port. Reconnection uses cdpEndpoint directly.
|
|
784
|
+
writeSessionState(
|
|
785
|
+
{
|
|
786
|
+
port: 0,
|
|
787
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
788
|
+
session,
|
|
789
|
+
startedAt: new Date().toISOString(),
|
|
790
|
+
status: "active",
|
|
791
|
+
mode: accessMode,
|
|
792
|
+
provider: {
|
|
793
|
+
name: providerName,
|
|
794
|
+
sessionId: providerSession.sessionId,
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
logger,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
disconnectBrowser(browser, logger, session);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
if (browser) {
|
|
803
|
+
disconnectBrowser(browser, logger, session);
|
|
804
|
+
}
|
|
805
|
+
// Clean up the remote session so it doesn't leak
|
|
806
|
+
logger.warn("open-provider-cleanup-after-error", {
|
|
807
|
+
provider: providerName,
|
|
808
|
+
sessionId: providerSession.sessionId,
|
|
809
|
+
error: err,
|
|
810
|
+
});
|
|
811
|
+
try {
|
|
812
|
+
await provider.closeSession(providerSession.sessionId);
|
|
813
|
+
} catch (cleanupErr) {
|
|
814
|
+
logger.warn("open-provider-cleanup-failed", {
|
|
815
|
+
provider: providerName,
|
|
816
|
+
sessionId: providerSession.sessionId,
|
|
817
|
+
error: cleanupErr,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
throw err;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
logger.info("open-provider-success", {
|
|
824
|
+
url,
|
|
825
|
+
provider: providerName,
|
|
826
|
+
session,
|
|
827
|
+
sessionId: providerSession.sessionId,
|
|
828
|
+
});
|
|
829
|
+
console.log(`Browser open (${providerName}): ${url}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
706
832
|
export async function runSave(
|
|
707
833
|
urlOrDomain: string,
|
|
708
834
|
session: string,
|
|
@@ -797,11 +923,37 @@ export async function runClose(
|
|
|
797
923
|
return;
|
|
798
924
|
}
|
|
799
925
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
926
|
+
if (state.provider) {
|
|
927
|
+
// Cloud provider session — close via provider API, no local pid to kill
|
|
928
|
+
logger.info("close-provider", {
|
|
929
|
+
session,
|
|
930
|
+
provider: state.provider.name,
|
|
931
|
+
sessionId: state.provider.sessionId,
|
|
932
|
+
});
|
|
933
|
+
try {
|
|
934
|
+
const provider = getCloudProviderApi(state.provider.name);
|
|
935
|
+
await provider.closeSession(state.provider.sessionId);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
logger.warn("close-provider-error", {
|
|
938
|
+
session,
|
|
939
|
+
provider: state.provider.name,
|
|
940
|
+
sessionId: state.provider.sessionId,
|
|
941
|
+
error: err,
|
|
942
|
+
});
|
|
943
|
+
// Preserve state with cleanup-failed status so the user can retry.
|
|
944
|
+
// The provider.sessionId is retained for manual or future cleanup.
|
|
945
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
|
|
948
|
+
`State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
953
|
+
if (state.pid != null) {
|
|
954
|
+
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
955
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
956
|
+
}
|
|
805
957
|
}
|
|
806
958
|
|
|
807
959
|
clearSessionState(session, logger);
|
|
@@ -813,21 +965,13 @@ type ClosableSession = {
|
|
|
813
965
|
session: string;
|
|
814
966
|
pid?: number;
|
|
815
967
|
port: number;
|
|
968
|
+
provider?: { name: string; sessionId: string };
|
|
816
969
|
};
|
|
817
970
|
|
|
818
971
|
function waitForCloseSignalWindow(ms: number): Promise<void> {
|
|
819
972
|
return new Promise((r) => setTimeout(r, ms));
|
|
820
973
|
}
|
|
821
974
|
|
|
822
|
-
function isPidRunning(pid: number): boolean {
|
|
823
|
-
try {
|
|
824
|
-
process.kill(pid, 0);
|
|
825
|
-
return true;
|
|
826
|
-
} catch {
|
|
827
|
-
return false;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
975
|
function sendSignalToProcessGroupOrPid(
|
|
832
976
|
pid: number,
|
|
833
977
|
signal: NodeJS.Signals,
|
|
@@ -874,6 +1018,7 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
874
1018
|
session,
|
|
875
1019
|
pid: state.pid,
|
|
876
1020
|
port: state.port,
|
|
1021
|
+
provider: state.provider,
|
|
877
1022
|
});
|
|
878
1023
|
}
|
|
879
1024
|
|
|
@@ -883,9 +1028,11 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
883
1028
|
function clearStoppedSessionStates(
|
|
884
1029
|
sessions: ReadonlyArray<ClosableSession>,
|
|
885
1030
|
logger: LoggerApi,
|
|
1031
|
+
skip?: ReadonlySet<string>,
|
|
886
1032
|
): number {
|
|
887
1033
|
let cleared = 0;
|
|
888
1034
|
for (const session of sessions) {
|
|
1035
|
+
if (skip?.has(session.session)) continue;
|
|
889
1036
|
if (session.pid == null || !isPidRunning(session.pid)) {
|
|
890
1037
|
clearSessionState(session.session, logger);
|
|
891
1038
|
cleared += 1;
|
|
@@ -911,7 +1058,38 @@ export async function runCloseAll(
|
|
|
911
1058
|
return;
|
|
912
1059
|
}
|
|
913
1060
|
|
|
1061
|
+
// Close provider sessions via their APIs
|
|
1062
|
+
const failedProviderSessions = new Set<string>();
|
|
1063
|
+
for (const target of closable) {
|
|
1064
|
+
if (target.provider) {
|
|
1065
|
+
logger.info("close-all-provider", {
|
|
1066
|
+
session: target.session,
|
|
1067
|
+
provider: target.provider.name,
|
|
1068
|
+
sessionId: target.provider.sessionId,
|
|
1069
|
+
});
|
|
1070
|
+
try {
|
|
1071
|
+
const provider = getCloudProviderApi(target.provider.name);
|
|
1072
|
+
await provider.closeSession(target.provider.sessionId);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
logger.warn("close-all-provider-error", {
|
|
1075
|
+
session: target.session,
|
|
1076
|
+
provider: target.provider.name,
|
|
1077
|
+
sessionId: target.provider.sessionId,
|
|
1078
|
+
error: err,
|
|
1079
|
+
});
|
|
1080
|
+
failedProviderSessions.add(target.session);
|
|
1081
|
+
// Mark as cleanup-failed, preserving provider.sessionId for retry
|
|
1082
|
+
const state = readSessionState(target.session, logger);
|
|
1083
|
+
if (state) {
|
|
1084
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Send SIGTERM to local sessions
|
|
914
1091
|
for (const target of closable) {
|
|
1092
|
+
if (target.provider) continue; // already handled above
|
|
915
1093
|
logger.info("close-all-sigterm", {
|
|
916
1094
|
session: target.session,
|
|
917
1095
|
pid: target.pid,
|
|
@@ -933,7 +1111,11 @@ export async function runCloseAll(
|
|
|
933
1111
|
(target) => target.pid != null && isPidRunning(target.pid),
|
|
934
1112
|
);
|
|
935
1113
|
if (survivors.length > 0 && !force) {
|
|
936
|
-
const closed = clearStoppedSessionStates(
|
|
1114
|
+
const closed = clearStoppedSessionStates(
|
|
1115
|
+
closable,
|
|
1116
|
+
logger,
|
|
1117
|
+
failedProviderSessions,
|
|
1118
|
+
);
|
|
937
1119
|
|
|
938
1120
|
throw new Error(
|
|
939
1121
|
[
|
|
@@ -966,7 +1148,11 @@ export async function runCloseAll(
|
|
|
966
1148
|
(target) => target.pid != null && isPidRunning(target.pid),
|
|
967
1149
|
);
|
|
968
1150
|
if (survivors.length > 0) {
|
|
969
|
-
const closed = clearStoppedSessionStates(
|
|
1151
|
+
const closed = clearStoppedSessionStates(
|
|
1152
|
+
closable,
|
|
1153
|
+
logger,
|
|
1154
|
+
failedProviderSessions,
|
|
1155
|
+
);
|
|
970
1156
|
throw new Error(
|
|
971
1157
|
[
|
|
972
1158
|
`Failed to force-close ${survivors.length} session(s): ${formatSessionList(survivors)}.`,
|
|
@@ -976,14 +1162,21 @@ export async function runCloseAll(
|
|
|
976
1162
|
}
|
|
977
1163
|
}
|
|
978
1164
|
|
|
979
|
-
clearStoppedSessionStates(closable, logger);
|
|
1165
|
+
clearStoppedSessionStates(closable, logger, failedProviderSessions);
|
|
1166
|
+
|
|
1167
|
+
if (failedProviderSessions.size > 0) {
|
|
1168
|
+
console.log(
|
|
1169
|
+
`Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
980
1172
|
|
|
981
1173
|
if (clearedUnreadableStates > 0) {
|
|
982
1174
|
console.log(
|
|
983
1175
|
`Cleared ${clearedUnreadableStates} unreadable session state file(s).`,
|
|
984
1176
|
);
|
|
985
1177
|
}
|
|
986
|
-
|
|
1178
|
+
const closedCount = closable.length - failedProviderSessions.size;
|
|
1179
|
+
console.log(`Closed ${closedCount} session(s).`);
|
|
987
1180
|
if (forceKilled > 0) {
|
|
988
1181
|
console.log(`Force-killed ${forceKilled} session(s).`);
|
|
989
1182
|
}
|
|
@@ -993,8 +1186,9 @@ export async function runConnect(
|
|
|
993
1186
|
cdpUrl: string,
|
|
994
1187
|
session: string,
|
|
995
1188
|
logger: LoggerApi,
|
|
1189
|
+
accessMode: SessionAccessMode = "write-access",
|
|
996
1190
|
): Promise<void> {
|
|
997
|
-
logger.info("connect-start", { cdpUrl, session });
|
|
1191
|
+
logger.info("connect-start", { cdpUrl, session, accessMode });
|
|
998
1192
|
assertSessionAvailableForStart(session, logger);
|
|
999
1193
|
|
|
1000
1194
|
let parsedUrl: URL;
|
|
@@ -1005,18 +1199,22 @@ export async function runConnect(
|
|
|
1005
1199
|
[
|
|
1006
1200
|
`Invalid CDP URL: ${cdpUrl}`,
|
|
1007
1201
|
``,
|
|
1008
|
-
`Expected an HTTP URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
|
|
1202
|
+
`Expected an HTTP or WebSocket URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
|
|
1009
1203
|
` libretto connect http://127.0.0.1:9222`,
|
|
1010
1204
|
` libretto connect http://remote-host:9222`,
|
|
1011
1205
|
` libretto connect http://remote-host:9222/devtools/browser/<id>`,
|
|
1206
|
+
` libretto connect ws://remote-host:9222/devtools/browser/<id>`,
|
|
1207
|
+
` libretto connect wss://remote-host/cdp-endpoint`,
|
|
1012
1208
|
].join("\n"),
|
|
1013
1209
|
);
|
|
1014
1210
|
}
|
|
1015
1211
|
|
|
1016
1212
|
const endpoint = parsedUrl.href;
|
|
1213
|
+
const isWebSocket =
|
|
1214
|
+
parsedUrl.protocol === "ws:" || parsedUrl.protocol === "wss:";
|
|
1017
1215
|
const port = parsedUrl.port
|
|
1018
1216
|
? Number(parsedUrl.port)
|
|
1019
|
-
: parsedUrl.protocol === "https:"
|
|
1217
|
+
: parsedUrl.protocol === "https:" || parsedUrl.protocol === "wss:"
|
|
1020
1218
|
? 443
|
|
1021
1219
|
: 80;
|
|
1022
1220
|
|
|
@@ -1024,17 +1222,25 @@ export async function runConnect(
|
|
|
1024
1222
|
`Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
|
|
1025
1223
|
);
|
|
1026
1224
|
|
|
1027
|
-
// Verify the CDP endpoint is reachable
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1225
|
+
// Verify the CDP endpoint is reachable (HTTP only — WebSocket
|
|
1226
|
+
// endpoints are validated by the Playwright connect call below).
|
|
1227
|
+
if (!isWebSocket) {
|
|
1228
|
+
const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
|
|
1229
|
+
try {
|
|
1230
|
+
const resp = await fetch(versionUrl);
|
|
1231
|
+
const versionInfo = await resp.json();
|
|
1232
|
+
logger.info("connect-version-ok", { versionUrl, versionInfo });
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
logger.error("connect-version-failed", { versionUrl, error: err });
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
`Cannot reach CDP endpoint at ${versionUrl}. Make sure the target is running and accessible at ${parsedUrl.host}.`,
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
} else {
|
|
1240
|
+
logger.info("connect-skip-version-check", {
|
|
1241
|
+
reason: "WebSocket-only endpoint, skipping HTTP version check",
|
|
1242
|
+
endpoint,
|
|
1243
|
+
});
|
|
1038
1244
|
}
|
|
1039
1245
|
|
|
1040
1246
|
// Connect via CDP using the full endpoint URL
|
|
@@ -1061,6 +1267,7 @@ export async function runConnect(
|
|
|
1061
1267
|
session,
|
|
1062
1268
|
startedAt: new Date().toISOString(),
|
|
1063
1269
|
status: "active",
|
|
1270
|
+
mode: accessMode,
|
|
1064
1271
|
},
|
|
1065
1272
|
logger,
|
|
1066
1273
|
);
|