@botbuddy/cli 1.8.6 → 1.8.7
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/package.json +1 -1
- package/src/pw/daemon.mjs +53 -4
- package/src/pw/run.mjs +15 -0
- package/src/botbuddy-release-repair.json +0 -1
package/package.json
CHANGED
package/src/pw/daemon.mjs
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { normaliseLane } from "./args.mjs";
|
|
7
8
|
export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
|
|
8
9
|
export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
|
|
9
10
|
export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
// BOT-1522: playwright-cli keeps one sub-dir per invocation context under the
|
|
12
|
+
// daemon dir, and only ever cleans up its OWN sub-dir's `<session>.session`. A
|
|
13
|
+
// lane opened from another worktree whose daemon has since exited leaves a file
|
|
14
|
+
// pointing at a socket that no longer exists — and it can sort before today's
|
|
15
|
+
// live one. Selection is therefore by liveness, never by readdir order, and the
|
|
16
|
+
// caller gets a remediation naming the lane and the command, never a raw syscall.
|
|
17
|
+
export const laneOf = (session) => normaliseLane(session ?? "");
|
|
18
|
+
// Any connect-phase failure means the daemon is not there to answer; name the
|
|
19
|
+
// shape in prose so the syscall code never reaches the operator (AC-3/AC-6).
|
|
20
|
+
const CONNECT_FAILURE = { ENOENT: "its socket is gone", ECONNREFUSED: "its socket refused the connection", ENOTSOCK: "its socket refused the connection", ECONNRESET: "the connection dropped", EPIPE: "the connection dropped" };
|
|
21
|
+
const connectFailure = (error) => (error?.code ? CONNECT_FAILURE[error.code] ?? "the connection failed" : null);
|
|
22
|
+
export function notRunningMessage(session, ignored = [], detail = null) {
|
|
23
|
+
const why = ignored.length ? `ignored stale session file(s): ${ignored.map((entry) => `${entry.file} [${entry.reason}]`).join(", ")}` : detail ?? "no session file found";
|
|
24
|
+
return `bb-pw: ${session} daemon is not running (${why}). Run \`bb-pw ${laneOf(session) || "<lane>"} open <url>\` first, then retry.`;
|
|
25
|
+
}
|
|
26
|
+
// Every `<root>/<sub>/<session>.session`, newest first. A file whose socket path
|
|
27
|
+
// is ABSENT on disk is provably dead (the daemon's tmp dir is gone) and is
|
|
28
|
+
// pruned; one whose socket EXISTS is kept — the daemon may be starting — and
|
|
29
|
+
// liveness is decided by connecting (resolveSession), not by the file. Corrupt
|
|
30
|
+
// JSON is left alone: it is not provably dead. Only this session's file is read,
|
|
31
|
+
// so another lane's live daemon in the same sub-dir is never touched.
|
|
32
|
+
export function scanSessions(session, env = process.env, { prune = true } = {}) {
|
|
33
|
+
const root = daemonDir(env), candidates = [], ignored = [];
|
|
34
|
+
if (!existsSync(root)) return { candidates, ignored };
|
|
35
|
+
for (const item of readdirSync(root)) {
|
|
36
|
+
const file = join(root, item, `${session}.session`);
|
|
37
|
+
let data; try { data = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
|
|
38
|
+
if (!data?.socketPath) { ignored.push({ file, reason: "no socket path" }); continue; }
|
|
39
|
+
if (!existsSync(data.socketPath)) { ignored.push({ file, reason: "socket missing" }); if (prune) { try { unlinkSync(file); } catch {} } continue; }
|
|
40
|
+
let mtimeMs = 0; try { mtimeMs = statSync(file).mtimeMs; } catch {}
|
|
41
|
+
candidates.push({ file, data, mtimeMs });
|
|
42
|
+
}
|
|
43
|
+
// Newest first; a deterministic path tiebreak so an mtime tie never falls back to readdir order.
|
|
44
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
|
|
45
|
+
return { candidates, ignored };
|
|
46
|
+
}
|
|
47
|
+
// Synchronous, back-compatible, READ-ONLY reader (reap.mjs): the newest session
|
|
48
|
+
// whose socket path exists on disk. Pruning belongs to the socket path (socketRun).
|
|
49
|
+
export function readSession(session, env = process.env) { return scanSessions(session, env, { prune: false }).candidates[0]?.data ?? null; }
|
|
50
|
+
export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000, session = null } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => { const why = session ? connectFailure(error) : null; finish({ ok: false, error: why ? notRunningMessage(session, [], why) : error.message }); }); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
|
|
12
51
|
export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
|
|
13
|
-
|
|
52
|
+
// Authoritative, async: the newest candidate whose socket actually ANSWERS.
|
|
53
|
+
export async function resolveSession(session, env = process.env, { alive = socketAlive } = {}) {
|
|
54
|
+
const { candidates, ignored } = scanSessions(session, env);
|
|
55
|
+
for (const candidate of candidates) { if (await alive(candidate.data.socketPath)) return { session: candidate.data, ignored }; ignored.push({ file: candidate.file, reason: "did not answer" }); }
|
|
56
|
+
return { session: null, ignored };
|
|
57
|
+
}
|
|
58
|
+
export async function socketRun(plan, env = process.env, { alive, send = sendToDaemon } = {}) {
|
|
59
|
+
const { session, ignored } = await resolveSession(plan.session, env, { alive });
|
|
60
|
+
if (!session) return { ok: false, error: notRunningMessage(plan.session, ignored) };
|
|
61
|
+
return send(session.socketPath, plan.socketArgs, { session: plan.session });
|
|
62
|
+
}
|
package/src/pw/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { canonicalizeHostString } from "./host.mjs";
|
|
|
7
7
|
import { resolveAgentProfile } from "../wait-profile.mjs";
|
|
8
8
|
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
9
9
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
10
|
+
import { VERSION } from "../version.mjs";
|
|
10
11
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
11
12
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
12
13
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
@@ -68,8 +69,22 @@ async function gate({ env, host, lane, deps }) {
|
|
|
68
69
|
return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
// BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
|
|
73
|
+
// pre-BOT-1488 refusal and was indistinguishable from a code regression. Every
|
|
74
|
+
// non-zero exit now names the bundled version, and --version exists at all, so
|
|
75
|
+
// an agent can tell "old binary" from "real bug" without reading the source.
|
|
76
|
+
const versionLine = () => `bb-pw (@botbuddy/cli v${VERSION})`;
|
|
71
77
|
export async function runPw(argv, deps = {}) {
|
|
78
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
79
|
+
const code = await runPwInner(argv, deps);
|
|
80
|
+
// Neutral: a refusal may be correct policy, not staleness — the line only lets
|
|
81
|
+
// the reader compare this binary against cli/package.json in their checkout.
|
|
82
|
+
if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
|
|
83
|
+
return code;
|
|
84
|
+
}
|
|
85
|
+
async function runPwInner(argv, deps = {}) {
|
|
72
86
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
87
|
+
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
73
88
|
while (args[0] === "--profile" || args[0] === "--session-id") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
|
|
74
89
|
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
75
90
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.8.3","source_identity":"8304ea6fbec329d54d6361cb6cbf5a6789331b0851d09caf151f0663ade18ec2"}
|