a2a-exposed 0.8.26 → 0.8.28
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/lib/config.mjs +8 -0
- package/lib/deploy.mjs +77 -10
- package/lib/feedback.mjs +2 -1
- package/lib/layers.mjs +205 -0
- package/lib/link-finish.mjs +178 -0
- package/lib/local-link.mjs +101 -0
- package/lib/main.mjs +34 -10
- package/lib/script.mjs +56 -0
- package/lib/status.mjs +281 -62
- package/lib/tunnel.mjs +2 -2
- package/lib/workersdev.mjs +12 -5
- package/package.json +1 -1
- package/worker/src/index.ts +1 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +7 -3
- package/worker/test/pairing.test.ts +37 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Shared local setup operations. No shell, token arguments, or child output.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { CliError } from "./a2a.mjs";
|
|
8
|
+
|
|
9
|
+
export function writeTokenFile(file, token) {
|
|
10
|
+
const dir = path.dirname(file);
|
|
11
|
+
let tmp, fd;
|
|
12
|
+
try {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
tmp = path.join(dir, `.a2a-token-${crypto.randomBytes(16).toString("hex")}`);
|
|
15
|
+
fd = fs.openSync(tmp, "wx", 0o600);
|
|
16
|
+
fs.fchmodSync(fd, 0o600);
|
|
17
|
+
fs.writeFileSync(fd, token + "\n");
|
|
18
|
+
fs.fsyncSync(fd);
|
|
19
|
+
fs.closeSync(fd); fd = undefined;
|
|
20
|
+
fs.renameSync(tmp, file);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new CliError("could not save the connector token; fix the destination permissions and retry setup; hosted finish needs fresh approval");
|
|
23
|
+
} finally {
|
|
24
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
25
|
+
if (tmp && fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPrivate(file) {
|
|
30
|
+
let fd;
|
|
31
|
+
try {
|
|
32
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
33
|
+
const stat = fs.fstatSync(fd);
|
|
34
|
+
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.size > 1024 * 1024)
|
|
35
|
+
throw new Error();
|
|
36
|
+
return fs.readFileSync(fd, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
throw new CliError("secret source must be a readable regular file with mode 600 (no symlinks, maximum 1 MiB)");
|
|
39
|
+
} finally {
|
|
40
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usableSecret(value) {
|
|
45
|
+
if (typeof value !== "string" || !value.trim() || value === "INSECURE_NO_AUTH" || Buffer.byteLength(value) > 65536)
|
|
46
|
+
throw new CliError("the selected source has no usable signing secret");
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateSecretSource(o) {
|
|
51
|
+
if (["secret-file", "secret-env", "secret-from"].filter((key) => o[key] !== undefined).length !== 1)
|
|
52
|
+
throw new CliError("choose exactly one of --secret-file, --secret-env, or --secret-from hermes");
|
|
53
|
+
if (o["secret-from"] !== undefined && o["secret-from"] !== "hermes")
|
|
54
|
+
throw new CliError("the only secret adapter is hermes");
|
|
55
|
+
if ((o["hermes-home"] || o.route) && o["secret-from"] !== "hermes")
|
|
56
|
+
throw new CliError("--hermes-home and --route require --secret-from hermes");
|
|
57
|
+
if (o["secret-env"] !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(o["secret-env"]))
|
|
58
|
+
throw new CliError("--secret-env must name an environment variable");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readSigningSecret(o, link, env = process.env) {
|
|
62
|
+
validateSecretSource(o);
|
|
63
|
+
if (o["secret-file"] !== undefined) return usableSecret(readPrivate(o["secret-file"]).replace(/\r?\n$/, ""));
|
|
64
|
+
if (o["secret-env"] !== undefined) return usableSecret(env[o["secret-env"]]);
|
|
65
|
+
if (link.preset !== "hermes") throw new CliError("the Hermes adapter requires the hermes preset");
|
|
66
|
+
const match = /^\/webhooks\/([A-Za-z0-9_-]+)$/.exec(link.path);
|
|
67
|
+
if (!match || (o.route && o.route !== match[1]))
|
|
68
|
+
throw new CliError("Hermes route does not match the approved link path; select the exact route and Hermes home");
|
|
69
|
+
const home = o["hermes-home"] || env.HERMES_HOME || path.join(os.homedir(), ".hermes");
|
|
70
|
+
// Static YAML routes override dynamic subscriptions. Without a YAML dependency,
|
|
71
|
+
// fail closed on any routes key; never guess which secret the gateway uses.
|
|
72
|
+
try {
|
|
73
|
+
const config = fs.readFileSync(path.join(home, "config.yaml"), "utf8");
|
|
74
|
+
if (/\broutes\b/.test(config)) throw new CliError("Hermes static routes may override this subscription; use --secret-file or --secret-env for the exact route secret");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof CliError) throw error;
|
|
77
|
+
if (error.code !== "ENOENT") throw new CliError("could not check Hermes static route configuration");
|
|
78
|
+
}
|
|
79
|
+
let subscriptions;
|
|
80
|
+
try { subscriptions = JSON.parse(readPrivate(path.join(home, "webhook_subscriptions.json")).replace(/^\uFEFF/, "")); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof CliError) throw error;
|
|
83
|
+
throw new CliError("could not parse the Hermes subscriptions file");
|
|
84
|
+
}
|
|
85
|
+
const route = Object.hasOwn(subscriptions || {}, match[1]) ? subscriptions[match[1]] : null;
|
|
86
|
+
if (!route || route.enabled === false) throw new CliError("the selected Hermes subscription is missing or disabled");
|
|
87
|
+
return usableSecret(route.secret);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const connectorArgs = (file) => ["tunnel", "run", "--token-file", file];
|
|
91
|
+
export const connectorCommand = (file) => "cloudflared " + connectorArgs(file).map((s) => "'" + s.replaceAll("'", "'\\''") + "'").join(" ");
|
|
92
|
+
|
|
93
|
+
export async function startConnector(file, spawnImpl = spawn) {
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = spawnImpl("cloudflared", connectorArgs(file), { detached: true, stdio: "ignore", env: Object.fromEntries(Object.entries(process.env).filter(([key]) => ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "WINDIR"].includes(key))) });
|
|
97
|
+
await new Promise((resolve, reject) => { child.once("error", reject); child.once("spawn", resolve); });
|
|
98
|
+
child.unref();
|
|
99
|
+
return child;
|
|
100
|
+
} catch { throw new CliError("could not start cloudflared; install cloudflared and run finish again"); }
|
|
101
|
+
}
|
package/lib/main.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import * as account from "./account.mjs";
|
|
|
12
12
|
import * as upd from "./update.mjs";
|
|
13
13
|
import * as fb from "./feedback.mjs";
|
|
14
14
|
import * as un from "./uninstall.mjs";
|
|
15
|
+
import { finishLink, createLink } from "./link-finish.mjs";
|
|
15
16
|
|
|
16
17
|
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
17
18
|
|
|
@@ -22,10 +23,12 @@ Usage: npx -y a2a-exposed@latest <command> [options]
|
|
|
22
23
|
--version, -v print the version
|
|
23
24
|
--help, -h print this help
|
|
24
25
|
|
|
26
|
+
Setup options: --skip-install skips dependency installation when dependencies already exist.
|
|
27
|
+
|
|
25
28
|
Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
|
|
26
29
|
npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
|
|
27
30
|
CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
|
|
28
|
-
(https://a2a.exposed/pricing). When to use it
|
|
31
|
+
(https://a2a.exposed/pricing). When to use it:
|
|
29
32
|
https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
|
|
30
33
|
init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
|
|
31
34
|
[--agent-name N] [--agent-description D] [--agent-skills JSON]
|
|
@@ -41,14 +44,18 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
41
44
|
--workers-dev move an existing custom-domain deployment to workers.dev (and
|
|
42
45
|
--hostname H moves it back); the old URL answers 301/410
|
|
43
46
|
--workers-dev-subdomain create the account's workers.dev subdomain if missing
|
|
47
|
+
Uses system script on macOS/Linux, including under automation.
|
|
44
48
|
--cf-profile cf auth profile for a separate Cloudflare login
|
|
45
49
|
(npx cf auth create NAME --no-browser); saved, used by every cf call.
|
|
46
50
|
When whoami lists no accounts, init reads cf accounts list
|
|
51
|
+
outside the Worker directory. A failed list prints cf's error unless --account-id is set
|
|
47
52
|
--d1-name D1 database to create or reuse (default: worker name)
|
|
48
53
|
--pairing-approval who approves device-flow pairing requests (default human:
|
|
49
54
|
the owner token via \`pair approve\`, or the approval password
|
|
50
55
|
on the /device page; agent: the agent runs \`pair approve\` after
|
|
51
56
|
the human says yes; off: no pairing endpoints, \`token issue\` only)
|
|
57
|
+
init reports readiness when password approval is enabled; when missing, use
|
|
58
|
+
\`pair set-password --web\` for the human-owned setup page.
|
|
52
59
|
--pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
|
|
53
60
|
the Workers maximum; lower only if /device hits error 1102)
|
|
54
61
|
--workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
|
|
@@ -156,6 +163,16 @@ Status
|
|
|
156
163
|
\`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
|
|
157
164
|
A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
|
|
158
165
|
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
|
|
166
|
+
Prints eight layers in order, each PASS, FAIL, or SKIP, and still checks the
|
|
167
|
+
later layers after one fails: Worker DNS (system lookup, then DNS-over-HTTPS
|
|
168
|
+
when that lookup fails), Worker (TLS, the agent card, and /health), Owner API,
|
|
169
|
+
Wake DNS, Access (protected or not, and whether a service token is configured;
|
|
170
|
+
the token is never printed), Connector, Local webhook (HTTP status of the wake
|
|
171
|
+
endpoint), and Pairing. A 401 or 403 fails the local webhook layer only when the
|
|
172
|
+
response shows Cloudflare Access. The webhook's own auth refusal reports the HTTP
|
|
173
|
+
status and passes, as does a 401 or 403 with no Access marker. A failure names the
|
|
174
|
+
missing or mismatched object and a fix. --json includes layers (eight objects).
|
|
175
|
+
Exit 1 if any layer fails.
|
|
159
176
|
For about a minute after tunnel create, while the connector reports 0 connections,
|
|
160
177
|
status prints "waiting for the connector to connect" and exits 0. After that,
|
|
161
178
|
0 connections is broken. A tunnel status of inactive is "never connected".
|
|
@@ -260,7 +277,7 @@ Owner
|
|
|
260
277
|
the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
|
|
261
278
|
|
|
262
279
|
Feedback
|
|
263
|
-
feedback "message" [--kind bug|idea|other] [--contact x]
|
|
280
|
+
feedback "message" [--json] [--kind bug|idea|other] [--contact x]
|
|
264
281
|
[--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
|
|
265
282
|
[--environment local|cloud VM|CI|cloud agent sandbox|other]
|
|
266
283
|
[--connection webhook|proxy|polling] [--yes]
|
|
@@ -272,9 +289,9 @@ Feedback
|
|
|
272
289
|
confidential information: secrets, tokens, owner or pairing
|
|
273
290
|
tokens, private URLs or hostnames, message contents, or
|
|
274
291
|
personal data. Prints that warning and asks before sending,
|
|
275
|
-
unless --yes is set. Off a terminal, --yes
|
|
292
|
+
unless --yes is set. Off a terminal, posting requires --yes.
|
|
276
293
|
Posts when this A2A endpoint names a feedback URL.
|
|
277
|
-
Otherwise prints a link to the bug
|
|
294
|
+
Otherwise prints Not filed and a link to the bug
|
|
278
295
|
report form and does not open a browser. The CLI adds its
|
|
279
296
|
version, the A2A endpoint Worker version when the A2A endpoint reports it,
|
|
280
297
|
the OS, the Node version, the connection (webhook, proxy, or
|
|
@@ -282,7 +299,7 @@ Feedback
|
|
|
282
299
|
that detection. Remove tokens from the message and from --logs.
|
|
283
300
|
|
|
284
301
|
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
285
|
-
|
|
302
|
+
Clients and how to load them:
|
|
286
303
|
https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
|
|
287
304
|
Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
|
|
288
305
|
Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
|
|
@@ -328,6 +345,7 @@ const deployOpts = {
|
|
|
328
345
|
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
|
|
329
346
|
};
|
|
330
347
|
const SPEC = {
|
|
348
|
+
hosted: { agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
|
|
331
349
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
332
350
|
inbox: { context: S, from: S, all: B, json: B },
|
|
333
351
|
reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
|
|
@@ -341,10 +359,11 @@ const SPEC = {
|
|
|
341
359
|
export: { sql: B },
|
|
342
360
|
upstream: { json: B },
|
|
343
361
|
connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
|
|
362
|
+
// Internal hosted-service integration commands. Keep out of public help until the production flow is available.
|
|
344
363
|
signup: { "control-url": S, json: B },
|
|
345
364
|
login: { "control-url": S, json: B },
|
|
346
365
|
pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
|
|
347
|
-
feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
|
|
366
|
+
feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B, json: B },
|
|
348
367
|
uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
|
|
349
368
|
};
|
|
350
369
|
const STATES = ["completed", "input-required", "failed", "rejected", "working"];
|
|
@@ -388,7 +407,7 @@ export async function main(argv) {
|
|
|
388
407
|
if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
389
408
|
const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
|
|
390
409
|
if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
|
|
391
|
-
throw new CliError(`unknown flag ${flag}. Run \`${CLI}
|
|
410
|
+
throw new CliError(`unknown flag ${flag}. Run \`${CLI} --help\` and see the ${name} section.`);
|
|
392
411
|
}
|
|
393
412
|
if (e && String(e.code || "").startsWith("ERR_PARSE_ARGS")) throw new CliError(String(e.message));
|
|
394
413
|
throw e;
|
|
@@ -449,6 +468,10 @@ export async function main(argv) {
|
|
|
449
468
|
if (sub === "forward") return address.forward(o);
|
|
450
469
|
throw new CliError(`unknown peer action ${sub} (announce|forward)`);
|
|
451
470
|
}
|
|
471
|
+
case "hosted": {
|
|
472
|
+
if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) throw new CliError("usage: hosted link create --agent NAME --hostname HOST --origin URL --path PATH --preset ID (--secret-file FILE | --secret-env VAR | --secret-from hermes); or hosted link finish --agent NAME (--secret-file FILE | --secret-env VAR | --secret-from hermes)");
|
|
473
|
+
return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
|
|
474
|
+
}
|
|
452
475
|
case "signup":
|
|
453
476
|
case "login": {
|
|
454
477
|
const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
|
|
@@ -469,21 +492,22 @@ export async function main(argv) {
|
|
|
469
492
|
case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
|
|
470
493
|
case "feedback": {
|
|
471
494
|
const message = p.join(" ").trim();
|
|
472
|
-
need(message, `feedback "message" [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
|
|
495
|
+
need(message, `feedback "message" [--json] [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
|
|
473
496
|
const kind = o.kind || "other";
|
|
474
497
|
if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
|
|
475
498
|
if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
|
|
476
499
|
if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
|
|
477
500
|
const repo = await fb.feedbackPublicRepo({ baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN") });
|
|
478
|
-
await fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo);
|
|
479
501
|
const result = await fb.runFeedback({
|
|
502
|
+
beforePost: () => fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo),
|
|
480
503
|
message, kind, contact: o.contact || "", version: VERSION,
|
|
481
504
|
steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
|
|
482
505
|
agent: o.agent || "", environment: o.environment || "",
|
|
483
506
|
connection: o.connection || "",
|
|
484
507
|
baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN"),
|
|
485
508
|
});
|
|
486
|
-
console.
|
|
509
|
+
if (!result.filed) console.error("Not filed: this A2A endpoint has no feedback inbox. Open this link to file it on GitHub:");
|
|
510
|
+
console.log(o.json ? JSON.stringify(result.filed ? { filed: true, issue: result.url } : result) : result.url);
|
|
487
511
|
return;
|
|
488
512
|
}
|
|
489
513
|
default: throw new CliError(`unknown command ${name} (see --help)`);
|
package/lib/script.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Node's "pipe" stdin is a socket on macOS. BSD script refuses its ioctl
|
|
2
|
+
// error, so relay stdin through a real shell pipe before allocating the PTY.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function spawnScript(args, options = {}) {
|
|
6
|
+
if (process.platform !== "darwin") return forwardShutdown(spawn("script", args, { ...options, detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
7
|
+
// All command arguments remain positional, never interpolated into shell code.
|
|
8
|
+
// fd 3 reports script completion so cat cannot keep the wrapper alive waiting
|
|
9
|
+
// for input. The terminal job cannot write that channel or inherit it.
|
|
10
|
+
const relay = '/bin/cat | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }';
|
|
11
|
+
const child = spawn("/bin/sh", ["-c", relay, "a2a-script", "/usr/bin/script", ...args], {
|
|
12
|
+
...options, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
child.stdio[3].once("data", () => child.stdin.end());
|
|
15
|
+
child.stdio[3].resume();
|
|
16
|
+
child.stdin.on("error", () => {}); // script can exit just before an answer.
|
|
17
|
+
return forwardShutdown(child);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stopScript(child, signal = "SIGTERM") {
|
|
21
|
+
if (!child.pid) return false;
|
|
22
|
+
try {
|
|
23
|
+
// Stop the relay and script together. Closing the PTY master also hangs
|
|
24
|
+
// up the terminal job rather than leaving it waiting for an answer.
|
|
25
|
+
process.kill(-child.pid, signal);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code === "ESRCH") return false;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Detached PTYs do not receive the CLI's terminal signals. Forward shutdown
|
|
34
|
+
// before allowing the parent's usual signal behavior, and release all hooks
|
|
35
|
+
// on spawn failure or completion so repeated commands do not retain children.
|
|
36
|
+
function forwardShutdown(child) {
|
|
37
|
+
const handlers = new Map();
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
|
40
|
+
process.removeListener("exit", onExit);
|
|
41
|
+
};
|
|
42
|
+
const onExit = () => stopScript(child);
|
|
43
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
44
|
+
const handler = () => {
|
|
45
|
+
stopScript(child, signal);
|
|
46
|
+
cleanup();
|
|
47
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
48
|
+
};
|
|
49
|
+
handlers.set(signal, handler);
|
|
50
|
+
process.on(signal, handler);
|
|
51
|
+
}
|
|
52
|
+
process.on("exit", onExit);
|
|
53
|
+
child.once("error", cleanup);
|
|
54
|
+
child.once("close", cleanup);
|
|
55
|
+
return child;
|
|
56
|
+
}
|