@join-pantheon/cli 0.33.0-rc.2 → 0.33.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/dist/board-peer.js +136 -0
- package/dist/board.js +30 -46
- package/dist/cli.js +40 -11
- package/dist/config.js +7 -1
- package/dist/home-safety.js +195 -0
- package/dist/machine.js +21 -52
- package/dist/service.js +5 -1
- package/dist/win-host.js +30 -2
- package/helper/macos/prebuilt/manifest.json +2 -2
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/windows/pantheon-notice.ps1 +44 -16
- package/installer/install.ps1 +130 -59
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { win32 } from "node:path";
|
|
4
|
+
const isPort = (n) => Number.isInteger(n) && n > 0 && n <= 65535;
|
|
5
|
+
export const lsofArgs = (clientPort) => ["-nP", "-w", `-iTCP@127.0.0.1:${clientPort}`, "-sTCP:ESTABLISHED", "-a", "-Fpun"];
|
|
6
|
+
export function parseLsofPeers(out, clientPort, boardPort) {
|
|
7
|
+
const want = `127.0.0.1:${clientPort}->127.0.0.1:${boardPort}`;
|
|
8
|
+
const found = [];
|
|
9
|
+
let pid = NaN, uid = NaN;
|
|
10
|
+
for (const line of out.split("\n")) {
|
|
11
|
+
const k = line[0], v = line.slice(1).trim();
|
|
12
|
+
if (k === "p") {
|
|
13
|
+
pid = Number(v);
|
|
14
|
+
uid = NaN;
|
|
15
|
+
}
|
|
16
|
+
else if (k === "u")
|
|
17
|
+
uid = Number(v);
|
|
18
|
+
else if (k === "n" && v === want && Number.isInteger(pid) && Number.isInteger(uid))
|
|
19
|
+
found.push({ pid, uid });
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
function runLsof(args) {
|
|
24
|
+
const exe = existsSync("/usr/sbin/lsof") ? "/usr/sbin/lsof" : "lsof";
|
|
25
|
+
return new Promise((done) => {
|
|
26
|
+
execFile(exe, args, { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }, (err, out) => {
|
|
27
|
+
if (err && typeof err.code !== "number") {
|
|
28
|
+
done(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
done(out ?? "");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const LSOF_AT_ONCE = 4;
|
|
36
|
+
let lsofRunning = 0;
|
|
37
|
+
const lsofWaiting = [];
|
|
38
|
+
async function lsofSlot(fn) {
|
|
39
|
+
while (lsofRunning >= LSOF_AT_ONCE)
|
|
40
|
+
await new Promise((r) => lsofWaiting.push(r));
|
|
41
|
+
lsofRunning++;
|
|
42
|
+
try {
|
|
43
|
+
return await fn();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
lsofRunning--;
|
|
47
|
+
lsofWaiting.shift()?.();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function macPeer(clientPort, boardPort, me, run = (a) => lsofSlot(() => runLsof(a))) {
|
|
51
|
+
if (me === null || !isPort(clientPort) || !isPort(boardPort))
|
|
52
|
+
return "unknown";
|
|
53
|
+
const out = await run(lsofArgs(clientPort)).catch(() => null);
|
|
54
|
+
if (out === null)
|
|
55
|
+
return "unknown";
|
|
56
|
+
const owners = parseLsofPeers(out, clientPort, boardPort);
|
|
57
|
+
if (!owners.length)
|
|
58
|
+
return "unknown";
|
|
59
|
+
return owners.every((o) => o.uid === me) ? "same" : "other";
|
|
60
|
+
}
|
|
61
|
+
export function windowsPeerScript(boardPort) {
|
|
62
|
+
if (!isPort(boardPort))
|
|
63
|
+
throw new Error("not a port");
|
|
64
|
+
return [
|
|
65
|
+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
|
|
66
|
+
"$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
|
67
|
+
"'ME ' + $me",
|
|
68
|
+
`foreach ($c in @(Get-NetTCPConnection -RemotePort ${boardPort} -State Established -ErrorAction SilentlyContinue)) {`,
|
|
69
|
+
" if (@('127.0.0.1', '::ffff:127.0.0.1') -notcontains $c.LocalAddress -or @('127.0.0.1', '::ffff:127.0.0.1') -notcontains $c.RemoteAddress) { continue }",
|
|
70
|
+
" $sid = ''",
|
|
71
|
+
" try { $p = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $c.OwningProcess); if ($p) { $o = Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid; if ($o.ReturnValue -eq 0) { $sid = [string]$o.Sid } } } catch { $sid = '' }",
|
|
72
|
+
" 'C ' + $c.LocalPort + ' ' + $c.OwningProcess + ' ' + $sid",
|
|
73
|
+
"}",
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
export function parseWindowsPeers(out) {
|
|
77
|
+
let me = null;
|
|
78
|
+
const ports = new Map();
|
|
79
|
+
for (const raw of out.split(/\r?\n/)) {
|
|
80
|
+
const line = raw.trim();
|
|
81
|
+
const m = /^ME (S-1-[0-9-]+)$/.exec(line);
|
|
82
|
+
if (m) {
|
|
83
|
+
me = m[1];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const c = /^C (\d{1,5}) (\d+)(?: (\S*))?$/.exec(line);
|
|
87
|
+
if (!c)
|
|
88
|
+
continue;
|
|
89
|
+
const sid = /^S-1-[0-9-]+$/.test(c[3] ?? "") ? c[3] : "";
|
|
90
|
+
ports.set(Number(c[1]), [...(ports.get(Number(c[1])) ?? []), sid]);
|
|
91
|
+
}
|
|
92
|
+
return { me, ports };
|
|
93
|
+
}
|
|
94
|
+
export function windowsVerdict(parsed, clientPort) {
|
|
95
|
+
const owners = parsed.ports.get(clientPort);
|
|
96
|
+
if (!parsed.me || !owners?.length)
|
|
97
|
+
return "unknown";
|
|
98
|
+
if (owners.every((s) => s === parsed.me))
|
|
99
|
+
return "same";
|
|
100
|
+
return owners.some((s) => s && s !== parsed.me) ? "other" : "unknown";
|
|
101
|
+
}
|
|
102
|
+
function runWindowsPowerShell(script) {
|
|
103
|
+
const exe = win32.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
104
|
+
return new Promise((done) => {
|
|
105
|
+
execFile(exe, ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 15_000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, out) => {
|
|
106
|
+
done(err && !out ? null : (out ?? null));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export function windowsPeerCheck(run = runWindowsPowerShell) {
|
|
111
|
+
const waiting = new Map();
|
|
112
|
+
const busy = new Set();
|
|
113
|
+
const go = (boardPort) => {
|
|
114
|
+
const batch = waiting.get(boardPort) ?? [];
|
|
115
|
+
waiting.delete(boardPort);
|
|
116
|
+
if (!batch.length)
|
|
117
|
+
return;
|
|
118
|
+
busy.add(boardPort);
|
|
119
|
+
run(windowsPeerScript(boardPort)).catch(() => null).then((out) => {
|
|
120
|
+
const parsed = out === null ? null : parseWindowsPeers(out);
|
|
121
|
+
for (const w of batch)
|
|
122
|
+
w.done(parsed ? windowsVerdict(parsed, w.port) : "unknown");
|
|
123
|
+
}).finally(() => { busy.delete(boardPort); go(boardPort); });
|
|
124
|
+
};
|
|
125
|
+
return (clientPort, boardPort) => new Promise((done) => {
|
|
126
|
+
if (!isPort(clientPort) || !isPort(boardPort)) {
|
|
127
|
+
done("unknown");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
waiting.set(boardPort, [...(waiting.get(boardPort) ?? []), { port: clientPort, done }]);
|
|
131
|
+
if (!busy.has(boardPort))
|
|
132
|
+
setImmediate(() => { if (!busy.has(boardPort))
|
|
133
|
+
go(boardPort); });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=board-peer.js.map
|
package/dist/board.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { chmodSync, mkdirSync,
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, platform } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { CONFIG_DIR } from "./config.js";
|
|
9
|
+
import { macPeer, windowsPeerCheck } from "./board-peer.js";
|
|
9
10
|
import { LIMITS } from "./protocol.js";
|
|
10
11
|
import { childNodeFlags } from "./managed.js";
|
|
11
12
|
import { which } from "./which.js";
|
|
@@ -61,9 +62,23 @@ export function defaultBoardDeps(home = CONFIG_DIR) {
|
|
|
61
62
|
actionFile: join(home, "bin", ".hud-action.json"),
|
|
62
63
|
openTerminal: (actionId) => openExplainTerminal(home, actionId),
|
|
63
64
|
now: Date.now,
|
|
64
|
-
|
|
65
|
+
peer: defaultPeerCheck(),
|
|
65
66
|
};
|
|
66
67
|
}
|
|
68
|
+
export function defaultPeerCheck(os = platform()) {
|
|
69
|
+
const me = typeof process.getuid === "function" ? process.getuid() : null;
|
|
70
|
+
if (os === "linux") {
|
|
71
|
+
return async (c, b) => {
|
|
72
|
+
const uid = loopbackPeerUid(c, b);
|
|
73
|
+
return uid === null || me === null ? "unknown" : uid === me ? "same" : "other";
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (os === "darwin")
|
|
77
|
+
return (c, b) => macPeer(c, b, me);
|
|
78
|
+
if (os === "win32")
|
|
79
|
+
return windowsPeerCheck();
|
|
80
|
+
return async () => "unknown";
|
|
81
|
+
}
|
|
67
82
|
export function loopbackPeerUid(clientPort, boardPort, read = (p) => readFileSync(p, "utf8"), os = platform()) {
|
|
68
83
|
if (os !== "linux")
|
|
69
84
|
return null;
|
|
@@ -290,15 +305,13 @@ export async function startBoardServer(o = {}) {
|
|
|
290
305
|
const known = peerChecked.get(sock);
|
|
291
306
|
if (known !== undefined)
|
|
292
307
|
return known;
|
|
293
|
-
const me = typeof process.getuid === "function" ? process.getuid() : null;
|
|
294
308
|
const addr = sock.remoteAddress ?? "";
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return ok;
|
|
309
|
+
const local = addr === "127.0.0.1" || addr === "::ffff:127.0.0.1";
|
|
310
|
+
const check = local && sock.remotePort
|
|
311
|
+
? deps.peer(sock.remotePort, port).then((v) => v === "same", () => false)
|
|
312
|
+
: Promise.resolve(false);
|
|
313
|
+
peerChecked.set(sock, check);
|
|
314
|
+
return check;
|
|
302
315
|
};
|
|
303
316
|
const status = async () => {
|
|
304
317
|
const s = await deps.status();
|
|
@@ -370,10 +383,6 @@ export async function startBoardServer(o = {}) {
|
|
|
370
383
|
sendJson(res, 403, { ok: false, error: "wrong host" });
|
|
371
384
|
return;
|
|
372
385
|
}
|
|
373
|
-
if (!samePerson(req)) {
|
|
374
|
-
sendJson(res, 403, { ok: false, error: "this board belongs to another user on this computer" });
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
386
|
const site = req.headers["sec-fetch-site"];
|
|
378
387
|
if (typeof site === "string" && site !== "same-origin" && site !== "none") {
|
|
379
388
|
sendJson(res, 403, { ok: false, error: "cross-site request" });
|
|
@@ -410,6 +419,10 @@ export async function startBoardServer(o = {}) {
|
|
|
410
419
|
if (method === "POST" && url.pathname === "/api/session") {
|
|
411
420
|
const nonce = bearerOf(req);
|
|
412
421
|
const body = strictObject(await readBody(req), []);
|
|
422
|
+
if (!(await samePerson(req))) {
|
|
423
|
+
sendJson(res, 403, { ok: false, error: "this board belongs to another user on this computer — or this computer could not show that this connection is yours" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
413
426
|
if (!nonce || !useLink(nonce)) {
|
|
414
427
|
sendJson(res, 401, { ok: false, error: "this board link has expired or was already used — run pantheon board for a fresh one" });
|
|
415
428
|
return;
|
|
@@ -565,7 +578,8 @@ export async function startBoardServer(o = {}) {
|
|
|
565
578
|
}
|
|
566
579
|
catch { } });
|
|
567
580
|
});
|
|
568
|
-
server.
|
|
581
|
+
server.keepAliveTimeout = 30_000;
|
|
582
|
+
server.headersTimeout = 35_000;
|
|
569
583
|
server.requestTimeout = 60_000;
|
|
570
584
|
await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", () => { server.off("error", fail); ok(); }); });
|
|
571
585
|
port = server.address().port;
|
|
@@ -620,31 +634,6 @@ export function browserCommand(url, os = platform(), find = (n) => which(n), o =
|
|
|
620
634
|
const scoped = o.scope ? scopeArgv([x, url], find) : null;
|
|
621
635
|
return scoped ? { cmd: scoped[0], args: scoped.slice(1), opts } : { cmd: x, args: [url], opts };
|
|
622
636
|
}
|
|
623
|
-
export function redirectPage(url, dir, now = Date.now()) {
|
|
624
|
-
if (!LINK_RE.test(url))
|
|
625
|
-
throw new Error("not a board link");
|
|
626
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
627
|
-
for (const f of readdirSync(dir)) {
|
|
628
|
-
if (!/^board-open-[0-9a-f]{16}\.html$/.test(f))
|
|
629
|
-
continue;
|
|
630
|
-
try {
|
|
631
|
-
if (now - statSync(join(dir, f)).mtimeMs > 60_000)
|
|
632
|
-
rmSync(join(dir, f), { force: true });
|
|
633
|
-
}
|
|
634
|
-
catch { }
|
|
635
|
-
}
|
|
636
|
-
const file = join(dir, `board-open-${randomBytes(8).toString("hex")}.html`);
|
|
637
|
-
writeFileSync(file, `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${url}"><title>Pantheon</title><script>location.replace(${JSON.stringify(url)})</script>\n`, { mode: 0o600 });
|
|
638
|
-
return file;
|
|
639
|
-
}
|
|
640
|
-
export function fileOpenCommand(file, os = platform()) {
|
|
641
|
-
const opts = { detached: true, stdio: "ignore", windowsHide: true };
|
|
642
|
-
if (os === "darwin")
|
|
643
|
-
return { cmd: "open", args: [file], opts };
|
|
644
|
-
if (os === "win32")
|
|
645
|
-
return { cmd: "explorer.exe", args: [file], opts };
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
637
|
export async function openBrowser(url, d = {}) {
|
|
649
638
|
if (!LINK_RE.test(url))
|
|
650
639
|
throw new Error("not a board link");
|
|
@@ -655,11 +644,6 @@ export async function openBrowser(url, d = {}) {
|
|
|
655
644
|
if (await portal(url).catch(() => false))
|
|
656
645
|
return "portal";
|
|
657
646
|
}
|
|
658
|
-
if (os === "darwin" || os === "win32") {
|
|
659
|
-
const page = redirectPage(url, d.pageDir ?? join(CONFIG_DIR, "bin"));
|
|
660
|
-
const how = fileOpenCommand(page, os);
|
|
661
|
-
return (await (d.launch ?? launch)(how)) ? "direct" : false;
|
|
662
|
-
}
|
|
663
647
|
const scope = os === "linux" && (d.inService ?? (() => inServiceCgroup()))();
|
|
664
648
|
const how = browserCommand(url, os, find, { scope });
|
|
665
649
|
if (!how)
|
|
@@ -721,7 +705,7 @@ async function openExplainTerminal(home, actionId) {
|
|
|
721
705
|
return { ok: false, why: `${t.error} — copy the question into your agent instead` };
|
|
722
706
|
return (await launch(t)) ? { ok: true } : { ok: false, why: "the terminal could not be started — copy the question into your agent instead" };
|
|
723
707
|
}
|
|
724
|
-
export async function ping(s, timeoutMs =
|
|
708
|
+
export async function ping(s, timeoutMs = 3000) {
|
|
725
709
|
const challenge = randomBytes(16).toString("hex");
|
|
726
710
|
try {
|
|
727
711
|
const res = await fetch(`http://127.0.0.1:${s.port}/api/ping?challenge=${challenge}`, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
|
package/dist/cli.js
CHANGED
|
@@ -2530,6 +2530,8 @@ async function main() {
|
|
|
2530
2530
|
die(`nothing was changed.${plan.teams.length > 1 ? " Leave one team with: pantheon leave <team>" : ""}`);
|
|
2531
2531
|
}
|
|
2532
2532
|
console.error(`${B(uninstalling ? "uninstalling pantheon" : "leaving pantheon")}${plan.teams.length ? D(` — ${plan.teams.map((t) => t.name).join(", ")}`) : ""}\n`);
|
|
2533
|
+
const { pantheonHomeRefusal, refusedHomeLine } = await import("./home-safety.js");
|
|
2534
|
+
const homeRefusal = pantheonHomeRefusal(CONFIG_DIR);
|
|
2533
2535
|
const { uninstallMachine, realUninstallIo } = await import("./machine.js");
|
|
2534
2536
|
const { removeUserMcpEntries } = await import("./install.js");
|
|
2535
2537
|
const { killRecordedRun } = await import("./worker.js");
|
|
@@ -2539,6 +2541,7 @@ async function main() {
|
|
|
2539
2541
|
hasIntent: () => readIntent() !== null,
|
|
2540
2542
|
clearIntent: () => clearIntent(),
|
|
2541
2543
|
killRun: () => killRecordedRun(dirname(CONFIG_PATH)),
|
|
2544
|
+
homeRefusal,
|
|
2542
2545
|
}));
|
|
2543
2546
|
for (const l of done.lines)
|
|
2544
2547
|
console.error(` ${l}`);
|
|
@@ -2561,7 +2564,10 @@ async function main() {
|
|
|
2561
2564
|
const { unlinkSync, existsSync: exists, rmSync, readdirSync } = await import("node:fs");
|
|
2562
2565
|
const spare = [];
|
|
2563
2566
|
const hasConfig = !!all && exists(CONFIG_PATH);
|
|
2564
|
-
if (
|
|
2567
|
+
if (homeRefusal) {
|
|
2568
|
+
left.push(refusedHomeLine(CONFIG_DIR, homeRefusal));
|
|
2569
|
+
}
|
|
2570
|
+
else if (hasConfig && keepIdentity) {
|
|
2565
2571
|
left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
|
|
2566
2572
|
}
|
|
2567
2573
|
else if (!keepIdentity && (hasConfig || uninstalling)) {
|
|
@@ -2613,16 +2619,18 @@ async function main() {
|
|
|
2613
2619
|
{
|
|
2614
2620
|
const { removePantheonWorktrees, removeHomeLeftovers } = await import("./machine.js");
|
|
2615
2621
|
const repos = [project, ...Object.values(all?.bindings ?? {}).map((b) => b.path).filter((p) => !!p)];
|
|
2616
|
-
const trees = removePantheonWorktrees(repos, [join(CONFIG_DIR, "worktrees"), ...repos.map((r) => join(r, ".pantheon", "worktrees"))]);
|
|
2622
|
+
const trees = removePantheonWorktrees(repos, [...(homeRefusal ? [] : [join(CONFIG_DIR, "worktrees")]), ...repos.map((r) => join(r, ".pantheon", "worktrees"))]);
|
|
2617
2623
|
removed.push(...trees.removed);
|
|
2618
2624
|
left.push(...trees.left);
|
|
2619
|
-
|
|
2620
|
-
|
|
2625
|
+
if (!homeRefusal) {
|
|
2626
|
+
try {
|
|
2627
|
+
rmSync(join(CONFIG_DIR, "worktrees"), { recursive: true, force: true });
|
|
2628
|
+
}
|
|
2629
|
+
catch { }
|
|
2630
|
+
const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling, refusal: null });
|
|
2631
|
+
removed.push(...rest.removed);
|
|
2632
|
+
left.push(...rest.left);
|
|
2621
2633
|
}
|
|
2622
|
-
catch { }
|
|
2623
|
-
const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling });
|
|
2624
|
-
removed.push(...rest.removed);
|
|
2625
|
-
left.push(...rest.left);
|
|
2626
2634
|
}
|
|
2627
2635
|
if (uninstalling) {
|
|
2628
2636
|
if (install?.present) {
|
|
@@ -2633,9 +2641,11 @@ async function main() {
|
|
|
2633
2641
|
if (!runningManaged)
|
|
2634
2642
|
left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
|
|
2635
2643
|
const { sweepPantheonHome } = await import("./machine.js");
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2644
|
+
if (!homeRefusal) {
|
|
2645
|
+
const swept = sweepPantheonHome(CONFIG_DIR, { keepIdentity, spare, refusal: null });
|
|
2646
|
+
removed.push(...swept.removed);
|
|
2647
|
+
left.push(...swept.left);
|
|
2648
|
+
}
|
|
2639
2649
|
}
|
|
2640
2650
|
console.error(`\n${B("Removed")}`);
|
|
2641
2651
|
for (const r of removed)
|
|
@@ -3564,6 +3574,25 @@ async function main() {
|
|
|
3564
3574
|
st = null;
|
|
3565
3575
|
raw = null;
|
|
3566
3576
|
}
|
|
3577
|
+
if (raw !== null) {
|
|
3578
|
+
let nonce;
|
|
3579
|
+
try {
|
|
3580
|
+
nonce = JSON.parse(raw).nonce;
|
|
3581
|
+
}
|
|
3582
|
+
catch {
|
|
3583
|
+
nonce = undefined;
|
|
3584
|
+
}
|
|
3585
|
+
const stale = st !== null && Date.now() - st.mtimeMs > HUD_ACTION_MAX_AGE_MS;
|
|
3586
|
+
if (stale && typeof nonce === "string" && nonce !== want) {
|
|
3587
|
+
try {
|
|
3588
|
+
rmSync(f, { force: true });
|
|
3589
|
+
}
|
|
3590
|
+
catch { }
|
|
3591
|
+
st = null;
|
|
3592
|
+
raw = null;
|
|
3593
|
+
nonce = undefined;
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3567
3596
|
if (raw !== null) {
|
|
3568
3597
|
let nonce;
|
|
3569
3598
|
try {
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve, sep } from "node:path";
|
|
6
6
|
import { VERSION } from "./version.js";
|
|
7
7
|
import { repoName } from "./repo-route.js";
|
|
8
|
+
import { markPantheonHome } from "./home-safety.js";
|
|
8
9
|
export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
|
|
9
10
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
10
11
|
export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
|
|
@@ -146,7 +147,7 @@ export function saveAll(f) {
|
|
|
146
147
|
return CONFIG_PATH;
|
|
147
148
|
}
|
|
148
149
|
function privateDir() {
|
|
149
|
-
|
|
150
|
+
ensurePantheonHome();
|
|
150
151
|
if (process.platform === "win32")
|
|
151
152
|
return;
|
|
152
153
|
try {
|
|
@@ -202,6 +203,11 @@ function writeConfigAtomic(data) {
|
|
|
202
203
|
catch { }
|
|
203
204
|
}
|
|
204
205
|
}
|
|
206
|
+
export function ensurePantheonHome() {
|
|
207
|
+
const made = !existsSync(CONFIG_DIR);
|
|
208
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
209
|
+
markPantheonHome(CONFIG_DIR, made);
|
|
210
|
+
}
|
|
205
211
|
const CONFIG_LOCK_STALE_MS = 5_000;
|
|
206
212
|
const CONFIG_LOCK_WAIT_MS = 15_000;
|
|
207
213
|
const sleepSync = (ms) => { try {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
4
|
+
import { basename, dirname, join, posix, sep, win32 } from "node:path";
|
|
5
|
+
export const HOME_MARKER = ".pantheon-home";
|
|
6
|
+
export const PANTHEON_HOME_ENTRIES = [
|
|
7
|
+
{ name: HOME_MARKER, what: "the mark that says this folder is Pantheon's home" },
|
|
8
|
+
{ name: "config.json", what: "your key and team tokens", identity: true },
|
|
9
|
+
{ name: "config.json.bak", what: "the previous config.json", identity: true },
|
|
10
|
+
{ name: "keys.json", what: "the teammates' keys this machine pinned", identity: true },
|
|
11
|
+
{ name: "nonces.json", what: "the signed requests this machine has seen", identity: true },
|
|
12
|
+
{ name: "continued.json", what: "what this machine already handled", identity: true },
|
|
13
|
+
{ name: "cursors.json", what: "where each agent session last read", identity: true },
|
|
14
|
+
{ name: "worker-cursor.json", what: "where the worker last read", identity: true },
|
|
15
|
+
{ name: "profile.json", what: "your profile", identity: true },
|
|
16
|
+
{ name: "worker-policy.json", what: "the worker's wake policy", identity: true },
|
|
17
|
+
{ name: "spend.json", what: "the spend ledger", identity: true },
|
|
18
|
+
{ name: "personas", what: "the worker's personas", dir: true, identity: true },
|
|
19
|
+
{ name: "worker-state.json", what: "the worker's last state" },
|
|
20
|
+
{ name: "worker-run.json", what: "the worker's record of a run in flight" },
|
|
21
|
+
{ name: "run-slot.json", what: "the worker's run slot" },
|
|
22
|
+
{ name: "worker.pid", what: "the worker's pid file" },
|
|
23
|
+
{ name: /^notifier-state(-.+)?\.json$/, sample: "notifier-state-team%2Fone.json", what: "the alerts' state" },
|
|
24
|
+
{ name: "update-check.json", what: "the update check's cache" },
|
|
25
|
+
{ name: "uri-key", what: "the key pantheon:// links are signed with" },
|
|
26
|
+
{ name: "board.json", what: "the board's state" },
|
|
27
|
+
{ name: "hook-intent.json", what: "the hook intent" },
|
|
28
|
+
{ name: "hook-guard.json", what: "the hook guard" },
|
|
29
|
+
{ name: /^(worker|relay|notifier|menubar)\.log(\.1)?$/, sample: "worker.log", what: "service logs an older version wrote here" },
|
|
30
|
+
{ name: /^\.write-check\..+$/, sample: ".write-check.1234.5678", what: "a leftover write probe" },
|
|
31
|
+
{ name: "bin", what: "the menu bar's and alerts' files", dir: true },
|
|
32
|
+
{ name: "logs", what: "the service logs", dir: true },
|
|
33
|
+
{ name: "host", what: "the Windows host's queue, state and markers", dir: true },
|
|
34
|
+
{ name: "mcp", what: "the agent windows' server state", dir: true },
|
|
35
|
+
{ name: "run-homes", what: "the woken runs' homes", dir: true },
|
|
36
|
+
{ name: "worktrees", what: "the worker's worktrees", dir: true },
|
|
37
|
+
{ name: "no-hooks", what: "the empty hooks folder woken runs' git uses", dir: true },
|
|
38
|
+
{ name: "lfs-hooks", what: "the LFS-only hooks folder woken runs' git uses", dir: true },
|
|
39
|
+
{ name: "runtime", what: "Pantheon's own Node", dir: true, kept: "managed" },
|
|
40
|
+
{ name: "data", what: "the relay's ledger", dir: true, kept: "it is your team's history and may be its only copy; delete it yourself once no relay runs from it" },
|
|
41
|
+
{ name: /^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i, sample: ".DS_Store", what: "the file browser's folder cache" },
|
|
42
|
+
];
|
|
43
|
+
const LOCK_OR_TMP = /^(.+?)(\.lock|(\.[0-9a-f]+)+\.tmp)$/i;
|
|
44
|
+
export function homeEntryFor(name) {
|
|
45
|
+
const direct = (n) => PANTHEON_HOME_ENTRIES.find((e) => typeof e.name === "string" ? e.name === n : e.name.test(n)) ?? null;
|
|
46
|
+
const e = direct(name);
|
|
47
|
+
if (e)
|
|
48
|
+
return e;
|
|
49
|
+
const m = LOCK_OR_TMP.exec(name);
|
|
50
|
+
const base = m ? direct(m[1]) : null;
|
|
51
|
+
return base && !base.dir ? base : null;
|
|
52
|
+
}
|
|
53
|
+
const realOrNull = (p) => { try {
|
|
54
|
+
return realpathSync.native(p);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
} };
|
|
59
|
+
function gitTopOf(dir) {
|
|
60
|
+
const env = { ...process.env };
|
|
61
|
+
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_CEILING_DIRECTORIES"])
|
|
62
|
+
delete env[k];
|
|
63
|
+
try {
|
|
64
|
+
const out = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }).trim();
|
|
65
|
+
return out ? realOrNull(out) ?? out : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export const realHomeCheckIo = () => ({
|
|
72
|
+
real: realOrNull,
|
|
73
|
+
exists: (p) => existsSync(p),
|
|
74
|
+
read: (p) => { try {
|
|
75
|
+
return readFileSync(p, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
} },
|
|
80
|
+
homes: () => {
|
|
81
|
+
const out = [homedir()];
|
|
82
|
+
try {
|
|
83
|
+
const u = userInfo().homedir;
|
|
84
|
+
if (u)
|
|
85
|
+
out.push(u);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
if (process.env.USERPROFILE)
|
|
89
|
+
out.push(process.env.USERPROFILE);
|
|
90
|
+
return out;
|
|
91
|
+
},
|
|
92
|
+
cwd: () => process.cwd(),
|
|
93
|
+
repoTop: gitTopOf,
|
|
94
|
+
os: platform(),
|
|
95
|
+
});
|
|
96
|
+
export function isPantheonHomeName(dir, os = platform()) {
|
|
97
|
+
const b = (os === "win32" ? win32 : posix).basename(dir);
|
|
98
|
+
return os === "win32" ? [".pantheon", "pantheon"].includes(b.toLowerCase()) : b === ".pantheon" || b === "Pantheon";
|
|
99
|
+
}
|
|
100
|
+
function holdsPantheonIdentity(dir, io, pj = join) {
|
|
101
|
+
if (!io.exists(pj(dir, "keys.json")))
|
|
102
|
+
return false;
|
|
103
|
+
const raw = io.read(pj(dir, "config.json"));
|
|
104
|
+
if (raw === null)
|
|
105
|
+
return false;
|
|
106
|
+
try {
|
|
107
|
+
const c = JSON.parse(raw.replace(/^/, ""));
|
|
108
|
+
return !!c && typeof c === "object" && typeof c.member === "string" && typeof c.publicKey === "string" && typeof c.privateKey === "string" && typeof c.relay === "string";
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function pantheonHomeRefusal(dir, over = {}) {
|
|
115
|
+
const io = { ...realHomeCheckIo(), ...over };
|
|
116
|
+
const r = io.real(dir);
|
|
117
|
+
if (r === null)
|
|
118
|
+
return null;
|
|
119
|
+
const win = io.os === "win32";
|
|
120
|
+
const P = win ? win32 : posix;
|
|
121
|
+
const norm = (p) => { const s = p.replace(/[\\/]+$/, "") || p; return win ? s.replace(/\//g, "\\").toLowerCase() : s; };
|
|
122
|
+
const S = win ? "\\" : sep;
|
|
123
|
+
const d = norm(r);
|
|
124
|
+
const inside = (child, parent) => child.startsWith(parent.endsWith(S) ? parent : parent + S);
|
|
125
|
+
if (P.dirname(r) === r || /^[a-z]:\\?$/i.test(d) || d === "")
|
|
126
|
+
return `${dir} is the root of a ${win ? "drive" : "filesystem"}`;
|
|
127
|
+
const homes = [...new Set(io.homes().map((h) => io.real(h) ?? h).map(norm))];
|
|
128
|
+
for (const h of homes) {
|
|
129
|
+
if (d === h)
|
|
130
|
+
return `${dir} is your home folder`;
|
|
131
|
+
if (inside(h, d))
|
|
132
|
+
return `${dir} holds your home folder (${h})`;
|
|
133
|
+
}
|
|
134
|
+
const cwd = io.real(io.cwd());
|
|
135
|
+
if (cwd && norm(cwd) === d)
|
|
136
|
+
return `${dir} is the folder this command runs in`;
|
|
137
|
+
if (io.exists(P.join(r, ".git")))
|
|
138
|
+
return `${dir} is a git repository`;
|
|
139
|
+
let walked = null;
|
|
140
|
+
for (let p = P.dirname(r), prev = r; p !== prev; prev = p, p = P.dirname(p)) {
|
|
141
|
+
if (io.exists(P.join(p, ".git"))) {
|
|
142
|
+
walked = p;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const top = io.repoTop(r) ?? walked;
|
|
147
|
+
if (top) {
|
|
148
|
+
const t = norm(top);
|
|
149
|
+
const dotfiles = homes.some((h) => t === h && inside(d, h));
|
|
150
|
+
if (t === d)
|
|
151
|
+
return `${dir} is a git repository`;
|
|
152
|
+
if (!dotfiles)
|
|
153
|
+
return `${dir} is inside the git repository ${top}`;
|
|
154
|
+
}
|
|
155
|
+
if (!isPantheonHomeName(r, io.os) && !io.exists(P.join(r, HOME_MARKER)) && !holdsPantheonIdentity(r, io, P.join)) {
|
|
156
|
+
return `${dir} is not a folder Pantheon made: it is not named .pantheon, it has no ${HOME_MARKER} mark, and it holds no Pantheon identity`;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
export function pantheonNamedFiles(dir) {
|
|
161
|
+
let names = [];
|
|
162
|
+
try {
|
|
163
|
+
names = readdirSync(dir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
return names.sort().filter((n) => homeEntryFor(n)).map((n) => join(dir, n));
|
|
169
|
+
}
|
|
170
|
+
export function refusedHomeLine(dir, why) {
|
|
171
|
+
const files = pantheonNamedFiles(dir).filter((p) => !/^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i.test(basename(p)));
|
|
172
|
+
const cwd = /the folder this command runs in$/.test(why);
|
|
173
|
+
return `nothing in ${dir} was removed: ${why}, so it may hold files of your own under the names Pantheon uses.` +
|
|
174
|
+
(cwd ? " Run the command again from another folder to have Pantheon's files there removed." : "") +
|
|
175
|
+
(files.length ? ` Pantheon's files there, if they are Pantheon's — check each, then delete it yourself: ${files.join(", ")}` : " No file there has a name Pantheon writes.");
|
|
176
|
+
}
|
|
177
|
+
export function markPantheonHome(dir, createdNow, over = {}) {
|
|
178
|
+
try {
|
|
179
|
+
const f = join(dir, HOME_MARKER);
|
|
180
|
+
if (existsSync(f))
|
|
181
|
+
return;
|
|
182
|
+
const io = { ...realHomeCheckIo(), ...over };
|
|
183
|
+
const r = io.real(dir);
|
|
184
|
+
if (r === null || dirname(r) === r)
|
|
185
|
+
return;
|
|
186
|
+
const homes = io.homes().map((h) => io.real(h) ?? h);
|
|
187
|
+
if (homes.some((h) => h === r || h.startsWith(r.endsWith(sep) ? r : r + sep)))
|
|
188
|
+
return;
|
|
189
|
+
if (!createdNow && readdirSync(r).some((n) => !/^(config\.json|keys\.json)(\.tmp[\w.-]*|\.lock)?$/.test(n)))
|
|
190
|
+
return;
|
|
191
|
+
writeFileSync(f, "This folder is Pantheon's home. `pantheon uninstall` removes what Pantheon wrote here, and nothing else.\n", { flag: "wx", mode: 0o600 });
|
|
192
|
+
}
|
|
193
|
+
catch { }
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=home-safety.js.map
|
package/dist/machine.js
CHANGED
|
@@ -6,6 +6,7 @@ import { CONFIG_DIR } from "./config.js";
|
|
|
6
6
|
import { borrowedHome, disableService, existingUnitPath, plistPath, serviceInstalled, serviceLogFile, stopWindowsHost, unitPath, unitPaths, uninstallService } from "./service.js";
|
|
7
7
|
import { LEGACY_TASK_NAME, hostAlive, hostChildRunning, hostFiles, isHostKind, readHostState, recordedTaskName } from "./win-host.js";
|
|
8
8
|
import { copilotMcpConfigFile, vsCodeUserDir, kiroMcpConfigFile, kiroDir } from "./install.js";
|
|
9
|
+
import { HOME_MARKER, homeEntryFor, isPantheonHomeName, pantheonHomeRefusal, refusedHomeLine } from "./home-safety.js";
|
|
9
10
|
const sh = (cmd) => {
|
|
10
11
|
try {
|
|
11
12
|
return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
@@ -667,6 +668,9 @@ export function removePantheonWorktrees(repos, roots, git = (repo, args) => exec
|
|
|
667
668
|
const MANAGED_LAUNCHER = "pantheon";
|
|
668
669
|
export function removeHomeLeftovers(home, keep, opts = {}) {
|
|
669
670
|
const removed = [], left = [];
|
|
671
|
+
const refusal = opts.refusal !== undefined ? opts.refusal : pantheonHomeRefusal(home);
|
|
672
|
+
if (refusal)
|
|
673
|
+
return { removed, left: [refusedHomeLine(home, refusal)] };
|
|
670
674
|
const items = [
|
|
671
675
|
["worker-policy.json", "the worker's wake policy (who may wake it, and self-upgrade) — a rejoin starts from no grants"],
|
|
672
676
|
["profile.json", "your profile (name, title, what you decide)"],
|
|
@@ -705,52 +709,7 @@ export function removeHomeLeftovers(home, keep, opts = {}) {
|
|
|
705
709
|
}
|
|
706
710
|
return { removed, left };
|
|
707
711
|
}
|
|
708
|
-
export
|
|
709
|
-
{ name: "config.json", what: "your key and team tokens", identity: true },
|
|
710
|
-
{ name: "config.json.bak", what: "the previous config.json", identity: true },
|
|
711
|
-
{ name: "keys.json", what: "the teammates' keys this machine pinned", identity: true },
|
|
712
|
-
{ name: "nonces.json", what: "the signed requests this machine has seen", identity: true },
|
|
713
|
-
{ name: "continued.json", what: "what this machine already handled", identity: true },
|
|
714
|
-
{ name: "cursors.json", what: "where each agent session last read", identity: true },
|
|
715
|
-
{ name: "worker-cursor.json", what: "where the worker last read", identity: true },
|
|
716
|
-
{ name: "profile.json", what: "your profile", identity: true },
|
|
717
|
-
{ name: "worker-policy.json", what: "the worker's wake policy", identity: true },
|
|
718
|
-
{ name: "spend.json", what: "the spend ledger", identity: true },
|
|
719
|
-
{ name: "personas", what: "the worker's personas", dir: true, identity: true },
|
|
720
|
-
{ name: "worker-state.json", what: "the worker's last state" },
|
|
721
|
-
{ name: "worker-run.json", what: "the worker's record of a run in flight" },
|
|
722
|
-
{ name: "run-slot.json", what: "the worker's run slot" },
|
|
723
|
-
{ name: "worker.pid", what: "the worker's pid file" },
|
|
724
|
-
{ name: /^notifier-state(-.+)?\.json$/, sample: "notifier-state-team%2Fone.json", what: "the alerts' state" },
|
|
725
|
-
{ name: "update-check.json", what: "the update check's cache" },
|
|
726
|
-
{ name: "uri-key", what: "the key pantheon:// links are signed with" },
|
|
727
|
-
{ name: "board.json", what: "the board's state" },
|
|
728
|
-
{ name: "hook-intent.json", what: "the hook intent" },
|
|
729
|
-
{ name: "hook-guard.json", what: "the hook guard" },
|
|
730
|
-
{ name: /^(worker|relay|notifier|menubar)\.log(\.1)?$/, sample: "worker.log", what: "service logs an older version wrote here" },
|
|
731
|
-
{ name: /^\.write-check\..+$/, sample: ".write-check.1234.5678", what: "a leftover write probe" },
|
|
732
|
-
{ name: "bin", what: "the menu bar's and alerts' files", dir: true },
|
|
733
|
-
{ name: "logs", what: "the service logs", dir: true },
|
|
734
|
-
{ name: "host", what: "the Windows host's queue, state and markers", dir: true },
|
|
735
|
-
{ name: "mcp", what: "the agent windows' server state", dir: true },
|
|
736
|
-
{ name: "run-homes", what: "the woken runs' homes", dir: true },
|
|
737
|
-
{ name: "worktrees", what: "the worker's worktrees", dir: true },
|
|
738
|
-
{ name: "no-hooks", what: "the empty hooks folder woken runs' git uses", dir: true },
|
|
739
|
-
{ name: "lfs-hooks", what: "the LFS-only hooks folder woken runs' git uses", dir: true },
|
|
740
|
-
{ name: "runtime", what: "Pantheon's own Node", dir: true, kept: "managed" },
|
|
741
|
-
{ name: "data", what: "the relay's ledger", dir: true, kept: "it is your team's history and may be its only copy; delete it yourself once no relay runs from it" },
|
|
742
|
-
{ name: /^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i, sample: ".DS_Store", what: "the file browser's folder cache" },
|
|
743
|
-
];
|
|
744
|
-
const LOCK_OR_TMP = /^(.+?)(\.lock|(\.[0-9a-f]+)+\.tmp)$/i;
|
|
745
|
-
export function homeEntryFor(name) {
|
|
746
|
-
const direct = (n) => PANTHEON_HOME_ENTRIES.find((e) => typeof e.name === "string" ? e.name === n : e.name.test(n)) ?? null;
|
|
747
|
-
const e = direct(name);
|
|
748
|
-
if (e)
|
|
749
|
-
return e;
|
|
750
|
-
const m = LOCK_OR_TMP.exec(name);
|
|
751
|
-
const base = m ? direct(m[1]) : null;
|
|
752
|
-
return base && !base.dir ? base : null;
|
|
753
|
-
}
|
|
712
|
+
export { PANTHEON_HOME_ENTRIES, homeEntryFor } from "./home-safety.js";
|
|
754
713
|
const pause = (ms) => { try {
|
|
755
714
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
756
715
|
}
|
|
@@ -768,6 +727,9 @@ export function sweepPantheonHome(home, o = {}) {
|
|
|
768
727
|
const removed = [], left = [];
|
|
769
728
|
if (!fs.exists(home))
|
|
770
729
|
return { removed, left, gone: true };
|
|
730
|
+
const refusal = o.refusal !== undefined ? o.refusal : o.fs ? null : pantheonHomeRefusal(home);
|
|
731
|
+
if (refusal)
|
|
732
|
+
return { removed, left: [refusedHomeLine(home, refusal)], gone: false };
|
|
771
733
|
const spare = new Set((o.spare ?? []).map((p) => resolve(p)));
|
|
772
734
|
const attempt = (p, rm) => {
|
|
773
735
|
let why = "";
|
|
@@ -801,6 +763,8 @@ export function sweepPantheonHome(home, o = {}) {
|
|
|
801
763
|
foreign.push(name);
|
|
802
764
|
continue;
|
|
803
765
|
}
|
|
766
|
+
if (name === HOME_MARKER)
|
|
767
|
+
continue;
|
|
804
768
|
if (spare.has(resolve(p)))
|
|
805
769
|
continue;
|
|
806
770
|
if (e.kept === "managed")
|
|
@@ -828,6 +792,8 @@ export function sweepPantheonHome(home, o = {}) {
|
|
|
828
792
|
rest = fs.list(home);
|
|
829
793
|
}
|
|
830
794
|
catch { }
|
|
795
|
+
if (rest.includes(HOME_MARKER) && (rest.length === 1 || isPantheonHomeName(home, os)) && attempt(join(home, HOME_MARKER), fs.remove) === null)
|
|
796
|
+
rest = rest.filter((n) => n !== HOME_MARKER);
|
|
831
797
|
if (!rest.length) {
|
|
832
798
|
const why = attempt(home, fs.removeDir);
|
|
833
799
|
if (why === null) {
|
|
@@ -885,20 +851,22 @@ export function uninstallMachine(opts, io) {
|
|
|
885
851
|
if (!opts.stopRelay && (io.installed("relay") || io.proc("relay").running)) {
|
|
886
852
|
left.push("the relay — others may be using it. Stop and remove it too: pantheon leave --stop-relay");
|
|
887
853
|
}
|
|
888
|
-
if (io.hasIntent()) {
|
|
854
|
+
if (!io.homeRefusal && io.hasIntent()) {
|
|
889
855
|
io.clearIntent();
|
|
890
856
|
removed.push("the hook intent (hook-intent.json) — nothing will put the hooks back");
|
|
891
857
|
}
|
|
892
858
|
const mcp = io.removeMcpEntries();
|
|
893
859
|
removed.push(...mcp.removed);
|
|
894
860
|
left.push(...mcp.left);
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
861
|
+
if (!io.homeRefusal) {
|
|
862
|
+
try {
|
|
863
|
+
if (existsSync(io.logDir)) {
|
|
864
|
+
rmSync(io.logDir, { recursive: true, force: true });
|
|
865
|
+
removed.push(`service logs (${io.logDir})`);
|
|
866
|
+
}
|
|
899
867
|
}
|
|
868
|
+
catch { }
|
|
900
869
|
}
|
|
901
|
-
catch { }
|
|
902
870
|
const repoDir = join(opts.project, ".pantheon");
|
|
903
871
|
let repoLogs = 0;
|
|
904
872
|
for (const k of LOG_KINDS) {
|
|
@@ -1052,6 +1020,7 @@ export function realUninstallIo(deps) {
|
|
|
1052
1020
|
hasIntent: deps.hasIntent,
|
|
1053
1021
|
clearIntent: deps.clearIntent,
|
|
1054
1022
|
logDir: join(CONFIG_DIR, "logs"),
|
|
1023
|
+
homeRefusal: deps.homeRefusal !== undefined ? deps.homeRefusal : pantheonHomeRefusal(CONFIG_DIR),
|
|
1055
1024
|
ownWorkerPid: () => ownWorkerPid(),
|
|
1056
1025
|
borrowed: () => borrowedHome(),
|
|
1057
1026
|
kill: (pid) => killPid(pid) === null,
|
package/dist/service.js
CHANGED
|
@@ -7,7 +7,7 @@ import { linuxGraphicalSession } from "./host-env.js";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { isVersionManagedPath, launcher } from "./install.js";
|
|
9
9
|
import { childNodeFlags, managedLauncher, managedLayout } from "./managed.js";
|
|
10
|
-
import { CONFIG_DIR } from "./config.js";
|
|
10
|
+
import { CONFIG_DIR, ensurePantheonHome } from "./config.js";
|
|
11
11
|
import { HOST_KINDS, LEGACY_TASK_NAME, clearTrayQuit, ensureHostShim, fromUtf16le, hostAlive, hostArgv, hostChildRunning, hostFiles, hostLaunch, isHostKind, launchFileText, legacyTaskIsUsers, readDefinition, readHostState, taskName, taskUserId, taskXml, utf16le, windowsPowerShell, writeDefinition, } from "./win-host.js";
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const LABEL = (kind) => `dev.pantheon.${kind}`;
|
|
@@ -272,6 +272,10 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
272
272
|
const noServices = servicesUnavailable(sys.platform);
|
|
273
273
|
if (noServices)
|
|
274
274
|
throw new Error(noServices.replace(/^not available — /, ""));
|
|
275
|
+
try {
|
|
276
|
+
ensurePantheonHome();
|
|
277
|
+
}
|
|
278
|
+
catch { }
|
|
275
279
|
if (sys.platform === "win32")
|
|
276
280
|
return installWindows(kind, resolve(cwd), args, sys);
|
|
277
281
|
const out = [];
|
package/dist/win-host.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join, resolve, win32 } from "node:path";
|
|
@@ -649,7 +649,8 @@ export function takeHostLock(home = CONFIG_DIR, pid = process.pid, now = Date.no
|
|
|
649
649
|
if (e.code !== "EEXIST")
|
|
650
650
|
throw e;
|
|
651
651
|
}
|
|
652
|
-
const
|
|
652
|
+
const judged = readFileSafe(f.lock);
|
|
653
|
+
const held = Number(judged);
|
|
653
654
|
let age = 0;
|
|
654
655
|
try {
|
|
655
656
|
age = now() - statSync(f.lock).mtimeMs;
|
|
@@ -674,10 +675,37 @@ export function takeHostLock(home = CONFIG_DIR, pid = process.pid, now = Date.no
|
|
|
674
675
|
catch {
|
|
675
676
|
continue;
|
|
676
677
|
}
|
|
678
|
+
const moved = readFileSafe(gone);
|
|
679
|
+
if (moved !== judged) {
|
|
680
|
+
putBack(gone, f.lock);
|
|
681
|
+
const other = Number(moved);
|
|
682
|
+
return Number.isInteger(other) && other > 0 ? other : -1;
|
|
683
|
+
}
|
|
677
684
|
rmSync(gone, { force: true });
|
|
678
685
|
}
|
|
679
686
|
return lastHolder;
|
|
680
687
|
}
|
|
688
|
+
function putBack(moved, lock) {
|
|
689
|
+
try {
|
|
690
|
+
linkSync(moved, lock);
|
|
691
|
+
rmSync(moved, { force: true });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
catch (e) {
|
|
695
|
+
if (e.code === "EEXIST") {
|
|
696
|
+
rmSync(moved, { force: true });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (existsSync(lock)) {
|
|
701
|
+
rmSync(moved, { force: true });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
renameSync(moved, lock);
|
|
706
|
+
}
|
|
707
|
+
catch { }
|
|
708
|
+
}
|
|
681
709
|
const readFileSafe = (p) => { try {
|
|
682
710
|
return readFileSync(p, "utf8").trim();
|
|
683
711
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": 1,
|
|
3
|
-
"built_at": "2026-10-
|
|
3
|
+
"built_at": "2026-10-05T08:38:15.336Z",
|
|
4
4
|
"swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
|
|
5
5
|
"helpers": {
|
|
6
6
|
"pantheon-tray": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"pantheon-hud": {
|
|
18
18
|
"source": "main.swift",
|
|
19
19
|
"source_sha256": "bcf55a19b658d1f09a9d117c76030393ab08a2569062dd7413f884ca05f3a0eb",
|
|
20
|
-
"sha256": "
|
|
20
|
+
"sha256": "191f3f18e8212e11ad4438bdbef87a5cc533f5ae2fae208801db01c9a265193b",
|
|
21
21
|
"archs": [
|
|
22
22
|
"arm64",
|
|
23
23
|
"x86_64"
|
|
Binary file
|
|
@@ -30,37 +30,65 @@
|
|
|
30
30
|
#>
|
|
31
31
|
param([string]$Mode = '')
|
|
32
32
|
|
|
33
|
+
# Whether toasts are switched off for an app id: only an explicit Disabled*
|
|
34
|
+
# setting is off. Windows THROWS "Element not found" (0x80070490) for an app id
|
|
35
|
+
# that has never shown a toast for this user, and a plain property read turned
|
|
36
|
+
# that into $null -- which was taken for "off" for every id, so a fresh PC only
|
|
37
|
+
# ever got balloons. Unreadable is unknown, and unknown is tried.
|
|
38
|
+
function global:Get-PantheonToastSetting($Notifier) {
|
|
39
|
+
$s = $null
|
|
40
|
+
try { $s = $Notifier.get_Setting() } catch { try { $s = $Notifier.Setting } catch { $s = $null } }
|
|
41
|
+
if ($null -eq $s) { return 'unknown' }
|
|
42
|
+
return [string]$s
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
# One toast: the XML, tried under each app id in turn. Returns @{ code; said }.
|
|
34
|
-
|
|
46
|
+
# $Api stands in for the WinRT calls (the checks run it on Linux); left out,
|
|
47
|
+
# the real ToastNotificationManager is used.
|
|
48
|
+
function global:Show-PantheonToast([string]$Xml, [string]$Tag, [string[]]$Aumids, [hashtable]$Api = $null) {
|
|
35
49
|
$said = New-Object System.Collections.Generic.List[string]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
$doc = $null
|
|
51
|
+
if (-not $Api) {
|
|
52
|
+
try {
|
|
53
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
54
|
+
[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
55
|
+
$doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
|
|
56
|
+
$doc.LoadXml($Xml)
|
|
57
|
+
} catch {
|
|
58
|
+
$said.Add('no-toast-api ' + $_.Exception.Message)
|
|
59
|
+
return @{ code = 3; said = $said }
|
|
60
|
+
}
|
|
61
|
+
$Api = @{
|
|
62
|
+
notifier = { param($id) [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id) }
|
|
63
|
+
toast = { param($d) [Windows.UI.Notifications.ToastNotification]::new($d) }
|
|
64
|
+
history = { param($id) [Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory($id) }
|
|
65
|
+
remove = { param($t, $id) [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($t, 'pantheon', $id) }
|
|
66
|
+
wait = { Start-Sleep -Milliseconds 400 }
|
|
67
|
+
}
|
|
44
68
|
}
|
|
45
69
|
$ids = @($Aumids | Where-Object { $_ })
|
|
46
70
|
for ($i = 0; $i -lt $ids.Count; $i++) {
|
|
47
71
|
$id = $ids[$i]
|
|
48
72
|
$last = ($i -eq $ids.Count - 1)
|
|
49
73
|
try {
|
|
50
|
-
$notifier =
|
|
51
|
-
|
|
52
|
-
$
|
|
74
|
+
$notifier = & $Api.notifier $id
|
|
75
|
+
$setting = Get-PantheonToastSetting $notifier
|
|
76
|
+
if ($setting -like 'Disabled*') { $said.Add('off ' + $id + ' ' + $setting); continue }
|
|
77
|
+
$toast = & $Api.toast $doc
|
|
53
78
|
if ($Tag) { $toast.Tag = $Tag; $toast.Group = 'pantheon' }
|
|
54
79
|
$notifier.Show($toast)
|
|
55
80
|
# Pantheon's own app id is used only when Windows then has the toast in
|
|
56
81
|
# its history for that id: a build that ignores the registration falls
|
|
57
82
|
# through to the next id instead of swallowing the alert.
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
# The last id too, when Windows would not say its setting: an unknown
|
|
84
|
+
# setting that Windows then drops silently must still end in a balloon.
|
|
85
|
+
$knownOn = ($setting -eq 'Enabled')
|
|
86
|
+
if ($Tag -and (-not $last -or -not $knownOn)) {
|
|
87
|
+
& $Api.wait
|
|
60
88
|
$seen = $false
|
|
61
|
-
foreach ($h in
|
|
89
|
+
foreach ($h in @(& $Api.history $id)) { if ($h.Tag -eq $Tag) { $seen = $true } }
|
|
62
90
|
if (-not $seen) {
|
|
63
|
-
try {
|
|
91
|
+
try { & $Api.remove $Tag $id } catch { }
|
|
64
92
|
$said.Add('unseen ' + $id)
|
|
65
93
|
continue
|
|
66
94
|
}
|
package/installer/install.ps1
CHANGED
|
@@ -186,8 +186,11 @@ function Install-Pantheon {
|
|
|
186
186
|
# Is a task this user's? Its XML (schtasks /Query /TN <name> /XML) names its
|
|
187
187
|
# principal as DOMAIN\user, a bare user name, or a SID; every one of them must
|
|
188
188
|
# be this user (src/win-host.ts taskIsUsers). A task of the same name that
|
|
189
|
-
# another person on this PC registered is never ended.
|
|
190
|
-
|
|
189
|
+
# another person on this PC registered is never ended. A SID is compared as a
|
|
190
|
+
# SID; a name is first turned into its account's SID ($ToSid) and compared
|
|
191
|
+
# by that, and only by name when Windows cannot say which account it is.
|
|
192
|
+
function Test-PantheonTaskIsMine([string]$Xml, [string]$UserId, [string]$Sid,
|
|
193
|
+
[scriptblock]$ToSid = { param($n) if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return '' }; try { ([Security.Principal.NTAccount]$n).Translate([Security.Principal.SecurityIdentifier]).Value } catch { '' } }) {
|
|
191
194
|
if (-not $Xml) { return $false }
|
|
192
195
|
$ids = @([regex]::Matches(($Xml -replace "`0", ''), '<UserId>([^<]*)</UserId>') | ForEach-Object { $_.Groups[1].Value.Trim().ToLowerInvariant() })
|
|
193
196
|
if ($ids.Count -eq 0) { return $false }
|
|
@@ -195,14 +198,57 @@ function Install-Pantheon {
|
|
|
195
198
|
$s = ([string]$Sid).ToLowerInvariant()
|
|
196
199
|
$bare = if ($u.Contains('\')) { $u.Substring($u.LastIndexOf('\') + 1) } else { $u }
|
|
197
200
|
foreach ($id in $ids) {
|
|
201
|
+
if ($id.StartsWith('s-1-')) { if ($s -and $id -eq $s) { continue }; return $false }
|
|
202
|
+
$idSid = ''
|
|
203
|
+
if ($s) { try { $idSid = ([string](& $ToSid $id)).ToLowerInvariant() } catch { $idSid = '' } }
|
|
204
|
+
if ($idSid) { if ($idSid -eq $s) { continue }; return $false }
|
|
198
205
|
if ($u -and $id -eq $u) { continue }
|
|
199
|
-
if ($
|
|
200
|
-
if ($bare -and -not $id.Contains('\') -and -not $id.StartsWith('s-1-') -and $id -eq $bare) { continue }
|
|
206
|
+
if ($bare -and -not $id.Contains('\') -and $id -eq $bare) { continue }
|
|
201
207
|
return $false
|
|
202
208
|
}
|
|
203
209
|
return $true
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
# What schtasks printed, as text. It writes a task's XML as UTF-16 (with or
|
|
213
|
+
# without a byte order mark); read in the console's code page, a user name
|
|
214
|
+
# with a letter outside ASCII came out as other letters and never matched.
|
|
215
|
+
# So the bytes are decoded by what they hold.
|
|
216
|
+
function ConvertFrom-TaskXmlBytes([byte[]]$Bytes) {
|
|
217
|
+
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
|
218
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE) { return [Text.Encoding]::Unicode.GetString($Bytes, 2, $Bytes.Length - 2) }
|
|
219
|
+
if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFE -and $Bytes[1] -eq 0xFF) { return [Text.Encoding]::BigEndianUnicode.GetString($Bytes, 2, $Bytes.Length - 2) }
|
|
220
|
+
if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { return [Text.Encoding]::UTF8.GetString($Bytes, 3, $Bytes.Length - 3) }
|
|
221
|
+
# No mark: UTF-16LE when most odd bytes of the start are zero ('<', 0, '?', 0, ...).
|
|
222
|
+
$n = [Math]::Min($Bytes.Length, 64); $odd = 0; $zeros = 0
|
|
223
|
+
for ($i = 1; $i -lt $n; $i += 2) { $odd++; if ($Bytes[$i] -eq 0) { $zeros++ } }
|
|
224
|
+
if ($odd -gt 0 -and $zeros * 4 -ge $odd * 3) { return [Text.Encoding]::Unicode.GetString($Bytes) }
|
|
225
|
+
try { return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($Bytes) } catch { }
|
|
226
|
+
return [Console]::OutputEncoding.GetString($Bytes)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# A task's XML (schtasks /Query /TN <name> /XML), its bytes read as they are
|
|
230
|
+
# (ConvertFrom-TaskXmlBytes); '' when there is no such task.
|
|
231
|
+
function Get-PantheonTaskXml([string]$Schtasks, [string]$Name) {
|
|
232
|
+
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
233
|
+
$psi.FileName = $Schtasks
|
|
234
|
+
$psi.Arguments = ConvertTo-ArgLine @('/Query', '/TN', $Name, '/XML')
|
|
235
|
+
$psi.UseShellExecute = $false
|
|
236
|
+
$psi.CreateNoWindow = $true
|
|
237
|
+
$psi.RedirectStandardInput = $true
|
|
238
|
+
$psi.RedirectStandardOutput = $true
|
|
239
|
+
$psi.RedirectStandardError = $true
|
|
240
|
+
$p = [System.Diagnostics.Process]::Start($psi)
|
|
241
|
+
$p.StandardInput.Close()
|
|
242
|
+
$buf = New-Object System.IO.MemoryStream
|
|
243
|
+
$copy = $p.StandardOutput.BaseStream.CopyToAsync($buf)
|
|
244
|
+
$err = $p.StandardError.ReadToEndAsync()
|
|
245
|
+
$p.WaitForExit()
|
|
246
|
+
$copy.Wait()
|
|
247
|
+
$null = $err.Result
|
|
248
|
+
if ($p.ExitCode -ne 0) { return '' }
|
|
249
|
+
return ConvertFrom-TaskXmlBytes $buf.ToArray()
|
|
250
|
+
}
|
|
251
|
+
|
|
206
252
|
# Before the runtime folder is swapped: Pantheon's background host (the
|
|
207
253
|
# logon task) runs node.exe from it, and Windows will not rename a folder a
|
|
208
254
|
# running program came from. A teammate's request the worker is running
|
|
@@ -268,6 +314,68 @@ function Install-Pantheon {
|
|
|
268
314
|
return @{ code = $p.ExitCode; out = $out.Result; err = $err.Result }
|
|
269
315
|
}
|
|
270
316
|
|
|
317
|
+
# Hand over to pantheon with the person's own arguments (join <invite>,
|
|
318
|
+
# start): after an install, and also when the update was put off because a
|
|
319
|
+
# teammate's run was in progress -- a join or a start needs a working
|
|
320
|
+
# pantheon, not the update. Sets $State.code to pantheon's exit code.
|
|
321
|
+
function Invoke-PantheonHandoff([string]$nodeExe, [string]$entry, [string[]]$ca, [string[]]$Arguments) {
|
|
322
|
+
if ($null -eq $ca) { $ca = @() }
|
|
323
|
+
# node.exe directly, not the .cmd: PowerShell hands arguments to a .cmd
|
|
324
|
+
# through cmd.exe, which would cut an invite at a & or expand a %. And
|
|
325
|
+
# Start-Process -NoNewWindow, so pantheon gets this console itself: a real
|
|
326
|
+
# terminal for the sign-in questions, not a pipe PowerShell reads from.
|
|
327
|
+
$State.step = 'starting pantheon'
|
|
328
|
+
# With the same --use-system-ca as npm and pantheon.cmd: this first run is the
|
|
329
|
+
# one that signs in, and behind a TLS-inspecting proxy the relay's
|
|
330
|
+
# certificate chains only to the company root in the Windows store.
|
|
331
|
+
$line = ConvertTo-ArgLine (@($ca) + @($entry) + $Arguments)
|
|
332
|
+
$noConsole = $false
|
|
333
|
+
try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
|
|
334
|
+
if (-not $noConsole) {
|
|
335
|
+
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
|
|
336
|
+
$null = $p.Handle # without this, ExitCode is empty once the process is gone
|
|
337
|
+
$p.WaitForExit()
|
|
338
|
+
$State.code = $p.ExitCode
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# No console to type into (CI, input piped in). pantheon still runs: it
|
|
343
|
+
# never asks a question without a terminal (every prompt checks
|
|
344
|
+
# process.stdin.isTTY) and works on flags alone, so a join to a team's own
|
|
345
|
+
# relay, or from a machine already signed in, needs no answers. Its input
|
|
346
|
+
# is an empty file, so nothing can wait on it. What it cannot do here is
|
|
347
|
+
# sign in: on the hosted relay join and start stop at once, before anything
|
|
348
|
+
# is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
|
|
349
|
+
# is how it ended and config.json still has no account, the last line says
|
|
350
|
+
# the one thing to do; any other failure keeps pantheon's own words last.
|
|
351
|
+
$quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
352
|
+
New-Item -ItemType Directory -Force -Path $quiet | Out-Null
|
|
353
|
+
try {
|
|
354
|
+
$in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
|
|
355
|
+
[IO.File]::WriteAllText($in, '')
|
|
356
|
+
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
|
|
357
|
+
$null = $p.Handle
|
|
358
|
+
$p.WaitForExit()
|
|
359
|
+
$State.code = $p.ExitCode
|
|
360
|
+
$said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
|
|
361
|
+
if ($said) { [Console]::Error.Write($said) }
|
|
362
|
+
if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
|
|
363
|
+
$pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
|
|
364
|
+
$signedIn = $false
|
|
365
|
+
try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
|
|
366
|
+
if (-not $signedIn) {
|
|
367
|
+
# Quoted only where PowerShell needs it, so it reads as typed.
|
|
368
|
+
$show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
|
|
369
|
+
$what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
|
|
370
|
+
Say ''
|
|
371
|
+
Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
271
379
|
try {
|
|
272
380
|
if ($PantheonVersion -like '*__*') {
|
|
273
381
|
Fail "this copy of install.ps1 has no version filled in. Use the one the relay serves: irm $Site/install.ps1 | iex"
|
|
@@ -457,7 +565,7 @@ function Install-Pantheon {
|
|
|
457
565
|
$stopped = Stop-PantheonHost -NodeDir $nodeDir -HostDir $hostDir -Tasks $tasks `
|
|
458
566
|
-Holders { param($d) Get-FolderHolders $d } -EndTask { param($t) $null = Invoke-Quiet $schtasks @('/End', '/TN', $t) } `
|
|
459
567
|
-RunRecord (Join-Path $pantheonHomeDir 'worker-run.json') `
|
|
460
|
-
-TaskIsMine { param($t) Test-PantheonTaskIsMine (
|
|
568
|
+
-TaskIsMine { param($t) Test-PantheonTaskIsMine (Get-PantheonTaskXml $schtasks $t) $meUser $meSid }
|
|
461
569
|
# A teammate's run still going after the wait: nothing was stopped,
|
|
462
570
|
# and the runtime is left exactly as it is (see the catch below).
|
|
463
571
|
if ($stopped.busy) { throw 'PANTHEON_BUSY:' }
|
|
@@ -617,60 +725,7 @@ function Install-Pantheon {
|
|
|
617
725
|
return
|
|
618
726
|
}
|
|
619
727
|
Say ''
|
|
620
|
-
|
|
621
|
-
# through cmd.exe, which would cut an invite at a & or expand a %. And
|
|
622
|
-
# Start-Process -NoNewWindow, so pantheon gets this console itself: a real
|
|
623
|
-
# terminal for the sign-in questions, not a pipe PowerShell reads from.
|
|
624
|
-
$State.step = 'starting pantheon'
|
|
625
|
-
# With the same --use-system-ca as npm and pantheon.cmd: this first run is the
|
|
626
|
-
# one that signs in, and behind a TLS-inspecting proxy the relay's
|
|
627
|
-
# certificate chains only to the company root in the Windows store.
|
|
628
|
-
$line = ConvertTo-ArgLine (@($ca) + @($entry) + $Arguments)
|
|
629
|
-
$noConsole = $false
|
|
630
|
-
try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
|
|
631
|
-
if (-not $noConsole) {
|
|
632
|
-
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
|
|
633
|
-
$null = $p.Handle # without this, ExitCode is empty once the process is gone
|
|
634
|
-
$p.WaitForExit()
|
|
635
|
-
$State.code = $p.ExitCode
|
|
636
|
-
return
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
# No console to type into (CI, input piped in). pantheon still runs: it
|
|
640
|
-
# never asks a question without a terminal (every prompt checks
|
|
641
|
-
# process.stdin.isTTY) and works on flags alone, so a join to a team's own
|
|
642
|
-
# relay, or from a machine already signed in, needs no answers. Its input
|
|
643
|
-
# is an empty file, so nothing can wait on it. What it cannot do here is
|
|
644
|
-
# sign in: on the hosted relay join and start stop at once, before anything
|
|
645
|
-
# is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
|
|
646
|
-
# is how it ended and config.json still has no account, the last line says
|
|
647
|
-
# the one thing to do; any other failure keeps pantheon's own words last.
|
|
648
|
-
$quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
649
|
-
New-Item -ItemType Directory -Force -Path $quiet | Out-Null
|
|
650
|
-
try {
|
|
651
|
-
$in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
|
|
652
|
-
[IO.File]::WriteAllText($in, '')
|
|
653
|
-
$p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
|
|
654
|
-
$null = $p.Handle
|
|
655
|
-
$p.WaitForExit()
|
|
656
|
-
$State.code = $p.ExitCode
|
|
657
|
-
$said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
|
|
658
|
-
if ($said) { [Console]::Error.Write($said) }
|
|
659
|
-
if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
|
|
660
|
-
$pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
|
|
661
|
-
$signedIn = $false
|
|
662
|
-
try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
|
|
663
|
-
if (-not $signedIn) {
|
|
664
|
-
# Quoted only where PowerShell needs it, so it reads as typed.
|
|
665
|
-
$show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
|
|
666
|
-
$what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
|
|
667
|
-
Say ''
|
|
668
|
-
Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
} finally {
|
|
672
|
-
Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
|
|
673
|
-
}
|
|
728
|
+
Invoke-PantheonHandoff $nodeExe $entry $ca $Arguments
|
|
674
729
|
} catch {
|
|
675
730
|
$m = $_.Exception.Message
|
|
676
731
|
if ($m -like 'PANTHEON_BUSY:*') {
|
|
@@ -678,6 +733,22 @@ function Install-Pantheon {
|
|
|
678
733
|
# is none that runs is the exit code non-zero.
|
|
679
734
|
$have = ''
|
|
680
735
|
if ($installedCli) { try { $have = & $installedCli } catch { $have = '' } }
|
|
736
|
+
if ($have -and $Arguments.Count -gt 0) {
|
|
737
|
+
# The person's join or start still happens, on the Pantheon that is
|
|
738
|
+
# here: it does not touch the runtime folder the run is using.
|
|
739
|
+
Write-Host "Pantheon was not updated to $PantheonVersion because a teammate's run was in progress; Pantheon $have is still installed and working, so your command runs on it now. Run the same line again in a few minutes to update." -ForegroundColor Yellow
|
|
740
|
+
if ($State.lock) { $State.lock.Dispose(); $State.lock = $null }
|
|
741
|
+
$caNow = @()
|
|
742
|
+
try { if ((Invoke-Quiet $nodeExe @('--use-system-ca', '-e', '0')).code -eq 0) { $caNow = @('--use-system-ca') } } catch { $caNow = @() }
|
|
743
|
+
try {
|
|
744
|
+
Say ''
|
|
745
|
+
Invoke-PantheonHandoff $nodeExe $entry $caNow $Arguments
|
|
746
|
+
} catch {
|
|
747
|
+
Write-Host "pantheon install failed: it stopped while starting pantheon ($($_.Exception.Message)). Open a new terminal and run your pantheon command again." -ForegroundColor Red
|
|
748
|
+
$State.code = 1
|
|
749
|
+
}
|
|
750
|
+
return
|
|
751
|
+
}
|
|
681
752
|
$kept = if ($have) { " Pantheon $have is still installed and working." } else { '' }
|
|
682
753
|
Write-Host "Pantheon was not updated because a teammate's run was in progress; run the same line again in a few minutes.$kept" -ForegroundColor Yellow
|
|
683
754
|
$State.code = if ($have) { 0 } else { 1 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.33.0
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|