@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.
Files changed (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
@@ -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 unitPath(kind) {
61
- return join(homedir(), ".config", "systemd", "user", `pantheon-${kind}.service`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -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
- if (platform() === "win32")
75
- return which("pantheon");
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.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
143
+ return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
111
144
  }
112
- const systemdValue = (v) => v.replace(/%/g, "%%");
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=${systemdQuote(`PATH=${path}`)}
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
- out.push(` could not load it: ${e.message.split("\n")[0]}`);
172
- out.push(` load it yourself with: launchctl load -w ${p}`);
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(join(homedir(), ".config", "systemd", "user"), { recursive: true });
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 out;
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 stray = unitPath(kind);
205
- if (existsSync(stray)) {
206
- unlinkSync(stray);
207
- return [` removed ${stray}`];
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 unit = unitPath(kind);
232
- if (!existsSync(unit))
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
- unlinkSync(unit);
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
- out.push(` removed ${unit}`);
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
- return { disabled, loaded: pr.ok, running: pr.ok && /^\s*state = running/m.test(pr.out) };
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 = unitPath(kind);
339
- if (!existsSync(u))
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 existsSync(plat === "darwin" ? plistPath(kind) : unitPath(kind));
418
+ return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
363
419
  }
364
420
  catch {
365
421
  return false;