@rubytech/create-maxy-code 0.1.389 → 0.1.392
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/__tests__/claude-bin.test.js +51 -0
- package/dist/__tests__/claude-upgrade-privilege.test.js +17 -0
- package/dist/__tests__/launchd-plist.test.js +36 -1
- package/dist/__tests__/macos-darwin-branch.test.js +18 -0
- package/dist/__tests__/neo4j-datadir.test.js +14 -0
- package/dist/claude-bin.js +78 -0
- package/dist/claude-upgrade-privilege.js +21 -0
- package/dist/index.js +241 -34
- package/dist/launchd-plist.js +25 -9
- package/dist/neo4j-datadir.js +15 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -4
- package/payload/platform/scripts/smoke-boot-services.sh +35 -10
- package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts +11 -0
- package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js +37 -0
- package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +8 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +5 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +28 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +54 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-CerrEc_m.js → AdminLoginScreens-CL27ZV86.js} +1 -1
- package/payload/server/public/assets/{AdminShell-cRTvNRbo.js → AdminShell-CBcSh9Yd.js} +1 -1
- package/payload/server/public/assets/{Checkbox-Dh2pLMFN.js → Checkbox-DRIcYN9r.js} +1 -1
- package/payload/server/public/assets/{Transcript-B_GVJujB.js → Transcript-CRc72hkG.js} +1 -1
- package/payload/server/public/assets/{admin-BXaYelnR.js → admin-iKYYnv3w.js} +1 -1
- package/payload/server/public/assets/{browser-fhjGE7fH.js → browser--8gzAe-E.js} +1 -1
- package/payload/server/public/assets/{calendar-PnZudAtc.js → calendar-7xAT81N_.js} +1 -1
- package/payload/server/public/assets/chat-B8Z9W42Z.js +1 -0
- package/payload/server/public/assets/chevron-left-DDselYau.js +1 -0
- package/payload/server/public/assets/data-Ece320dZ.js +1 -0
- package/payload/server/public/assets/{graph-CycO3tkW.js → graph-B3haz2DN.js} +1 -1
- package/payload/server/public/assets/{graph-labels-DlSsSpBV.js → graph-labels-Ba_fyw6I.js} +1 -1
- package/payload/server/public/assets/{operator-L4kBC-zS.js → operator-BYL1IFz9.js} +1 -1
- package/payload/server/public/assets/page-Bj6lB07z.js +32 -0
- package/payload/server/public/assets/{page-D5E-Ng4D.js → page-CLKVCquw.js} +1 -1
- package/payload/server/public/assets/{public-BmnajF3K.js → public-BKJUKC76.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-W5HhvAbo.js → rotate-ccw-BypBbSI4.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-D1TI1xvd.css → useSubAccountSwitcher-4qilMyPX.css} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +5 -5
- package/payload/server/public/chat.html +8 -8
- package/payload/server/public/data.html +7 -7
- package/payload/server/public/graph.html +8 -8
- package/payload/server/public/index.html +10 -10
- package/payload/server/public/operator.html +10 -10
- package/payload/server/public/public.html +8 -8
- package/payload/server/server.js +157 -12
- package/payload/server/public/assets/chat-Dy_zrjKS.js +0 -1
- package/payload/server/public/assets/chevron-left-IB6TmMZ_.js +0 -1
- package/payload/server/public/assets/data-B2IcjAj6.js +0 -1
- package/payload/server/public/assets/page-M8sD9LOi.js +0 -32
- /package/payload/server/public/assets/{useSubAccountSwitcher-UqZbmzYy.js → useSubAccountSwitcher-CsRv6MEd.js} +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// acceptance grid for claude-bin.ts (Task 1395 gap 4).
|
|
2
|
+
//
|
|
3
|
+
// Pure resolver: version parse/compare, canonical pick (highest version),
|
|
4
|
+
// and PATH enumeration with injected fs/spawn so no real binary is probed.
|
|
5
|
+
// Runs under node --test alongside the rest of the package.
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { pickCanonicalClaude, compareVersions, parseClaudeVersion, enumerateClaudeCandidates, } from "../claude-bin.js";
|
|
9
|
+
test("parseClaudeVersion extracts leading version", () => {
|
|
10
|
+
assert.equal(parseClaudeVersion("2.1.201 (Claude Code)"), "2.1.201");
|
|
11
|
+
assert.equal(parseClaudeVersion(" 2.1.153 (Claude Code)\n"), "2.1.153");
|
|
12
|
+
assert.equal(parseClaudeVersion("not installed"), null);
|
|
13
|
+
});
|
|
14
|
+
test("compareVersions orders by numeric parts", () => {
|
|
15
|
+
assert.ok(compareVersions("2.1.201", "2.1.153") > 0);
|
|
16
|
+
assert.ok(compareVersions("2.1.153", "2.1.201") < 0);
|
|
17
|
+
assert.equal(compareVersions("2.1.1", "2.1.1"), 0);
|
|
18
|
+
assert.ok(compareVersions("2.2.0", "2.1.999") > 0);
|
|
19
|
+
});
|
|
20
|
+
test("pickCanonicalClaude picks highest version (nvm over stale Cask)", () => {
|
|
21
|
+
const pick = pickCanonicalClaude([
|
|
22
|
+
{ path: "/usr/local/bin/claude", version: "2.1.153" },
|
|
23
|
+
{ path: "/Users/x/.nvm/versions/node/v22/bin/claude", version: "2.1.201" },
|
|
24
|
+
]);
|
|
25
|
+
assert.equal(pick, "/Users/x/.nvm/versions/node/v22/bin/claude");
|
|
26
|
+
});
|
|
27
|
+
test("pickCanonicalClaude: empty -> null, ties -> first", () => {
|
|
28
|
+
assert.equal(pickCanonicalClaude([]), null);
|
|
29
|
+
assert.equal(pickCanonicalClaude([
|
|
30
|
+
{ path: "/a/claude", version: "2.1.1" },
|
|
31
|
+
{ path: "/b/claude", version: "2.1.1" },
|
|
32
|
+
]), "/a/claude");
|
|
33
|
+
});
|
|
34
|
+
test("enumerateClaudeCandidates dedupes by realpath, skips non-exec/unversioned", () => {
|
|
35
|
+
const versions = {
|
|
36
|
+
"/opt/homebrew/bin/claude": "2.1.201 (Claude Code)",
|
|
37
|
+
"/usr/local/bin/claude": "2.1.153 (Claude Code)",
|
|
38
|
+
};
|
|
39
|
+
const deps = {
|
|
40
|
+
isExecutable: (p) => p === "/opt/homebrew/bin/claude" || p === "/usr/local/bin/claude" || p === "/dup/claude",
|
|
41
|
+
// /dup/claude is a symlink to the homebrew one -> deduped away
|
|
42
|
+
realpath: (p) => (p === "/dup/claude" ? "/opt/homebrew/bin/claude" : p),
|
|
43
|
+
runVersion: (p) => versions[p] ?? null,
|
|
44
|
+
};
|
|
45
|
+
const cands = enumerateClaudeCandidates("/opt/homebrew/bin:/usr/local/bin:/dup:/empty", deps);
|
|
46
|
+
assert.deepEqual(cands, [
|
|
47
|
+
{ path: "/opt/homebrew/bin/claude", version: "2.1.201" },
|
|
48
|
+
{ path: "/usr/local/bin/claude", version: "2.1.153" },
|
|
49
|
+
]);
|
|
50
|
+
assert.equal(pickCanonicalClaude(cands), "/opt/homebrew/bin/claude");
|
|
51
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { decideClaudeUpgradePrivilege } from "../claude-upgrade-privilege.js";
|
|
4
|
+
test("darwin: writable user-owned prefix runs the upgrade without sudo", () => {
|
|
5
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: true, canSudo: false }), "run-no-sudo");
|
|
6
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: true, canSudo: true }), "run-no-sudo");
|
|
7
|
+
});
|
|
8
|
+
test("darwin: non-writable prefix falls back to sudo when available, else skips", () => {
|
|
9
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: false, canSudo: true }), "run-sudo");
|
|
10
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: false, canSudo: false }), "skip");
|
|
11
|
+
});
|
|
12
|
+
test("linux: unchanged — sudo when available, skip when not; prefixWritable is ignored", () => {
|
|
13
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: false, canSudo: true }), "run-sudo");
|
|
14
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: false, canSudo: false }), "skip");
|
|
15
|
+
// prefixWritable must not change the Linux decision (root-owned global prefix assumed).
|
|
16
|
+
assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: true, canSudo: false }), "skip");
|
|
17
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// package so `node --test dist/__tests__/*.test.js` picks it up after build.
|
|
11
11
|
import test from "node:test";
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
|
-
import { renderPlist } from "../launchd-plist.js";
|
|
13
|
+
import { renderPlist, buildSessionManagerWrapper } from "../launchd-plist.js";
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Standard render — every required field present, no escaping needed.
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
@@ -193,3 +193,38 @@ test("renderPlist omits WorkingDirectory key when workingDirectory is absent", (
|
|
|
193
193
|
assert.match(xml, /<key>KeepAlive<\/key>/);
|
|
194
194
|
assert.match(xml, /<key>RunAtLoad<\/key>/);
|
|
195
195
|
});
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// buildSessionManagerWrapper — Task 1395 gap 2
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
test("buildSessionManagerWrapper sources .env and execs the manager", () => {
|
|
200
|
+
const w = buildSessionManagerWrapper({
|
|
201
|
+
envPath: "/Users/x/.maxy/.env",
|
|
202
|
+
installDir: "/Users/x/maxy-code",
|
|
203
|
+
nodeBin: "/usr/local/bin/node",
|
|
204
|
+
persistDir: "/Users/x/.maxy",
|
|
205
|
+
brandPort: 19200,
|
|
206
|
+
});
|
|
207
|
+
assert.match(w, /^#!\/bin\/bash\n/);
|
|
208
|
+
assert.match(w, /set -a; \[ -f "\/Users\/x\/\.maxy\/\.env" \] && \. "\/Users\/x\/\.maxy\/\.env"; set \+a/);
|
|
209
|
+
assert.match(w, /export CLAUDE_SESSION_MANAGER_PERSIST_DIR="\/Users\/x\/\.maxy"/);
|
|
210
|
+
assert.match(w, /export PLATFORM_ROOT="\/Users\/x\/maxy-code\/platform"/);
|
|
211
|
+
assert.match(w, /export MAXY_BRAND_PORT="19200"/);
|
|
212
|
+
assert.match(w, /export PLATFORM_PORT="19200"/);
|
|
213
|
+
assert.match(w, /cd "\/Users\/x\/maxy-code\/platform\/services\/claude-session-manager"/);
|
|
214
|
+
assert.match(w, /exec \/usr\/local\/bin\/node dist\/index\.js\n$/);
|
|
215
|
+
});
|
|
216
|
+
test("session-manager plist renders bash wrapper as sole ProgramArguments", () => {
|
|
217
|
+
const xml = renderPlist({
|
|
218
|
+
label: "com.rubytech.maxy-code-claude-session-manager",
|
|
219
|
+
programArguments: ["/bin/bash", "/Users/x/.maxy/claude-session-manager-wrapper.sh"],
|
|
220
|
+
stdoutPath: "/Users/x/.maxy/logs/server.log",
|
|
221
|
+
stderrPath: "/Users/x/.maxy/logs/server.log",
|
|
222
|
+
keepAlive: true,
|
|
223
|
+
runAtLoad: true,
|
|
224
|
+
workingDirectory: "/Users/x/maxy-code/platform/services/claude-session-manager",
|
|
225
|
+
});
|
|
226
|
+
assert.match(xml, /<string>com\.rubytech\.maxy-code-claude-session-manager<\/string>/);
|
|
227
|
+
assert.match(xml, /<string>\/bin\/bash<\/string>\s*<string>\/Users\/x\/\.maxy\/claude-session-manager-wrapper\.sh<\/string>/);
|
|
228
|
+
assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
|
|
229
|
+
assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
|
|
230
|
+
});
|
|
@@ -81,3 +81,21 @@ test("uninstall darwin path bootsout the LaunchAgent and removes the plist", ()
|
|
|
81
81
|
// brand-keyed — install + uninstall both depend on the same label shape.
|
|
82
82
|
assert.match(SRC, /function launchdLabel\(\):\s*string\s*\{\s*return\s*`com\.rubytech\.\$\{BRAND\.hostname\}`/);
|
|
83
83
|
});
|
|
84
|
+
test("resetNeo4jAuth has a darwin branch that uses brew services and no sudo", () => {
|
|
85
|
+
// On darwin Neo4j runs under a user launchd agent (brew services) and its
|
|
86
|
+
// datadir is user-owned — no systemctl, no sudo. Task 1394. Static-grep, not
|
|
87
|
+
// behaviour: resetNeo4jAuth spawns brew/neo4j-admin and can't run headless.
|
|
88
|
+
const fn = SRC.slice(SRC.indexOf("function resetNeo4jAuth"), SRC.indexOf("function redactInstallLogs"));
|
|
89
|
+
assert.match(fn, /platform === "darwin"/, "resetNeo4jAuth must branch on darwin");
|
|
90
|
+
assert.match(fn, /brew["']?,\s*\[["']services["']\s*,\s*["']stop["']/, "darwin branch stops neo4j via brew services");
|
|
91
|
+
assert.match(fn, /brew["']?,\s*\[["']services["']\s*,\s*["']start["']/, "darwin branch starts neo4j via brew services");
|
|
92
|
+
assert.match(fn, /neo4j-darwin-auth-reset/, "darwin branch emits the reset observability marker");
|
|
93
|
+
// The darwin branch must NOT shell out to systemctl or sudo (that path is
|
|
94
|
+
// Linux-only). Match invocation shapes — quoted command literals and the
|
|
95
|
+
// `sudo: true` option — not prose, so the branch's own explanatory comment
|
|
96
|
+
// ("no systemctl, no sudo") does not defeat the assertion.
|
|
97
|
+
const darwinBranch = fn.slice(fn.indexOf('platform === "darwin"'), fn.indexOf("} else {"));
|
|
98
|
+
assert.ok(!/["']systemctl["']/.test(darwinBranch), "darwin branch must not invoke systemctl");
|
|
99
|
+
assert.ok(!/["']sudo["']/.test(darwinBranch), "darwin branch must not spawn sudo");
|
|
100
|
+
assert.ok(!/sudo:\s*true/.test(darwinBranch), "darwin branch must not pass { sudo: true } on the user-owned datadir");
|
|
101
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveNeo4jDataDir } from "../neo4j-datadir.js";
|
|
4
|
+
test("darwin resolves the Homebrew datadir under the brew prefix (Intel + Apple Silicon)", () => {
|
|
5
|
+
assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/usr/local", dedicated: false, hostname: "maxy-code" }), "/usr/local/var/neo4j");
|
|
6
|
+
assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/opt/homebrew", dedicated: false, hostname: "maxy-code" }), "/opt/homebrew/var/neo4j");
|
|
7
|
+
});
|
|
8
|
+
test("darwin ignores dedicated — only the shared Homebrew instance exists on darwin", () => {
|
|
9
|
+
assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/usr/local", dedicated: true, hostname: "realagent-code" }), "/usr/local/var/neo4j");
|
|
10
|
+
});
|
|
11
|
+
test("linux shared and dedicated datadirs are unchanged", () => {
|
|
12
|
+
assert.equal(resolveNeo4jDataDir({ platform: "linux", brewPrefix: "", dedicated: false, hostname: "maxy-code" }), "/var/lib/neo4j");
|
|
13
|
+
assert.equal(resolveNeo4jDataDir({ platform: "linux", brewPrefix: "", dedicated: true, hostname: "realagent" }), "/var/lib/neo4j-realagent");
|
|
14
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Task 1395 gap 4 — resolve one canonical `claude` binary.
|
|
2
|
+
//
|
|
3
|
+
// A Mac can carry more than one `claude` on PATH at different versions — e.g.
|
|
4
|
+
// an npm-global (nvm/Homebrew node) claude AND a Homebrew Cask `claude-code` at
|
|
5
|
+
// /usr/local/bin/claude. Whichever the runtime PATH hits first wins, which
|
|
6
|
+
// during the Task 1393 migration was the STALE Cask (2.1.153) rather than the
|
|
7
|
+
// logged-in nvm binary (2.1.201). The installer resolves a single canonical
|
|
8
|
+
// path and persists it as CLAUDE_BIN in `.env`; every runtime spawn then uses
|
|
9
|
+
// that exact binary instead of a PATH lottery.
|
|
10
|
+
//
|
|
11
|
+
// Canonical = highest version. This is deterministic and picks the newest
|
|
12
|
+
// claude, which is the defensible choice when two are present. All fs/spawn I/O
|
|
13
|
+
// is injected so the resolver stays pure and unit-testable.
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
/** Extract the leading dotted-numeric version from `claude --version` output,
|
|
16
|
+
* e.g. "2.1.201 (Claude Code)" -> "2.1.201". Returns null when absent. */
|
|
17
|
+
export function parseClaudeVersion(raw) {
|
|
18
|
+
const m = raw.trim().match(/(\d+(?:\.\d+)+)/);
|
|
19
|
+
return m ? m[1] : null;
|
|
20
|
+
}
|
|
21
|
+
function toParts(v) {
|
|
22
|
+
const m = v.trim().match(/^(\d+(?:\.\d+)*)/);
|
|
23
|
+
return m ? m[1].split(".").map((n) => parseInt(n, 10)) : [];
|
|
24
|
+
}
|
|
25
|
+
/** Numeric dotted-version compare (a<b -> <0, a>b -> >0). Missing parts = 0. */
|
|
26
|
+
export function compareVersions(a, b) {
|
|
27
|
+
const pa = toParts(a);
|
|
28
|
+
const pb = toParts(b);
|
|
29
|
+
const len = Math.max(pa.length, pb.length);
|
|
30
|
+
for (let i = 0; i < len; i++) {
|
|
31
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
32
|
+
if (d !== 0)
|
|
33
|
+
return d;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/** Pick the highest-version candidate's path; first-wins on ties. Null when the
|
|
38
|
+
* list is empty. */
|
|
39
|
+
export function pickCanonicalClaude(candidates) {
|
|
40
|
+
if (candidates.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
let best = candidates[0];
|
|
43
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
44
|
+
if (compareVersions(candidates[i].version, best.version) > 0)
|
|
45
|
+
best = candidates[i];
|
|
46
|
+
}
|
|
47
|
+
return best.path;
|
|
48
|
+
}
|
|
49
|
+
/** Enumerate every distinct `claude` reachable from `pathEnv` (colon-separated
|
|
50
|
+
* PATH), deduped by realpath, each probed for its version. Non-executable
|
|
51
|
+
* entries, symlink duplicates, and version-probe failures are skipped. */
|
|
52
|
+
export function enumerateClaudeCandidates(pathEnv, deps) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const dir of pathEnv.split(":").filter(Boolean)) {
|
|
56
|
+
const cand = join(dir, "claude");
|
|
57
|
+
if (!deps.isExecutable(cand))
|
|
58
|
+
continue;
|
|
59
|
+
let real;
|
|
60
|
+
try {
|
|
61
|
+
real = deps.realpath(cand);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
real = cand;
|
|
65
|
+
}
|
|
66
|
+
if (seen.has(real))
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(real);
|
|
69
|
+
const raw = deps.runVersion(cand);
|
|
70
|
+
if (!raw)
|
|
71
|
+
continue;
|
|
72
|
+
const version = parseClaudeVersion(raw);
|
|
73
|
+
if (!version)
|
|
74
|
+
continue;
|
|
75
|
+
out.push({ path: cand, version });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure decision for whether the phase-3 `npm install -g @anthropic-ai/claude-code`
|
|
2
|
+
// upgrade runs under sudo, runs unprivileged, or is skipped. Peer of
|
|
3
|
+
// apt-resolve.ts / brew-resolve.ts: no spawnSync, no fs — the caller injects the
|
|
4
|
+
// probed inputs and applies the result.
|
|
5
|
+
//
|
|
6
|
+
// Root cause this encodes: on darwin the npm global prefix (nvm or Homebrew) is
|
|
7
|
+
// user-owned, so `npm install -g` needs no sudo; wrapping it in sudo forces a
|
|
8
|
+
// non-interactive password prompt that hard-fails. On Linux the global prefix is
|
|
9
|
+
// root-owned by default, so the existing sudo-or-skip behaviour is preserved
|
|
10
|
+
// unchanged (prefixWritable is not consulted on Linux).
|
|
11
|
+
export function decideClaudeUpgradePrivilege(input) {
|
|
12
|
+
if (input.platform === "darwin") {
|
|
13
|
+
if (input.prefixWritable)
|
|
14
|
+
return "run-no-sudo";
|
|
15
|
+
if (input.canSudo)
|
|
16
|
+
return "run-sudo";
|
|
17
|
+
return "skip";
|
|
18
|
+
}
|
|
19
|
+
// linux: unchanged from the historical `isLinux() && !canSudo()` skip.
|
|
20
|
+
return input.canSudo ? "run-sudo" : "skip";
|
|
21
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync, accessSync, constants as fsConstants } from "node:fs";
|
|
4
4
|
import { registerSpecialistAgentsAt, SpecialistSymlinkCollision } from "./specialist-registration.js";
|
|
5
5
|
import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
|
|
6
6
|
import { resolve, join, dirname } from "node:path";
|
|
@@ -13,8 +13,11 @@ import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
|
|
|
13
13
|
import { resolveNeo4jPidfileScrub } from "./neo4j-pidfile-scrub.js";
|
|
14
14
|
import { runInitLogging } from "./init-logging.js";
|
|
15
15
|
import { requireSupportedPlatform, detectPlatform } from "./platform-detect.js";
|
|
16
|
+
import { decideClaudeUpgradePrivilege } from "./claude-upgrade-privilege.js";
|
|
17
|
+
import { resolveNeo4jDataDir } from "./neo4j-datadir.js";
|
|
16
18
|
import { protectCommands, unprotectCommands, lsattrShowsImmutable } from "./install-immutability.js";
|
|
17
|
-
import { renderPlist } from "./launchd-plist.js";
|
|
19
|
+
import { renderPlist, buildSessionManagerWrapper } from "./launchd-plist.js";
|
|
20
|
+
import { enumerateClaudeCandidates, pickCanonicalClaude } from "./claude-bin.js";
|
|
18
21
|
import { installAllBrewPackages } from "./brew-install.js";
|
|
19
22
|
import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
|
|
20
23
|
import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
|
|
@@ -278,6 +281,29 @@ function canSudo() {
|
|
|
278
281
|
const result = spawnSync("sudo", ["-n", "true"], { stdio: "pipe", timeout: 5_000 });
|
|
279
282
|
return result.status === 0;
|
|
280
283
|
}
|
|
284
|
+
/** True when the npm global prefix's node_modules dir is writable by the current
|
|
285
|
+
* user (nvm/Homebrew installs) — meaning `npm install -g` needs no sudo. */
|
|
286
|
+
function npmGlobalPrefixWritable() {
|
|
287
|
+
try {
|
|
288
|
+
const prefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf-8", timeout: 10_000 }).trim();
|
|
289
|
+
accessSync(join(prefix, "lib", "node_modules"), fsConstants.W_OK);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Resolve the Homebrew prefix (`/usr/local` on Intel, `/opt/homebrew` on Apple
|
|
297
|
+
* Silicon). Falls back to the arch default if `brew --prefix` is unavailable. */
|
|
298
|
+
function resolveBrewPrefix() {
|
|
299
|
+
try {
|
|
300
|
+
const out = execFileSync("brew", ["--prefix"], { encoding: "utf-8", timeout: 10_000 }).trim();
|
|
301
|
+
if (out)
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
catch { /* fall through to arch default */ }
|
|
305
|
+
return isArm64() ? "/opt/homebrew" : "/usr/local";
|
|
306
|
+
}
|
|
281
307
|
// verified-not-asserted apt-dep reconciliation.
|
|
282
308
|
// resolve package-name aliases before probing dpkg, so the
|
|
283
309
|
// post-install check no longer false-negatives when apt resolves a virtual
|
|
@@ -1023,13 +1049,44 @@ function installNodejs() {
|
|
|
1023
1049
|
console.log(" [privileged] apt-get install");
|
|
1024
1050
|
shell("apt-get", ["install", "-y", "nodejs"], { sudo: true });
|
|
1025
1051
|
}
|
|
1052
|
+
// Task 1395 gap 4 — the single canonical `claude` path resolved for this
|
|
1053
|
+
// install, pinned into `.env` as CLAUDE_BIN by installServiceDarwin. Null on
|
|
1054
|
+
// Linux (and darwin when unresolved) — callers fall back to bare `claude`.
|
|
1055
|
+
let RESOLVED_CLAUDE_BIN = null;
|
|
1056
|
+
/** Resolve the highest-version `claude` reachable from the runtime PATH the
|
|
1057
|
+
* launchd wrapper will use (`/opt/homebrew/bin:/usr/local/bin:$PATH`). Darwin
|
|
1058
|
+
* only — a Mac can carry two claude binaries at different versions; the
|
|
1059
|
+
* logged-in one must win, not whichever the bare PATH hits first. */
|
|
1060
|
+
function resolveCanonicalClaudeBin() {
|
|
1061
|
+
if (process.platform !== "darwin")
|
|
1062
|
+
return null;
|
|
1063
|
+
const runtimePath = `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? ""}`;
|
|
1064
|
+
const candidates = enumerateClaudeCandidates(runtimePath, {
|
|
1065
|
+
isExecutable: (p) => { try {
|
|
1066
|
+
accessSync(p, fsConstants.X_OK);
|
|
1067
|
+
return true;
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
return false;
|
|
1071
|
+
} },
|
|
1072
|
+
realpath: (p) => realpathSync(p),
|
|
1073
|
+
runVersion: (p) => {
|
|
1074
|
+
const r = spawnSync(p, ["--version"], { encoding: "utf-8", timeout: 5_000 });
|
|
1075
|
+
return r.status === 0 ? (r.stdout ?? "").toString() : null;
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
return pickCanonicalClaude(candidates);
|
|
1079
|
+
}
|
|
1026
1080
|
function installClaudeCode() {
|
|
1027
1081
|
let needsInstall = true;
|
|
1082
|
+
// Darwin: pin the canonical claude before the version check so the check (and
|
|
1083
|
+
// any upgrade decision) targets the logged-in binary, not the stale one.
|
|
1084
|
+
const claudeForCheck = (RESOLVED_CLAUDE_BIN = resolveCanonicalClaudeBin()) ?? "claude";
|
|
1028
1085
|
if (commandExists("claude")) {
|
|
1029
1086
|
try {
|
|
1030
1087
|
// `claude --version` prints "2.1.114 (Claude Code)" — extract the semver so
|
|
1031
1088
|
// the equality check against `npm view` (which returns bare "2.1.114") works.
|
|
1032
|
-
const rawVersion = execFileSync(
|
|
1089
|
+
const rawVersion = execFileSync(claudeForCheck, ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
|
|
1033
1090
|
const installed = rawVersion.match(/^(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/)?.[1] ?? rawVersion;
|
|
1034
1091
|
let latest = null;
|
|
1035
1092
|
try {
|
|
@@ -1054,21 +1111,38 @@ function installClaudeCode() {
|
|
|
1054
1111
|
log("3", TOTAL, "Installing Claude Code...");
|
|
1055
1112
|
}
|
|
1056
1113
|
if (needsInstall) {
|
|
1057
|
-
// `npm install -g` needs write access to the global prefix
|
|
1058
|
-
// root-owned by default
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1114
|
+
// `npm install -g` needs write access to the global prefix. On Linux that
|
|
1115
|
+
// prefix is root-owned by default, so we run under sudo — and skip when sudo
|
|
1116
|
+
// is non-interactive (e.g. systemd-run --scope on upgrade), keeping the
|
|
1117
|
+
// running version. On darwin the nvm/Homebrew prefix is user-owned, so the
|
|
1118
|
+
// upgrade runs unprivileged; sudo there would force a password prompt that
|
|
1119
|
+
// hard-fails non-interactively.
|
|
1120
|
+
const platform = requireSupportedPlatform(process.platform);
|
|
1121
|
+
const decision = decideClaudeUpgradePrivilege({
|
|
1122
|
+
platform,
|
|
1123
|
+
prefixWritable: platform === "darwin" ? npmGlobalPrefixWritable() : false,
|
|
1124
|
+
canSudo: canSudo(),
|
|
1125
|
+
});
|
|
1126
|
+
if (decision === "skip") {
|
|
1127
|
+
logFile(`[create-maxy] claude-upgrade skipped reason=no-noninteractive-sudo platform=${platform}`);
|
|
1063
1128
|
console.log(" Skipping Claude Code upgrade (sudo unavailable non-interactively — keeping installed version)");
|
|
1064
1129
|
}
|
|
1065
1130
|
else {
|
|
1131
|
+
const useSudo = decision === "run-sudo";
|
|
1132
|
+
logFile(`[create-maxy] claude-upgrade platform=${platform} sudo=${useSudo}`);
|
|
1066
1133
|
console.log(" This may take 15–30 minutes on Raspberry Pi...");
|
|
1067
|
-
console.log("
|
|
1068
|
-
shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo:
|
|
1134
|
+
console.log(` ${useSudo ? "[privileged] " : ""}npm install -g @anthropic-ai/claude-code@latest`);
|
|
1135
|
+
shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: useSudo, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
|
|
1069
1136
|
3, 30);
|
|
1070
1137
|
}
|
|
1071
1138
|
}
|
|
1139
|
+
// Task 1395 gap 4 — a darwin upgrade may have just installed a newer
|
|
1140
|
+
// npm-global claude; re-resolve so the pinned CLAUDE_BIN reflects post-upgrade
|
|
1141
|
+
// state. Keep the pre-upgrade pick if re-resolution finds nothing.
|
|
1142
|
+
RESOLVED_CLAUDE_BIN = resolveCanonicalClaudeBin() ?? RESOLVED_CLAUDE_BIN;
|
|
1143
|
+
if (process.platform === "darwin") {
|
|
1144
|
+
logFile(` [create-maxy] claude-bin=${RESOLVED_CLAUDE_BIN ?? "unresolved"}`);
|
|
1145
|
+
}
|
|
1072
1146
|
console.log(" Registering Claude plugin marketplaces...");
|
|
1073
1147
|
// pre-create the per-brand CLAUDE_CONFIG_DIR so the first
|
|
1074
1148
|
// `claude plugin marketplace add` has a directory to write into.
|
|
@@ -1176,34 +1250,54 @@ function installClaudeCode() {
|
|
|
1176
1250
|
console.log(` Warning: Playwright MCP cache may be incomplete (browser automation may not work).${npxResult.stderr ? ` npx stderr: ${npxResult.stderr.slice(0, 200)}` : ""}`);
|
|
1177
1251
|
}
|
|
1178
1252
|
}
|
|
1179
|
-
function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
|
|
1253
|
+
function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j", platform = "linux") {
|
|
1180
1254
|
const password = randomBytes(24).toString("base64url");
|
|
1181
1255
|
const dedicated = port !== DEFAULT_NEO4J_PORT;
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
// The neo4j user database (graph data) is preserved.
|
|
1187
|
-
// set-initial-password only works before the system DB's first start,
|
|
1188
|
-
// so we must delete it to make Neo4j treat the next start as initial.
|
|
1189
|
-
spawnSync("sudo", ["rm", "-rf",
|
|
1256
|
+
// Clear the system database (stores auth/roles) and dbms auth config so the
|
|
1257
|
+
// next start is treated as initial and set-initial-password takes. The neo4j
|
|
1258
|
+
// user database (graph data) is preserved.
|
|
1259
|
+
const clearPaths = [
|
|
1190
1260
|
`${dataDir}/data/dbms`,
|
|
1191
1261
|
`${dataDir}/data/databases/system`,
|
|
1192
1262
|
`${dataDir}/data/transactions/system`,
|
|
1193
|
-
]
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
//
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1263
|
+
];
|
|
1264
|
+
if (platform === "darwin") {
|
|
1265
|
+
// Homebrew Neo4j runs under a user launchd agent (brew services) and its
|
|
1266
|
+
// datadir is user-owned — no systemctl, no sudo. This branch handles the
|
|
1267
|
+
// shared (default-brand) instance. Dedicated brands (realagent-code etc.)
|
|
1268
|
+
// set NEO4J_DEDICATED, but their dedicated provisioning (setupDedicatedNeo4j)
|
|
1269
|
+
// is Linux-only and aborts a dedicated-brand darwin install upstream, so a
|
|
1270
|
+
// non-default port never reaches here — see Task 1396 for darwin dedicated
|
|
1271
|
+
// support.
|
|
1272
|
+
console.log(" Resetting Neo4j auth with fresh password (brew services)...");
|
|
1273
|
+
spawnSync("brew", ["services", "stop", "neo4j"], { stdio: "inherit" });
|
|
1274
|
+
spawnSync("rm", ["-rf", ...clearPaths], { stdio: "inherit" });
|
|
1275
|
+
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { redact: true });
|
|
1276
|
+
const brewStart = spawnSync("brew", ["services", "start", "neo4j"], { stdio: "inherit" });
|
|
1277
|
+
if (brewStart.status !== 0) {
|
|
1278
|
+
throw new Error(`brew services start neo4j failed (exit ${brewStart.status}) during auth reset — Neo4j must be running before the brand server starts`);
|
|
1279
|
+
}
|
|
1280
|
+
logFile(` [neo4j-darwin-auth-reset] datadir=${dataDir} service=brew-services sudo=false`);
|
|
1200
1281
|
}
|
|
1201
1282
|
else {
|
|
1202
|
-
|
|
1203
|
-
|
|
1283
|
+
const serviceName = dedicated ? `neo4j-${BRAND.hostname}` : "neo4j";
|
|
1284
|
+
console.log(` Resetting Neo4j auth with fresh password (${serviceName})...`);
|
|
1285
|
+
spawnSync("sudo", ["systemctl", "stop", serviceName], { stdio: "inherit" });
|
|
1286
|
+
spawnSync("sudo", ["rm", "-rf", ...clearPaths], { stdio: "inherit" });
|
|
1287
|
+
if (dedicated) {
|
|
1288
|
+
const confDir = `/etc/neo4j-${BRAND.hostname}`;
|
|
1289
|
+
// sudo env VAR=val passes the variable through sudo's env_reset
|
|
1290
|
+
spawnSync("sudo", ["env", `NEO4J_CONF=${confDir}`, "neo4j-admin", "dbms", "set-initial-password", "--", password], {
|
|
1291
|
+
stdio: "inherit",
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
console.log(" [privileged] neo4j-admin dbms");
|
|
1296
|
+
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true, redact: true });
|
|
1297
|
+
}
|
|
1298
|
+
console.log(" [privileged] systemctl start");
|
|
1299
|
+
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
1204
1300
|
}
|
|
1205
|
-
console.log(" [privileged] systemctl start");
|
|
1206
|
-
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
1207
1301
|
console.log(" Waiting for Neo4j to start...");
|
|
1208
1302
|
for (let i = 0; i < 15; i++) {
|
|
1209
1303
|
const check = spawnSync("cypher-shell", [
|
|
@@ -1249,7 +1343,15 @@ function ensureNeo4jPassword() {
|
|
|
1249
1343
|
const persistentPasswordFile = join(persistDir, ".neo4j-password");
|
|
1250
1344
|
// Dedicated instances have their own auth database — password checks and resets
|
|
1251
1345
|
// must target the dedicated port and data directory, not the shared instance.
|
|
1252
|
-
|
|
1346
|
+
// On darwin the datadir lives under the Homebrew prefix and is user-owned.
|
|
1347
|
+
const platform = requireSupportedPlatform(process.platform);
|
|
1348
|
+
const dataDir = resolveNeo4jDataDir({
|
|
1349
|
+
platform,
|
|
1350
|
+
brewPrefix: platform === "darwin" ? resolveBrewPrefix() : "",
|
|
1351
|
+
dedicated: NEO4J_DEDICATED,
|
|
1352
|
+
hostname: BRAND.hostname,
|
|
1353
|
+
});
|
|
1354
|
+
logFile(`[create-maxy] neo4j-datadir=${dataDir} platform=${platform}`);
|
|
1253
1355
|
// 1. Same-brand check: if our own password file exists and works, done (upgrade path).
|
|
1254
1356
|
if (existsSync(passwordFile)) {
|
|
1255
1357
|
const existingPassword = readFileSync(passwordFile, "utf-8").trim();
|
|
@@ -1276,7 +1378,7 @@ function ensureNeo4jPassword() {
|
|
|
1276
1378
|
console.log(" Neo4j has existing data. Resetting auth only (data preserved)...");
|
|
1277
1379
|
logFile(` Neo4j auth reset — clearing dbms auth in ${dataDir}, preserving databases + transactions`);
|
|
1278
1380
|
}
|
|
1279
|
-
const password = resetNeo4jAuth(NEO4J_PORT, dataDir);
|
|
1381
|
+
const password = resetNeo4jAuth(NEO4J_PORT, dataDir, platform);
|
|
1280
1382
|
writeFileSync(passwordFile, password, { mode: 0o600 });
|
|
1281
1383
|
mkdirSync(persistDir, { recursive: true });
|
|
1282
1384
|
writeFileSync(persistentPasswordFile, password, { mode: 0o600 });
|
|
@@ -2542,6 +2644,39 @@ function buildPlatform() {
|
|
|
2542
2644
|
}
|
|
2543
2645
|
console.log(` Installing claude-session-manager dependencies (${csmDir})...`);
|
|
2544
2646
|
shellRetry("npm", ["install", "--omit=dev", ...NPM_NET_FLAGS], { cwd: csmDir }, 3, 15);
|
|
2647
|
+
// Task 1395 gap 3 — on darwin node-pty ships uncompiled. Its install script
|
|
2648
|
+
// is `node scripts/prebuild.js || node-gyp rebuild`; on this platform
|
|
2649
|
+
// prebuild.js exits 0 without placing a binary, so `node-gyp rebuild` never
|
|
2650
|
+
// runs and every PTY spawn fails `posix_spawnp failed`. `npm rebuild` just
|
|
2651
|
+
// re-runs the same broken script — proven inert. The fix is to invoke
|
|
2652
|
+
// `node-gyp rebuild` DIRECTLY in node-pty's resolved directory, bypassing
|
|
2653
|
+
// prebuild.js. node-pty hoists to the platform workspace root, so resolve
|
|
2654
|
+
// its real location rather than assuming csmDir. Xcode Command Line Tools
|
|
2655
|
+
// are the prerequisite. Blocker, not warning: no addon means no session
|
|
2656
|
+
// ever spawns.
|
|
2657
|
+
if (process.platform === "darwin") {
|
|
2658
|
+
const nodePtyCandidates = [
|
|
2659
|
+
join(INSTALL_DIR, "platform", "node_modules", "node-pty"),
|
|
2660
|
+
join(csmDir, "node_modules", "node-pty"),
|
|
2661
|
+
];
|
|
2662
|
+
const nodePtyDir = nodePtyCandidates.find((d) => existsSync(d));
|
|
2663
|
+
if (!nodePtyDir) {
|
|
2664
|
+
throw new Error(`node-pty not found after install (looked in ${nodePtyCandidates.join(", ")}). ` +
|
|
2665
|
+
`The platform / claude-session-manager npm install did not place it.`);
|
|
2666
|
+
}
|
|
2667
|
+
console.log(` Rebuilding node-pty native addon (darwin, ${nodePtyDir})...`);
|
|
2668
|
+
const rebuilt = spawnSync("npx", ["--yes", "node-gyp", "rebuild"], { cwd: nodePtyDir, stdio: "inherit", timeout: 300_000 });
|
|
2669
|
+
const nodePtyRelease = join(nodePtyDir, "build", "Release");
|
|
2670
|
+
const ptyNode = join(nodePtyRelease, "pty.node");
|
|
2671
|
+
const spawnHelper = join(nodePtyRelease, "spawn-helper");
|
|
2672
|
+
const built = existsSync(ptyNode) && existsSync(spawnHelper);
|
|
2673
|
+
logFile(` [create-maxy] node-pty build=${built ? "rebuilt" : "failed"} exit=${rebuilt.status ?? "null"} dir=${nodePtyDir} pty.node=${existsSync(ptyNode)} spawn-helper=${existsSync(spawnHelper)}`);
|
|
2674
|
+
if (!built) {
|
|
2675
|
+
throw new Error(`node-pty native addon missing after node-gyp rebuild in ${nodePtyDir} ` +
|
|
2676
|
+
`(pty.node=${existsSync(ptyNode)} spawn-helper=${existsSync(spawnHelper)}). ` +
|
|
2677
|
+
`Install Xcode Command Line Tools (xcode-select --install) and re-run.`);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2545
2680
|
}
|
|
2546
2681
|
// (maxy-code, Task 677 P2) — whatsapp-channel service has its own
|
|
2547
2682
|
// package.json declaring @modelcontextprotocol/sdk. It ships dist/ +
|
|
@@ -3203,8 +3338,20 @@ function installServiceDarwin() {
|
|
|
3203
3338
|
envContent = envContent.trimEnd() + (envContent.length > 0 ? "\n" : "") + `${key}=${value}\n`;
|
|
3204
3339
|
}
|
|
3205
3340
|
}
|
|
3341
|
+
// Task 1395 gap 4 — pin the canonical claude path (resolved in phase 3) so
|
|
3342
|
+
// every runtime spawn under launchd uses the logged-in binary, not whichever
|
|
3343
|
+
// the bare launchd PATH resolves first. Written only when resolved; an unset
|
|
3344
|
+
// CLAUDE_BIN makes runtime callers fall back to bare `claude` on PATH.
|
|
3345
|
+
const canonicalClaude = RESOLVED_CLAUDE_BIN ?? resolveCanonicalClaudeBin();
|
|
3346
|
+
if (canonicalClaude) {
|
|
3347
|
+
const re = /^CLAUDE_BIN=.*$/m;
|
|
3348
|
+
if (re.test(envContent))
|
|
3349
|
+
envContent = envContent.replace(re, `CLAUDE_BIN=${canonicalClaude}`);
|
|
3350
|
+
else
|
|
3351
|
+
envContent = envContent.trimEnd() + (envContent.length > 0 ? "\n" : "") + `CLAUDE_BIN=${canonicalClaude}\n`;
|
|
3352
|
+
}
|
|
3206
3353
|
writeFileSync(envPath, envContent);
|
|
3207
|
-
logFile(` .env: DISPLAY_MODE=${DISPLAY_MODE}, EMBED_MODEL=${EMBED_MODEL}, EMBED_DIMENSIONS=${EMBED_DIMS}, NEO4J_URI=bolt://localhost:${NEO4J_PORT}, PORT=${PORT}, MAXY_UI_INTERNAL_PORT=${PORT} (darwin-collapsed), CLAUDE_SESSION_MANAGER_PORT=${BRAND.claudeSessionManagerPort}, HOSTNAME=0.0.0.0, ACCOUNT_ID=${installAccountId.slice(0, 8)}…, CLAUDE_CONFIG_DIR=${persistDir}/.claude`);
|
|
3354
|
+
logFile(` .env: DISPLAY_MODE=${DISPLAY_MODE}, EMBED_MODEL=${EMBED_MODEL}, EMBED_DIMENSIONS=${EMBED_DIMS}, NEO4J_URI=bolt://localhost:${NEO4J_PORT}, PORT=${PORT}, MAXY_UI_INTERNAL_PORT=${PORT} (darwin-collapsed), CLAUDE_SESSION_MANAGER_PORT=${BRAND.claudeSessionManagerPort}, HOSTNAME=0.0.0.0, ACCOUNT_ID=${installAccountId.slice(0, 8)}…, CLAUDE_CONFIG_DIR=${persistDir}/.claude, CLAUDE_BIN=${canonicalClaude ?? "unresolved(PATH)"}`);
|
|
3208
3355
|
}
|
|
3209
3356
|
catch (err) {
|
|
3210
3357
|
console.error(` WARNING: failed to write .env to ${envPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3266,6 +3413,66 @@ function installServiceDarwin() {
|
|
|
3266
3413
|
logFile(` [create-maxy] launchd-plist=${path} loaded=false exit=${bootstrap.status}`);
|
|
3267
3414
|
throw new Error(`launchctl bootstrap ${gui()} ${path} failed (exit ${bootstrap.status}): ${stderr}`);
|
|
3268
3415
|
}
|
|
3416
|
+
// Task 1395 gap 2 — the claude-session-manager. On Linux it is a separate
|
|
3417
|
+
// systemd user unit (buildClaudeSessionManagerUnitFile) the brand unit
|
|
3418
|
+
// Requires=; darwin authored no such unit, so nothing listened on the manager
|
|
3419
|
+
// port and every session spawn returned `manager-unreachable`. Author a second
|
|
3420
|
+
// LaunchAgent mirroring the Linux unit: a wrapper sources .env + the
|
|
3421
|
+
// manager-only vars, then execs `node dist/index.js`.
|
|
3422
|
+
const smLabel = `${launchdLabel()}-claude-session-manager`;
|
|
3423
|
+
const smPlistPath = join(launchAgentsDir(), `${smLabel}.plist`);
|
|
3424
|
+
const smWrapperPath = join(persistDir, "claude-session-manager-wrapper.sh");
|
|
3425
|
+
writeFileSync(smWrapperPath, buildSessionManagerWrapper({
|
|
3426
|
+
envPath,
|
|
3427
|
+
installDir: INSTALL_DIR,
|
|
3428
|
+
nodeBin,
|
|
3429
|
+
persistDir,
|
|
3430
|
+
brandPort: PORT,
|
|
3431
|
+
}));
|
|
3432
|
+
chmodSync(smWrapperPath, 0o755);
|
|
3433
|
+
const smPlist = renderPlist({
|
|
3434
|
+
label: smLabel,
|
|
3435
|
+
programArguments: ["/bin/bash", smWrapperPath],
|
|
3436
|
+
stdoutPath: join(logsDir, "server.log"),
|
|
3437
|
+
stderrPath: join(logsDir, "server.log"),
|
|
3438
|
+
keepAlive: true,
|
|
3439
|
+
runAtLoad: true,
|
|
3440
|
+
workingDirectory: `${INSTALL_DIR}/platform/services/claude-session-manager`,
|
|
3441
|
+
});
|
|
3442
|
+
writeFileSync(smPlistPath, smPlist);
|
|
3443
|
+
logFile(` ${smPlistPath} written (${smPlist.length} bytes)`);
|
|
3444
|
+
spawnSync("launchctl", ["bootout", `${gui()}/${smLabel}`], { stdio: "pipe" });
|
|
3445
|
+
const smBootstrap = spawnSync("launchctl", ["bootstrap", gui(), smPlistPath], {
|
|
3446
|
+
stdio: "pipe",
|
|
3447
|
+
encoding: "utf-8",
|
|
3448
|
+
timeout: 15_000,
|
|
3449
|
+
});
|
|
3450
|
+
if (smBootstrap.status === 0) {
|
|
3451
|
+
console.log(` [launchd] bootstrap ${gui()}/${smLabel} ok`);
|
|
3452
|
+
logFile(` [create-maxy] launchd-plist=${smLabel} loaded=true`);
|
|
3453
|
+
}
|
|
3454
|
+
else {
|
|
3455
|
+
const smStderr = (smBootstrap.stderr ?? "").trim();
|
|
3456
|
+
console.error(` [launchd] bootstrap ${smLabel} returned ${smBootstrap.status}: ${smStderr}`);
|
|
3457
|
+
logFile(` [create-maxy] launchd-plist=${smLabel} loaded=false exit=${smBootstrap.status}`);
|
|
3458
|
+
throw new Error(`launchctl bootstrap ${smLabel} failed (exit ${smBootstrap.status}): ${smStderr}`);
|
|
3459
|
+
}
|
|
3460
|
+
// Health-probe the manager (/healthz) so a silent boot failure surfaces at
|
|
3461
|
+
// install time, not at first session spawn as `manager-unreachable`.
|
|
3462
|
+
console.log(" Waiting for session manager...");
|
|
3463
|
+
let smUp = false;
|
|
3464
|
+
for (let i = 0; i < 15; i++) {
|
|
3465
|
+
const probe = spawnSync("curl", ["-sf", `http://127.0.0.1:${BRAND.claudeSessionManagerPort}/healthz`, "-o", "/dev/null"], { timeout: 3000 });
|
|
3466
|
+
if (probe.status === 0) {
|
|
3467
|
+
smUp = true;
|
|
3468
|
+
break;
|
|
3469
|
+
}
|
|
3470
|
+
spawnSync("sleep", ["2"]);
|
|
3471
|
+
}
|
|
3472
|
+
logFile(` [create-maxy] session-manager-health=${smUp ? "ok" : "timeout"} port=${BRAND.claudeSessionManagerPort}`);
|
|
3473
|
+
if (!smUp) {
|
|
3474
|
+
console.log(` Session manager not yet healthy on ${BRAND.claudeSessionManagerPort} — check ${join(logsDir, "server.log")}.`);
|
|
3475
|
+
}
|
|
3269
3476
|
// Wait for the server to come up.
|
|
3270
3477
|
console.log(" Waiting for web server...");
|
|
3271
3478
|
let webServerUp = false;
|