@join-pantheon/cli 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -40
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +224 -17
- package/dist/claude-confine.js +299 -0
- package/dist/cli.js +1116 -137
- package/dist/client.js +294 -10
- package/dist/command-state.js +102 -0
- package/dist/config.js +287 -14
- package/dist/continued.js +42 -10
- package/dist/doctor.js +191 -23
- package/dist/file-lock.js +197 -0
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +565 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +101 -1
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +494 -32
- package/dist/mcp-supervisor.js +29 -7
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +99 -36
- package/dist/network.js +39 -8
- package/dist/nonces.js +130 -0
- package/dist/notify.js +26 -9
- package/dist/pending.js +121 -22
- package/dist/persona.js +21 -0
- package/dist/pins.js +161 -0
- package/dist/profile.js +22 -1
- package/dist/protocol.js +40 -1
- package/dist/quickstart.js +173 -24
- package/dist/reads.js +37 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1341 -207
- package/dist/repo-find.js +127 -0
- package/dist/repo-route.js +70 -0
- package/dist/roster-file.js +190 -25
- package/dist/run-failure.js +99 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +91 -35
- package/dist/setup-guide.js +91 -31
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +24 -3
- package/dist/untrusted.js +24 -5
- package/dist/update-check.js +2 -0
- package/dist/upgrade.js +9 -5
- package/dist/verify-act.js +102 -0
- package/dist/which.js +69 -12
- package/dist/worker-fleet.js +30 -9
- package/dist/worker-policy.js +231 -7
- package/dist/worker.js +1717 -130
- package/helper/macos/menubar.swift +63 -2
- package/package.json +2 -2
package/dist/run-failure.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { checkRepoCommand } from "./agents.js";
|
|
3
|
+
import { bashRuleMatches, GIT_DENY_RULES } from "./claude-confine.js";
|
|
2
4
|
export const AUTH_RE = /not (?:logged|signed) in|not authenticated|no (?:github )?(?:authentication|credentials?|token) (?:found|available)|login required|please (?:log|sign) in|run .*login|invalid api key|unauthori[sz]ed|authentication (?:failed|required)|\b401\b|expired (?:token|session)/i;
|
|
3
5
|
export const SIGN_IN_FIX = {
|
|
4
6
|
"claude-code": "run `claude`, then /login",
|
|
@@ -58,15 +60,24 @@ export function diagnoseRun(f, probe = gitProbe) {
|
|
|
58
60
|
fix: `Sign in once and the next run works: ${fix}. Until then every woken run on this machine fails the same way.`,
|
|
59
61
|
};
|
|
60
62
|
}
|
|
63
|
+
if (f.allowBash && (f.agent ?? "claude-code") === "claude-code" && (f.os ?? process.platform) === "linux" && SANDBOX_RE.test(f.detail)) {
|
|
64
|
+
return {
|
|
65
|
+
cause: "sandbox_unavailable",
|
|
66
|
+
says: "The run could not run commands: Claude Code's sandbox did not start on this machine, and a command run never goes without it.",
|
|
67
|
+
fix: "On Linux the sandbox needs bubblewrap and socat. Install them (Debian/Ubuntu: sudo apt install bubblewrap socat; Fedora: sudo dnf install bubblewrap socat), then the next run works.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
61
70
|
if (!f.exited) {
|
|
62
71
|
return {
|
|
63
72
|
cause: "crashed",
|
|
64
73
|
says: `The agent did not finish — ${exitLine(f.detail)}.`,
|
|
65
74
|
fix: `Read the transcript at ${f.runLog}. If the agent CLI is broken or missing, pantheon doctor names it.`,
|
|
66
75
|
dir: f.worktree,
|
|
67
|
-
lines: [`cat ${f.runLog}`],
|
|
76
|
+
lines: [`cat ${shq(f.runLog)}`],
|
|
68
77
|
};
|
|
69
78
|
}
|
|
79
|
+
if (!f.branch && f.folder)
|
|
80
|
+
return silent(f);
|
|
70
81
|
if (!f.branch) {
|
|
71
82
|
return {
|
|
72
83
|
cause: "not_a_repo",
|
|
@@ -76,6 +87,20 @@ export function diagnoseRun(f, probe = gitProbe) {
|
|
|
76
87
|
}
|
|
77
88
|
if (!f.allowBash && !(f.needsCommands || BLOCKED_RE.test(f.transcript ?? f.detail) || BLOCKED_RE.test(f.detail)))
|
|
78
89
|
return silent(f);
|
|
90
|
+
if (!f.allowBash && f.commandsNeedSandbox === "agent-cmd") {
|
|
91
|
+
return {
|
|
92
|
+
cause: "no_sandbox_commands",
|
|
93
|
+
says: "Commands are on here, and this machine is set to keep runs of the agent the worker was started with (--agent-cmd) to edits, so the run edited files only.",
|
|
94
|
+
fix: "To have it run tests, commit and push too: pantheon policy grant unsandboxed-runs — or start the worker without --agent-cmd so Claude Code runs them in its sandbox.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!f.allowBash && f.commandsNeedSandbox) {
|
|
98
|
+
return {
|
|
99
|
+
cause: "no_sandbox_commands",
|
|
100
|
+
says: "Commands are on here, and this machine is set to keep runs of this Claude Code (Windows, or older than 2.1.83) to edits, so the run edited files only.",
|
|
101
|
+
fix: "To have it run tests, commit and push too: pantheon policy grant unsandboxed-runs — or update Claude Code to 2.1.83 or later on macOS or Linux, which runs them in its sandbox.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
79
104
|
if (!f.allowBash) {
|
|
80
105
|
return {
|
|
81
106
|
cause: "no_commands",
|
|
@@ -84,6 +109,55 @@ export function diagnoseRun(f, probe = gitProbe) {
|
|
|
84
109
|
capability: "run_commands",
|
|
85
110
|
};
|
|
86
111
|
}
|
|
112
|
+
const claimed = blockedCommands(f.transcript ?? f.detail);
|
|
113
|
+
const allowed = (c) => !!f.allowedRules?.some((r) => ruleAllows(r, c));
|
|
114
|
+
const denied = (c) => GIT_DENY_RULES.find((r) => bashRuleMatches(r, c));
|
|
115
|
+
const byDeny = f.allowedRules ? claimed.filter((c) => allowed(c) && denied(c)) : [];
|
|
116
|
+
if (f.allowBash && byDeny.length) {
|
|
117
|
+
return {
|
|
118
|
+
cause: "command_not_listed",
|
|
119
|
+
says: `The run needed \`${byDeny.join("`, `")}\`, which a woken run is never given (${denied(byDeny[0])}): it would move or delete a branch the person's own checkout shares, or run a program through git.`,
|
|
120
|
+
fix: "That is by design and no list entry changes it. If the work needs it, do that step yourself, or ask the run for the part that does not need it.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const blocked = f.allowedRules ? claimed.filter((c) => !allowed(c)) : claimed;
|
|
124
|
+
const wronglyBlocked = f.allowedRules ? claimed.filter((c) => allowed(c) && !denied(c)) : [];
|
|
125
|
+
if (f.allowBash && blocked.length) {
|
|
126
|
+
const cmd = blocked[0];
|
|
127
|
+
const base = f.baseRef ? f.baseRef.replace(/^origin\//, "") : "the base branch";
|
|
128
|
+
const says = `The run needed \`${blocked.join("`, `")}\`, which is not on the list of commands a woken run may use, so it was refused.`;
|
|
129
|
+
const why = checkRepoCommand(cmd);
|
|
130
|
+
if (why || !/^[A-Za-z0-9 ._\/:=@+,%-]+$/.test(cmd)) {
|
|
131
|
+
return {
|
|
132
|
+
cause: "command_not_listed", says,
|
|
133
|
+
fix: why
|
|
134
|
+
? `It cannot be added to ${REPO_COMMANDS_FILE_NAME}: ${why}. If the run needs it, give it a fixed form the list accepts (a script path such as ./node_modules/.bin/vitest, or a make/npm script), or run it yourself.`
|
|
135
|
+
: `Check the command in the transcript at ${f.runLog}; if it is safe to run unattended, add it to ${REPO_COMMANDS_FILE_NAME} on ${base} yourself.`,
|
|
136
|
+
dir: f.project,
|
|
137
|
+
lines: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const onBase = !!f.baseRef && ask(() => probe.branchOf(f.project), "") === base;
|
|
141
|
+
return {
|
|
142
|
+
cause: "command_not_listed", says,
|
|
143
|
+
fix: `If it is safe to run unattended, add it to ${REPO_COMMANDS_FILE_NAME} on ${base} (one command per line) and get that onto ${base} the way your team changes it. ` +
|
|
144
|
+
`The next run reads the list from the tip of ${base} after it fetches — not from its own worktree, so committing it on the run's branch does nothing.`,
|
|
145
|
+
dir: f.project,
|
|
146
|
+
lines: onBase ? [
|
|
147
|
+
`mkdir -p .pantheon && printf '%s\\n' ${shq(cmd)} >> ${REPO_COMMANDS_FILE_NAME}`,
|
|
148
|
+
`git add ${REPO_COMMANDS_FILE_NAME} && git commit -m ${shq(`let woken runs use ${cmd.split(/\s+/).slice(0, 2).join(" ")}`)}`,
|
|
149
|
+
] : [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (f.allowBash && wronglyBlocked.length) {
|
|
153
|
+
return {
|
|
154
|
+
cause: "command_failed",
|
|
155
|
+
says: `The run reported \`${wronglyBlocked.join("`, `")}\` as refused, but it is allowed — most likely it failed inside the command (a failing test, a missing dependency, the sandbox refusing a file or host).`,
|
|
156
|
+
fix: `Read the transcript at ${f.runLog} for what the command printed. Adding it to ${REPO_COMMANDS_FILE_NAME} would change nothing.`,
|
|
157
|
+
dir: f.worktree,
|
|
158
|
+
lines: [`cat ${shq(f.runLog)}`],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
87
161
|
if (!ask(() => probe.hasRemote(f.project), true)) {
|
|
88
162
|
return {
|
|
89
163
|
cause: "no_remote",
|
|
@@ -101,17 +175,39 @@ export function diagnoseRun(f, probe = gitProbe) {
|
|
|
101
175
|
says: `The work is uncommitted in your own checkout${on ? ` on ${on}` : ""} — a run never sees that, it starts from what origin has.`,
|
|
102
176
|
fix: "Commit and push it yourself, then the run (or your teammate) can see it. A worker deliberately never touches your working tree.",
|
|
103
177
|
dir: f.project,
|
|
104
|
-
lines: [`git status --short # ${list(dirty)}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${on}` : ""}`],
|
|
178
|
+
lines: [`git status --short # ${list(dirty).replace(/[\r\n]+/g, " ")}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${shq(on)}` : ""}`],
|
|
105
179
|
};
|
|
106
180
|
}
|
|
107
181
|
return silent(f);
|
|
108
182
|
}
|
|
183
|
+
export function ruleAllows(rule, command) {
|
|
184
|
+
const m = /^Bash\((.*)\)$/s.exec(rule);
|
|
185
|
+
if (!m)
|
|
186
|
+
return false;
|
|
187
|
+
const pat = m[1];
|
|
188
|
+
const cmd = command.trim();
|
|
189
|
+
if ((pat.match(/\*/g) ?? []).length === 1 && pat.endsWith(" *") && cmd === pat.slice(0, -2))
|
|
190
|
+
return true;
|
|
191
|
+
return new RegExp(`^${pat.split("*").map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s\\S]*")}$`).test(cmd);
|
|
192
|
+
}
|
|
193
|
+
const REPO_COMMANDS_FILE_NAME = ".pantheon/commands";
|
|
194
|
+
const SANDBOX_RE = /\b(?:bubblewrap|bwrap)\b[^\n]*(?:not found|not installed|missing|unavailable|required)|sandbox[^\n]{0,80}(?:is unavailable|not available|could not (?:be )?start|failed to start)/i;
|
|
195
|
+
const shq = (v) => /^[A-Za-z0-9._\/:=@+,%-]+$/.test(v) ? v : `'${v.replace(/'/g, "'\\''")}'`;
|
|
196
|
+
export function blockedCommands(text) {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const m of (text ?? "").matchAll(/^\s*BLOCKED-COMMAND:\s*`?([^`\n]{1,200}?)`?\s*$/gm)) {
|
|
199
|
+
const c = m[1].trim();
|
|
200
|
+
if (c && !out.includes(c))
|
|
201
|
+
out.push(c);
|
|
202
|
+
}
|
|
203
|
+
return out.slice(0, 5);
|
|
204
|
+
}
|
|
109
205
|
const silent = (f) => ({
|
|
110
206
|
cause: "silent",
|
|
111
207
|
says: "The run finished cleanly but sent nothing back, so the request is still open.",
|
|
112
208
|
fix: `Read what it did at ${f.runLog}. If it was right to stay silent, answer the teammate yourself; if not, the transcript says where it stopped.`,
|
|
113
209
|
dir: f.worktree,
|
|
114
|
-
lines: [`cat ${f.runLog}`],
|
|
210
|
+
lines: [`cat ${shq(f.runLog)}`],
|
|
115
211
|
});
|
|
116
212
|
export const BLOCKED_RE = /(?:permission|approval)[^\n]{0,40}\b(?:denied|required|needed|to use (?:the )?(?:bash|shell))|\bnot (?:allowed|permitted) to (?:run|execute|use (?:the )?(?:bash|shell))|\b(?:bash|shell|command|tool)\b[^\n]{0,30}\b(?:was |is )?(?:blocked|denied|not allowed|rejected)\b|read-only sandbox|sandbox[^\n]{0,20}read-only/i;
|
|
117
213
|
export function workNeedsCommands(kind, trigger, triggerAct) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const MIN_EXACT = 12;
|
|
5
|
+
const PEM = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----|-----BEGIN OPENSSH PRIVATE KEY-----/;
|
|
6
|
+
const TOKEN_SHAPES = [
|
|
7
|
+
{ re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, kind: "a GitHub token" },
|
|
8
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/, kind: "a GitHub token" },
|
|
9
|
+
{ re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, kind: "an AWS access key id" },
|
|
10
|
+
{ re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, kind: "a Slack token" },
|
|
11
|
+
{ re: /\bnpm_[A-Za-z0-9]{36}\b/, kind: "an npm token" },
|
|
12
|
+
{ re: /\bglpat-[A-Za-z0-9_-]{20,}\b/, kind: "a GitLab token" },
|
|
13
|
+
{ re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, kind: "an Anthropic API key" },
|
|
14
|
+
];
|
|
15
|
+
function derPrivateKey(b64) {
|
|
16
|
+
let b;
|
|
17
|
+
try {
|
|
18
|
+
b = Buffer.from(b64.replace(/[-_]/g, (c) => (c === "-" ? "+" : "/")), "base64");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (b.length < 32)
|
|
24
|
+
return false;
|
|
25
|
+
if (b.subarray(0, 15).toString("latin1") === "openssh-key-v1\0")
|
|
26
|
+
return true;
|
|
27
|
+
if (b[0] !== 0x30)
|
|
28
|
+
return false;
|
|
29
|
+
let i = 1;
|
|
30
|
+
const l = b[i];
|
|
31
|
+
if (l & 0x80)
|
|
32
|
+
i += 1 + (l & 0x7f);
|
|
33
|
+
else
|
|
34
|
+
i += 1;
|
|
35
|
+
return b[i] === 0x02 && b[i + 1] === 0x01 && (b[i + 2] === 0x00 || b[i + 2] === 0x01);
|
|
36
|
+
}
|
|
37
|
+
export function secretFileValues(home = homedir()) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const take = (file, re) => {
|
|
40
|
+
let text = "";
|
|
41
|
+
try {
|
|
42
|
+
text = readFileSync(join(home, file), "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (text.length > 1 << 20)
|
|
48
|
+
return;
|
|
49
|
+
for (const m of text.matchAll(re)) {
|
|
50
|
+
const v = (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
51
|
+
if (v.length >= MIN_EXACT)
|
|
52
|
+
out.push({ value: v, file: `~/${file}` });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
take(".aws/credentials", /^\s*(?:aws_secret_access_key|aws_session_token)\s*=\s*(\S+)/gim);
|
|
56
|
+
take(".config/gh/hosts.yml", /^\s*oauth_token:\s*(\S+)/gim);
|
|
57
|
+
take("AppData/Roaming/GitHub CLI/hosts.yml", /^\s*oauth_token:\s*(\S+)/gim);
|
|
58
|
+
take(".netrc", /\bpassword\s+(\S+)/gi);
|
|
59
|
+
take(".git-credentials", /^[a-z][a-z0-9+.-]*:\/\/[^:\s/]+:([^@\s]+)@/gim);
|
|
60
|
+
take(".docker/config.json", /"(?:auth|identitytoken)"\s*:\s*"([^"]+)"/gi);
|
|
61
|
+
take(".npmrc", /_(?:authToken|auth|password)\s*=\s*(\S+)/gi);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const squash = (t) => t.replace(/[^A-Za-z0-9+/]/g, "");
|
|
65
|
+
function decodedRuns(text) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const flat = text.replace(/\s+/g, "");
|
|
68
|
+
for (const m of flat.matchAll(/(?:[0-9a-fA-F]{2}){12,}/g)) {
|
|
69
|
+
if (out.length > 50)
|
|
70
|
+
break;
|
|
71
|
+
out.push(Buffer.from(m[0], "hex").toString("latin1"));
|
|
72
|
+
}
|
|
73
|
+
for (const m of flat.matchAll(/[A-Za-z0-9+/_-]{16,}={0,2}/g)) {
|
|
74
|
+
if (out.length > 100)
|
|
75
|
+
break;
|
|
76
|
+
try {
|
|
77
|
+
out.push(Buffer.from(m[0].replace(/[-_]/g, (c) => (c === "-" ? "+" : "/")), "base64").toString("latin1"));
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function secretGuard(own, home = homedir()) {
|
|
84
|
+
const exact = [];
|
|
85
|
+
if (own.privateKey && own.privateKey.length >= MIN_EXACT)
|
|
86
|
+
exact.push({ value: own.privateKey, kind: "this machine's private key" });
|
|
87
|
+
for (const t of own.tokens ?? [])
|
|
88
|
+
if (t && t.length >= MIN_EXACT && !exact.some((e) => e.value === t))
|
|
89
|
+
exact.push({ value: t, kind: "a team token this machine holds" });
|
|
90
|
+
let files = [];
|
|
91
|
+
let readAt = 0;
|
|
92
|
+
return {
|
|
93
|
+
check(text) {
|
|
94
|
+
if (!text)
|
|
95
|
+
return null;
|
|
96
|
+
for (const e of exact)
|
|
97
|
+
if (text.includes(e.value))
|
|
98
|
+
return { kind: e.kind };
|
|
99
|
+
if (exact.length) {
|
|
100
|
+
const flat = squash(text);
|
|
101
|
+
const decoded = decodedRuns(text);
|
|
102
|
+
for (const e of exact) {
|
|
103
|
+
const v = squash(e.value);
|
|
104
|
+
if (v.length >= MIN_EXACT && (flat.includes(v) || decoded.some((d) => d.includes(e.value) || squash(d).includes(v))))
|
|
105
|
+
return { kind: e.kind };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (PEM.test(text))
|
|
109
|
+
return { kind: "a private key (a PEM block)" };
|
|
110
|
+
for (const s of TOKEN_SHAPES)
|
|
111
|
+
if (s.re.test(text))
|
|
112
|
+
return { kind: s.kind };
|
|
113
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/_-]{60,}={0,2}/g))
|
|
114
|
+
if (derPrivateKey(m[0]))
|
|
115
|
+
return { kind: "something shaped like a private key (base64 key material)" };
|
|
116
|
+
if (Date.now() - readAt > 60_000) {
|
|
117
|
+
files = secretFileValues(home);
|
|
118
|
+
readAt = Date.now();
|
|
119
|
+
}
|
|
120
|
+
for (const f of files)
|
|
121
|
+
if (text.includes(f.value))
|
|
122
|
+
return { kind: `a secret from ${f.file}` };
|
|
123
|
+
return null;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function refuseSecrets(guard, fields) {
|
|
128
|
+
for (const [name, v] of Object.entries(fields)) {
|
|
129
|
+
for (const s of Array.isArray(v) ? v : [v]) {
|
|
130
|
+
if (typeof s !== "string")
|
|
131
|
+
continue;
|
|
132
|
+
const hit = guard.check(s);
|
|
133
|
+
if (hit) {
|
|
134
|
+
return `pantheon: NOT sent — the ${name} contains ${hit.kind}. Secrets never go on the ledger: every act is stored on the relay and shown to the whole team. ` +
|
|
135
|
+
`Take it out and send again. If a teammate asked for it, say that you cannot share it and that their human should ask yours directly.`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=secret-guard.js.map
|
package/dist/service.js
CHANGED
|
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from "
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { homedir, platform, userInfo } from "node:os";
|
|
4
4
|
import { which } from "./which.js";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { launcher } from "./install.js";
|
|
8
8
|
import { CONFIG_DIR } from "./config.js";
|
|
@@ -57,8 +57,44 @@ export function servicesUnavailable(plat = platform()) {
|
|
|
57
57
|
export function plistPath(kind = "worker") {
|
|
58
58
|
return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
|
|
59
59
|
}
|
|
60
|
-
export function
|
|
61
|
-
|
|
60
|
+
export function parseSystemdEnvironment(out) {
|
|
61
|
+
const env = {};
|
|
62
|
+
for (const line of out.split(/\r?\n/)) {
|
|
63
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
64
|
+
if (!m)
|
|
65
|
+
continue;
|
|
66
|
+
let v = m[2];
|
|
67
|
+
if (v.startsWith("$'") && v.endsWith("'"))
|
|
68
|
+
v = v.slice(2, -1).replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
|
|
69
|
+
env[m[1]] = v;
|
|
70
|
+
}
|
|
71
|
+
return env;
|
|
72
|
+
}
|
|
73
|
+
const sdEnvCache = new WeakMap();
|
|
74
|
+
export function systemdUserEnv(sys = realSys()) {
|
|
75
|
+
if (sys.platform !== "linux" || (sys.borrowed ?? borrowedHome)())
|
|
76
|
+
return {};
|
|
77
|
+
const home = homedir();
|
|
78
|
+
const hit = sdEnvCache.get(sys.exec);
|
|
79
|
+
if (hit && hit.home === home)
|
|
80
|
+
return hit.env;
|
|
81
|
+
let env = {};
|
|
82
|
+
try {
|
|
83
|
+
env = parseSystemdEnvironment(sys.exec("systemctl", ["--user", "show-environment"]));
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
sdEnvCache.set(sys.exec, { home, env });
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
89
|
+
export function unitPath(kind, env = systemdUserEnv()) {
|
|
90
|
+
const xdg = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(homedir(), ".config");
|
|
91
|
+
return join(xdg, "systemd", "user", `pantheon-${kind}.service`);
|
|
92
|
+
}
|
|
93
|
+
export function unitPaths(kind, env = systemdUserEnv(), shellEnv = process.env) {
|
|
94
|
+
return [...new Set([unitPath(kind, env), unitPath(kind, {}), unitPath(kind, shellEnv)])];
|
|
95
|
+
}
|
|
96
|
+
export function existingUnitPath(kind, env = systemdUserEnv(), shellEnv = process.env) {
|
|
97
|
+
return unitPaths(kind, env, shellEnv).find((p) => existsSync(p)) ?? null;
|
|
62
98
|
}
|
|
63
99
|
function xml(s) {
|
|
64
100
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -70,16 +106,13 @@ function argvFor(kind, args) {
|
|
|
70
106
|
const abs = whichPantheon();
|
|
71
107
|
return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
|
|
72
108
|
}
|
|
73
|
-
export function whichPantheon() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const p = execFileSync("/usr/bin/which", ["pantheon"], { encoding: "utf8" }).trim();
|
|
78
|
-
return p.startsWith("/") && existsSync(p) ? p : null;
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
109
|
+
export function whichPantheon(find = which) {
|
|
110
|
+
const p = find("pantheon");
|
|
111
|
+
if (!p)
|
|
81
112
|
return null;
|
|
82
|
-
|
|
113
|
+
if (platform() === "win32")
|
|
114
|
+
return p;
|
|
115
|
+
return p.startsWith("/") && existsSync(p) ? p : null;
|
|
83
116
|
}
|
|
84
117
|
export function servicePlist(kind, cwd, logDir, args) {
|
|
85
118
|
const argv = argvFor(kind, args);
|
|
@@ -107,9 +140,15 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
|
|
|
107
140
|
`;
|
|
108
141
|
}
|
|
109
142
|
export function systemdQuote(arg) {
|
|
110
|
-
return `"${arg
|
|
143
|
+
return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
|
|
111
144
|
}
|
|
112
|
-
const
|
|
145
|
+
const cEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
|
146
|
+
export const systemdEnvQuote = (assignment) => `"${cEscape(assignment).replace(/%/g, "%%")}"`;
|
|
147
|
+
const systemdValue = (v) => {
|
|
148
|
+
if (/[\r\n]/.test(v))
|
|
149
|
+
throw new Error(`a path with a line break in it cannot be written into a systemd unit: ${JSON.stringify(v)}`);
|
|
150
|
+
return v.replace(/%/g, "%%");
|
|
151
|
+
};
|
|
113
152
|
export function systemdUnit(kind, cwd, args, path = servicePath()) {
|
|
114
153
|
const description = kind === "relay"
|
|
115
154
|
? "pantheon relay — the coordination plane for a space"
|
|
@@ -124,7 +163,7 @@ After=network-online.target
|
|
|
124
163
|
Type=simple
|
|
125
164
|
WorkingDirectory=${systemdValue(cwd)}
|
|
126
165
|
ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
|
|
127
|
-
Environment=${
|
|
166
|
+
Environment=${systemdEnvQuote(`PATH=${path}`)}
|
|
128
167
|
Restart=always
|
|
129
168
|
RestartSec=30
|
|
130
169
|
|
|
@@ -147,6 +186,9 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
147
186
|
if (noServices)
|
|
148
187
|
throw new Error(noServices.replace(/^not available — /, ""));
|
|
149
188
|
const out = [];
|
|
189
|
+
let loaded = true;
|
|
190
|
+
let reason;
|
|
191
|
+
const done = () => Object.assign(out, { loaded, ...(reason ? { reason } : {}) });
|
|
150
192
|
const abs = resolve(cwd);
|
|
151
193
|
const logDir = serviceLogDir();
|
|
152
194
|
if (sys.platform === "darwin") {
|
|
@@ -168,8 +210,16 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
168
210
|
out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
|
|
169
211
|
}
|
|
170
212
|
catch (e) {
|
|
171
|
-
|
|
172
|
-
|
|
213
|
+
const why = e.message.split("\n")[0];
|
|
214
|
+
loaded = false;
|
|
215
|
+
reason = why;
|
|
216
|
+
out.push(` could not load it: ${why}`);
|
|
217
|
+
if ((sys.borrowed ?? borrowedHome)()) {
|
|
218
|
+
reason = `HOME (${homedir()}) is not your own home, so launchd was not touched`;
|
|
219
|
+
out.push(` left unloaded — ${reason}; your own services are unchanged`);
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
out.push(` load it yourself with: launchctl load -w ${p}`);
|
|
173
223
|
}
|
|
174
224
|
out.push(` logs: ${join(logDir, `${kind}.log`)}`);
|
|
175
225
|
out.push(` stop: pantheon service uninstall ${kind}`);
|
|
@@ -178,8 +228,8 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
178
228
|
if (kind === "menubar" || kind === "notifier") {
|
|
179
229
|
throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
|
|
180
230
|
}
|
|
181
|
-
const unit = unitPath(kind);
|
|
182
|
-
mkdirSync(
|
|
231
|
+
const unit = unitPath(kind, systemdUserEnv(sys));
|
|
232
|
+
mkdirSync(dirname(unit), { recursive: true });
|
|
183
233
|
writeFileSync(unit, systemdUnit(kind, abs, args));
|
|
184
234
|
out.push(` wrote ${unit}`);
|
|
185
235
|
try {
|
|
@@ -196,17 +246,15 @@ export function installService(kind, cwd, args, sys = realSys()) {
|
|
|
196
246
|
out.push(` ! the ${kind} stops when you log out (closing an SSH session counts). To keep it running: loginctl enable-linger ${sys.user || "$USER"}`);
|
|
197
247
|
}
|
|
198
248
|
}
|
|
199
|
-
return
|
|
249
|
+
return done();
|
|
200
250
|
}
|
|
201
251
|
export function uninstallService(kind, sys = realSys()) {
|
|
202
252
|
const out = [];
|
|
203
253
|
if (sys.platform === "win32") {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
unlinkSync(
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
return [` no ${kind} service installed`];
|
|
254
|
+
const strays = unitPaths(kind, {}).filter((u) => existsSync(u));
|
|
255
|
+
for (const u of strays)
|
|
256
|
+
unlinkSync(u);
|
|
257
|
+
return strays.length ? strays.map((u) => ` removed ${u}`) : [` no ${kind} service installed`];
|
|
210
258
|
}
|
|
211
259
|
if (sys.platform === "darwin") {
|
|
212
260
|
const p = plistPath(kind);
|
|
@@ -228,8 +276,8 @@ export function uninstallService(kind, sys = realSys()) {
|
|
|
228
276
|
out.push(` removed ${p}`);
|
|
229
277
|
}
|
|
230
278
|
else {
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
279
|
+
const units = unitPaths(kind, systemdUserEnv(sys)).filter((u) => existsSync(u));
|
|
280
|
+
if (!units.length)
|
|
233
281
|
return [` no ${kind} service installed`];
|
|
234
282
|
try {
|
|
235
283
|
sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
|
|
@@ -238,12 +286,14 @@ export function uninstallService(kind, sys = realSys()) {
|
|
|
238
286
|
catch {
|
|
239
287
|
out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
|
|
240
288
|
}
|
|
241
|
-
|
|
289
|
+
for (const u of units)
|
|
290
|
+
unlinkSync(u);
|
|
242
291
|
try {
|
|
243
292
|
sys.exec("systemctl", ["--user", "daemon-reload"]);
|
|
244
293
|
}
|
|
245
294
|
catch { }
|
|
246
|
-
|
|
295
|
+
for (const u of units)
|
|
296
|
+
out.push(` removed ${u}`);
|
|
247
297
|
}
|
|
248
298
|
return out;
|
|
249
299
|
}
|
|
@@ -272,7 +322,8 @@ export function serviceState(kind, exec = realExec, plat = platform(), uid = pro
|
|
|
272
322
|
const dis = outOf(exec, "launchctl", ["print-disabled", `gui/${uid}`]).out;
|
|
273
323
|
const disabled = new RegExp(`"${LABEL(kind).replace(/\./g, "\\.")}"\\s*=>\\s*(disabled|true)`).test(dis);
|
|
274
324
|
const pr = outOf(exec, "launchctl", ["print", `gui/${uid}/${LABEL(kind)}`]);
|
|
275
|
-
|
|
325
|
+
const from = pr.ok ? /^\s*path = (.+)$/m.exec(pr.out)?.[1]?.trim() : undefined;
|
|
326
|
+
return { disabled, loaded: pr.ok, running: pr.ok && /^\s*state = running/m.test(pr.out), ...(from ? { from } : {}) };
|
|
276
327
|
}
|
|
277
328
|
const en = outOf(exec, "systemctl", ["--user", "is-enabled", `pantheon-${kind}`]).out.trim();
|
|
278
329
|
const act = outOf(exec, "systemctl", ["--user", "is-active", `pantheon-${kind}`]).out.trim();
|
|
@@ -288,6 +339,11 @@ export function restartService(kind, exec = realExec, opts = {}) {
|
|
|
288
339
|
return [` ${kind}: left stopped (you stopped it) — start it again: pantheon service install ${kind}`];
|
|
289
340
|
try {
|
|
290
341
|
if (plat === "darwin") {
|
|
342
|
+
if (st.loaded && st.from && resolve(st.from) !== resolve(plistPath(kind)) && existsSync(plistPath(kind))) {
|
|
343
|
+
exec("launchctl", ["bootout", `gui/${uid}/${LABEL(kind)}`]);
|
|
344
|
+
exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
|
|
345
|
+
return [` ${kind}: was running from ${st.from}, not this machine's own definition — reloaded from ${plistPath(kind)} on the new code`];
|
|
346
|
+
}
|
|
291
347
|
if (st.loaded && !st.running)
|
|
292
348
|
return [` ${kind}: not running — left as it is (launchd starts it again on its own schedule)`];
|
|
293
349
|
if (!st.loaded) {
|
|
@@ -335,11 +391,11 @@ export function refreshServiceDefinition(kind, plat = platform()) {
|
|
|
335
391
|
}
|
|
336
392
|
if (plat === "win32")
|
|
337
393
|
return { changed: false };
|
|
338
|
-
const u =
|
|
339
|
-
if (!
|
|
394
|
+
const u = existingUnitPath(kind);
|
|
395
|
+
if (!u)
|
|
340
396
|
return { changed: false };
|
|
341
397
|
const unit = readFileSync(u, "utf8");
|
|
342
|
-
if (/^Environment="PATH=/m.test(unit))
|
|
398
|
+
if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit))
|
|
343
399
|
return { changed: false };
|
|
344
400
|
const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
|
|
345
401
|
const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
|
|
@@ -359,7 +415,7 @@ export function serviceInstalled(kind, plat = platform()) {
|
|
|
359
415
|
try {
|
|
360
416
|
if (plat === "win32")
|
|
361
417
|
return false;
|
|
362
|
-
return
|
|
418
|
+
return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
|
|
363
419
|
}
|
|
364
420
|
catch {
|
|
365
421
|
return false;
|