@rubytech/create-maxy 1.0.792 → 1.0.793

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.
@@ -0,0 +1,175 @@
1
+ // Task 839 — brew-side install wrapper, peer of installAptGroup() in index.ts.
2
+ //
3
+ // Mirrors the apt path's install + post-check shape: classify each requested
4
+ // package against `brew list --versions` first, install the missing subset
5
+ // via `brew install <pkgs...>`, then re-probe `brew list --versions` per
6
+ // formula and throw loudly if any are still missing. Pure decision functions
7
+ // live here so a unit test can exercise every branch without spawning real
8
+ // brew — the wrapper accepts injected spawn-result probes.
9
+ //
10
+ // Why split classify / decide-plan / verify into three exports instead of one
11
+ // `installAll(pkgs)`: the call sites in index.ts need fine-grained log lines
12
+ // (`[homebrew] <pkg> already installed (v…)` vs `[homebrew] installing <pkg> …`
13
+ // vs `[homebrew] <pkg> verified post-install`) and the operator surface for
14
+ // each is distinct. Keeping them as separate pure functions also lets the
15
+ // pre-flight `which brew` refusal (ensureBrewAvailable) live alongside the
16
+ // install/verify primitives without coupling the test grid to spawnSync.
17
+ import { spawnSync } from "node:child_process";
18
+ import { decideBrewResolution, parseBrewListVersions } from "./brew-resolve.js";
19
+ // ---------------------------------------------------------------------------
20
+ // ensureBrewAvailable — pre-flight refusal when Homebrew is absent.
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Throw the loud refusal documented in the task brief when `which brew`
24
+ * exits non-zero. The message is grep-able and operator-actionable; no silent
25
+ * `curl | bash` fallback (per the task brief's "no auto-installing Homebrew"
26
+ * out-of-scope item).
27
+ */
28
+ export function ensureBrewAvailable(spawn) {
29
+ const r = spawn("which", ["brew"]);
30
+ if (r.status !== 0) {
31
+ throw new Error("[create-maxy] Homebrew not found. Install from https://brew.sh and re-run.");
32
+ }
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // classifyBrewState — turn a `brew list --versions <pkg>` probe into the
36
+ // installed/missing decision the install plan consumes.
37
+ // ---------------------------------------------------------------------------
38
+ /**
39
+ * Pure: given the spawn result of `brew list --versions <pkg>`, decide whether
40
+ * the formula is installed. A formula counts as installed only when brew
41
+ * exited 0 AND the parsed version list is non-empty — the half-uninstalled
42
+ * cellar state (exit 0 with name-only stdout) is treated as missing so the
43
+ * install pass re-runs and the post-check fails loudly if it still doesn't
44
+ * land. Mirrors apt-resolve's loud-failure contract.
45
+ */
46
+ export function classifyBrewState(_pkg, result) {
47
+ if (result.status !== 0)
48
+ return { installed: false, versions: [] };
49
+ const versions = parseBrewListVersions(result.stdout);
50
+ if (versions.length === 0)
51
+ return { installed: false, versions: [] };
52
+ return { installed: true, versions };
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // decideBrewInstallPlan — pure: which formulas to install + log lines.
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Pure: given each package's resolved formula name + install state, produce
59
+ * the install list (only missing formulas) and the per-pkg log lines. Lines
60
+ * are emitted in input order:
61
+ * - installed=true → ` [homebrew] <formula> already installed (v<n>)`
62
+ * - installed=false → ` [homebrew] installing <formula> …`
63
+ * The "already installed" line uses the first version token (the cellar's
64
+ * current head); subsequent tokens are older retained versions per
65
+ * brew-resolve.ts.
66
+ */
67
+ export function decideBrewInstallPlan(input) {
68
+ const toInstall = [];
69
+ const logs = [];
70
+ for (const p of input.pkgs) {
71
+ if (p.installed) {
72
+ const head = p.versions[0] ?? "?";
73
+ logs.push(` [homebrew] ${p.resolvedFormula} already installed (v${head})`);
74
+ }
75
+ else {
76
+ logs.push(` [homebrew] installing ${p.resolvedFormula} …`);
77
+ toInstall.push(p.resolvedFormula);
78
+ }
79
+ }
80
+ return { toInstall, logs };
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // verifyBrewInstalled — post-install probe + loud-failure throw.
84
+ // ---------------------------------------------------------------------------
85
+ /**
86
+ * Run `brew list --versions <formula>` (via the injected probe) for every
87
+ * formula and throw if any are still missing. The error message mirrors
88
+ * installAptGroup's "returned 0 but packages are still not installed"
89
+ * contract. Returns the per-formula verified-log lines on success.
90
+ */
91
+ export function verifyBrewInstalled(formulas, probe) {
92
+ const stillMissing = [];
93
+ const logs = [];
94
+ for (const f of formulas) {
95
+ const r = probe(f);
96
+ const state = classifyBrewState(f, r);
97
+ if (!state.installed) {
98
+ stillMissing.push(f);
99
+ continue;
100
+ }
101
+ logs.push(` [homebrew] ${f} verified post-install`);
102
+ }
103
+ if (stillMissing.length > 0) {
104
+ throw new Error(`[homebrew] installing ${stillMissing.join(", ")} exited 0 but brew list --versions reports missing: ${stillMissing.join(", ")}`);
105
+ }
106
+ return logs;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // installAll — orchestrating wrapper used by index.ts call sites. Production
110
+ // path only; tests exercise the pure functions above with injected spawns.
111
+ // ---------------------------------------------------------------------------
112
+ /**
113
+ * Install every requested package via Homebrew, with the same install +
114
+ * post-check pattern installAptGroup() uses for apt. The flow:
115
+ * 1. ensureBrewAvailable — refuse loud if `which brew` fails.
116
+ * 2. Probe `brew list --versions <original>` → classify per pkg.
117
+ * 3. Resolve each original via decideBrewResolution (so `nodejs` → `node@22`).
118
+ * 4. decideBrewInstallPlan → produces toInstall list + per-pkg log lines.
119
+ * 5. `brew install <toInstall>` if non-empty.
120
+ * 6. verifyBrewInstalled re-probes every resolved formula; throws loud if any
121
+ * formula is still missing post-install.
122
+ * `log` is the caller's logFile sink (timestamped install log line).
123
+ * Returns the resolved formula names so the caller can emit additional
124
+ * follow-up logging (e.g. `[neo4j] bolt://localhost:<port> reachable…`).
125
+ */
126
+ export function installAllBrewPackages(pkgs, log) {
127
+ const spawn = (cmd, args) => {
128
+ const r = spawnSync(cmd, args, {
129
+ stdio: "pipe",
130
+ encoding: "utf-8",
131
+ timeout: 600_000,
132
+ });
133
+ return { status: r.status, stdout: r.stdout ?? "" };
134
+ };
135
+ ensureBrewAvailable(spawn);
136
+ const probed = pkgs.map((original) => {
137
+ // First probe: is the original name installed (e.g. is `node@22` already there)?
138
+ // We probe under the resolved formula because that's the cellar key.
139
+ const provisional = decideBrewResolution({ pkg: original, brewInstalled: false });
140
+ const initialFormula = provisional.resolved;
141
+ const probe = spawn("brew", ["list", "--versions", initialFormula]);
142
+ const state = classifyBrewState(initialFormula, probe);
143
+ // Second resolution pass: brewInstalled=true short-circuits the alias to
144
+ // the original name (matches brew-resolve's contract — concrete-and-installed
145
+ // wins). When the cellar is empty, the alias path takes effect.
146
+ const decision = decideBrewResolution({ pkg: original, brewInstalled: state.installed });
147
+ if (decision.log)
148
+ log(decision.log);
149
+ return {
150
+ original,
151
+ resolvedFormula: state.installed ? initialFormula : decision.resolved,
152
+ installed: state.installed,
153
+ versions: state.versions,
154
+ };
155
+ });
156
+ const plan = decideBrewInstallPlan({ pkgs: probed });
157
+ for (const line of plan.logs)
158
+ log(line);
159
+ if (plan.toInstall.length > 0) {
160
+ const r = spawnSync("brew", ["install", ...plan.toInstall], {
161
+ stdio: "inherit",
162
+ timeout: 1_800_000,
163
+ });
164
+ if (r.status !== 0) {
165
+ throw new Error(`brew install ${plan.toInstall.join(" ")} exited ${r.status}`);
166
+ }
167
+ }
168
+ const verified = verifyBrewInstalled(probed.map((p) => p.resolvedFormula), (formula) => {
169
+ const r = spawnSync("brew", ["list", "--versions", formula], { stdio: "pipe", encoding: "utf-8", timeout: 30_000 });
170
+ return { status: r.status, stdout: r.stdout ?? "" };
171
+ });
172
+ for (const line of verified)
173
+ log(line);
174
+ return probed.map((p) => p.resolvedFormula);
175
+ }
@@ -0,0 +1,68 @@
1
+ // Task 836 wedge — pure brew-name resolution, peer of apt-resolve.ts.
2
+ //
3
+ // Same shape as Task 638's apt-resolve.ts: pure decision function, explicit
4
+ // input record, explicit output record, no spawnSync, no fs reads. The
5
+ // installer wraps this with the actual `brew list --versions` and `brew
6
+ // install` calls in follow-up Task 839; this module only decides which name
7
+ // to feed to those calls and which diagnostic line to log when an alias is
8
+ // applied.
9
+ //
10
+ // Brew's decision space is narrower than apt's — there is no apt-cache-policy
11
+ // analogue (Homebrew tells you `brew install` failed, never that a package
12
+ // is virtual), so the resolver collapses to two branches: alias-hit or
13
+ // pass-through. The brewInstalled gate short-circuits both: if the package
14
+ // is already on disk, the name is concrete and we return it unchanged.
15
+ // Virtual-package aliases applied when an apt-side name (used in shared
16
+ // installer call sites) needs to map to a brew formula name. Keyed by the
17
+ // apt name the installer passes in, valued by the brew formula name. Add
18
+ // entries here when a new shared name surfaces — every entry gets a
19
+ // deliberate test grid case (see brew-resolve.test.ts shape lock).
20
+ export const DARWIN_ALIASES = {
21
+ // Debian/Ubuntu ship Node.js as `nodejs`; Homebrew's pinned version is
22
+ // `node@22` (current Maxy floor — see pinned-binaries.ts). Mapping here
23
+ // lets follow-up tasks pass `nodejs` from the shared dep list and have
24
+ // the brew backend translate transparently.
25
+ nodejs: "node@22",
26
+ };
27
+ /**
28
+ * Parse the stdout of `brew list --versions <pkg>` into the version tokens
29
+ * after the package name. Output shape:
30
+ *
31
+ * "node@22 22.18.1 22.18.0\n" → ["22.18.1", "22.18.0"]
32
+ * "cloudflared 2025.1.4\n" → ["2025.1.4"]
33
+ * "node@22\n" → [] (cellar empty between uninstall + cleanup)
34
+ * "" → [] (pkg missing — brew exited 1)
35
+ * " \n" → [] (whitespace-only — never a real version)
36
+ *
37
+ * The first whitespace-separated token is always the package name and is
38
+ * dropped. Caller filters by version-shape if needed; this resolver returns
39
+ * everything after the name unfiltered.
40
+ */
41
+ export function parseBrewListVersions(stdout) {
42
+ const tokens = stdout.trim().split(/\s+/).filter((t) => t.length > 0);
43
+ if (tokens.length < 2)
44
+ return [];
45
+ return tokens.slice(1);
46
+ }
47
+ /**
48
+ * Pure decision: given the brew-installed fact, decide which package name to
49
+ * use. Resolution order:
50
+ * 1. brewInstalled true → name is concrete; return pkg unchanged, no log.
51
+ * 2. pkg is in DARWIN_ALIASES → return the alias and emit the log line.
52
+ * 3. Otherwise return pkg unchanged — caller's post-check will throw,
53
+ * preserving the apt-resolve.ts fail-loud contract for genuinely missing
54
+ * packages.
55
+ */
56
+ export function decideBrewResolution(input) {
57
+ const { pkg, brewInstalled } = input;
58
+ if (brewInstalled)
59
+ return { resolved: pkg, log: null };
60
+ if (Object.prototype.hasOwnProperty.call(DARWIN_ALIASES, pkg)) {
61
+ const alias = DARWIN_ALIASES[pkg];
62
+ return {
63
+ resolved: alias,
64
+ log: ` brew-resolve ${pkg} → ${alias} (reason=darwin-alias)`,
65
+ };
66
+ }
67
+ return { resolved: pkg, log: null };
68
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,10 @@ import { randomBytes } from "node:crypto";
6
6
  import { resolveInstallPortFromFs, buildMaxyUnitFile } from "./port-resolution.js";
7
7
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
8
8
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
9
+ import { requireSupportedPlatform } from "./platform-detect.js";
10
+ import { renderPlist } from "./launchd-plist.js";
11
+ import { installAllBrewPackages } from "./brew-install.js";
12
+ import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
9
13
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
10
14
  // Brand manifest — read from payload to derive all brand-specific installation values.
11
15
  // The bundler stamps brand.json into the payload at build time.
@@ -359,10 +363,70 @@ function installAptGroup(label, pkgs) {
359
363
  // Installation steps
360
364
  // ---------------------------------------------------------------------------
361
365
  const TOTAL = "11";
366
+ /**
367
+ * Task 840 — set macOS hostname via scutil. Three sequential `sudo scutil
368
+ * --set` calls (HostName, LocalHostName, ComputerName) — `hostnamectl` is
369
+ * Linux-only and `--hostname <h>` silently no-ops on darwin. All-or-nothing
370
+ * rollback within the 3-call batch: if any call fails, we restore the
371
+ * pre-batch values for the keys we already changed and re-throw. Full
372
+ * system-state rollback (avahi, /etc/hosts) is out of scope per the brief.
373
+ */
374
+ function setMacosHostnameViaScutil(hostname) {
375
+ const keys = ["HostName", "LocalHostName", "ComputerName"];
376
+ // Capture pre-batch values for rollback. Empty string is a legitimate
377
+ // scutil --get value (the key was never set) — preserve that as "" so
378
+ // rollback writes empty back rather than failing.
379
+ const previous = {};
380
+ for (const k of keys) {
381
+ const r = spawnSync("scutil", ["--get", k], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
382
+ previous[k] = (r.stdout ?? "").trim();
383
+ }
384
+ const applied = [];
385
+ for (const k of keys) {
386
+ const r = spawnSync("sudo", ["scutil", "--set", k, hostname], { encoding: "utf-8", stdio: "inherit", timeout: 30_000 });
387
+ if (r.status !== 0) {
388
+ // Roll back any keys we already changed within this batch.
389
+ for (const done of applied) {
390
+ spawnSync("sudo", ["scutil", "--set", done, previous[done] ?? ""], { stdio: "inherit", timeout: 30_000 });
391
+ }
392
+ const stderr = r.stderr ? `: ${r.stderr.toString().trim()}` : "";
393
+ console.error(` [scutil] ${k} failed${stderr}`);
394
+ throw new Error(`scutil --set ${k} ${hostname} exited ${r.status}`);
395
+ }
396
+ applied.push(k);
397
+ }
398
+ console.log(` [scutil] HostName=${hostname} LocalHostName=${hostname} ComputerName=${hostname} set ok`);
399
+ }
362
400
  function installSystemDeps() {
363
401
  log("1", TOTAL, "System dependencies and network...");
364
- if (!isLinux()) {
365
- console.log(" Skipping Linux-specific setup (not on Linux).");
402
+ const platform = requireSupportedPlatform(process.platform);
403
+ if (platform === "darwin") {
404
+ // Task 840 — darwin hostname via scutil when --hostname is supplied,
405
+ // otherwise preserve the existing system hostname.
406
+ if (HOSTNAME_FLAG) {
407
+ console.log(` Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
408
+ try {
409
+ setMacosHostnameViaScutil(HOSTNAME_FLAG);
410
+ DEVICE_HOSTNAME = HOSTNAME_FLAG;
411
+ }
412
+ catch (err) {
413
+ console.error(` WARNING: Failed to set hostname to '${HOSTNAME_FLAG}': ${err instanceof Error ? err.message : String(err)}`);
414
+ }
415
+ }
416
+ else {
417
+ try {
418
+ DEVICE_HOSTNAME = execFileSync("hostname", [], { encoding: "utf-8" }).trim();
419
+ console.log(` Hostname: ${DEVICE_HOSTNAME} (preserved — no --hostname flag on darwin)`);
420
+ }
421
+ catch { /* fallback to brand — set at declaration */ }
422
+ }
423
+ // Task 839 — macOS has no apt analogue for the VNC/WiFi-AP stacks
424
+ // (kiosk display + hostapd/dnsmasq are Pi-specific per Task 836's
425
+ // out-of-scope note). mDNS is provided by the OS, so avahi-* drop out.
426
+ // Translate the remaining apt names through decideBrewResolution and
427
+ // let installAllBrewPackages handle the install + verify pattern.
428
+ const DARWIN_BASE_DEPS = ["curl", "git", "unzip", "jq", "poppler", "ffmpeg"];
429
+ installAllBrewPackages(DARWIN_BASE_DEPS, logFile);
366
430
  return;
367
431
  }
368
432
  const BASE_DEPS = ["curl", "git", "unzip", "jq", "avahi-daemon", "avahi-utils", "poppler-utils", "ffmpeg"];
@@ -594,8 +658,13 @@ function installNodejs() {
594
658
  return;
595
659
  }
596
660
  log("2", TOTAL, "Installing Node.js...");
597
- if (!isLinux()) {
598
- throw new Error("Automatic Node.js installation is only supported on Linux. Install Node.js 20+ manually.");
661
+ const platform = requireSupportedPlatform(process.platform);
662
+ if (platform === "darwin") {
663
+ // Pass the apt-side `nodejs` name; decideBrewResolution maps it to
664
+ // `node@22` per DARWIN_ALIASES so the cellar formula matches Maxy's
665
+ // pinned-binaries floor (Task 836).
666
+ installAllBrewPackages(["nodejs"], logFile);
667
+ return;
599
668
  }
600
669
  spawnSync("bash", ["-c", "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -"], { stdio: "inherit" });
601
670
  console.log(" [privileged] apt-get install");
@@ -817,8 +886,29 @@ function installNeo4j() {
817
886
  return;
818
887
  }
819
888
  log("4", TOTAL, "Installing Neo4j Community Edition 5...");
820
- if (!isLinux()) {
821
- throw new Error("Automatic Neo4j installation is only supported on Linux. Install Neo4j 5.11+ manually.");
889
+ const platform = requireSupportedPlatform(process.platform);
890
+ if (platform === "darwin") {
891
+ // Homebrew's neo4j formula declares openjdk as a runtime dependency, so
892
+ // Java is pulled in transitively — no separate openjdk install step.
893
+ // Process supervision (launchd LaunchAgent) is owned by Task 838; this
894
+ // step ends after `brew install neo4j` + initial-password seeding so the
895
+ // payload deploy step finds the shared password file.
896
+ installAllBrewPackages(["neo4j"], logFile);
897
+ // Generate strong random password — stored in persistent location (~/{configDir}/)
898
+ const password = randomBytes(24).toString("base64url");
899
+ const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
900
+ mkdirSync(persistDir, { recursive: true });
901
+ writeFileSync(join(persistDir, ".neo4j-password"), password, { mode: 0o600 });
902
+ const configDir = resolve(INSTALL_DIR, "platform/config");
903
+ mkdirSync(configDir, { recursive: true });
904
+ writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
905
+ // Persist Neo4j data under ~/.maxy/neo4j-data/ per the task brief, so a
906
+ // brew uninstall (which removes the cellar) does not destroy the graph.
907
+ const neo4jDataDir = resolve(process.env.HOME ?? "/root", BRAND.configDir, "neo4j-data");
908
+ mkdirSync(neo4jDataDir, { recursive: true });
909
+ shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { redact: true });
910
+ console.log(" Neo4j installed via Homebrew. Password stored securely.");
911
+ return;
822
912
  }
823
913
  // Neo4j 5.x supports Java 17 and 21. Debian Bookworm ships 17, Trixie ships 21.
824
914
  // apt-cache policy shows "Candidate: (none)" when no installable version exists.
@@ -1170,8 +1260,13 @@ function installCloudflared() {
1170
1260
  return;
1171
1261
  }
1172
1262
  log("6", TOTAL, "Installing cloudflared...");
1173
- if (!isLinux()) {
1174
- console.log(" Skipping install manually for your platform.");
1263
+ const platform = requireSupportedPlatform(process.platform);
1264
+ if (platform === "darwin") {
1265
+ // Homebrew's `cloudflared` formula tracks the Cloudflare release stream;
1266
+ // tunnel-login flow stays unchanged (`feedback_no_api_token_route.md`).
1267
+ // `cloudflared service install` is invoked by the launchd supervisor in
1268
+ // Task 838 — out of scope here.
1269
+ installAllBrewPackages(["cloudflared"], logFile);
1175
1270
  return;
1176
1271
  }
1177
1272
  const arch = isArm64() ? "arm64" : "amd64";
@@ -1274,23 +1369,30 @@ function deployPayload() {
1274
1369
  // follow the manual recovery paragraph in .docs/deployment.md.
1275
1370
  // Stop the running service before wiping directories (upgrade path).
1276
1371
  // The server holds open files in platform/ — rmSync fails with ENOTEMPTY if it's running.
1277
- // systemctl stop returns when the main process exits, but ExecStopPost (e.g. VNC cleanup)
1278
- // may still hold file handles. Poll is-active to wait for full deactivation.
1279
- const svcName = BRAND.serviceName.replace(".service", "");
1280
- spawnSync("systemctl", ["--user", "stop", svcName], { stdio: "pipe" });
1281
- const MAX_STOP_WAIT = 5;
1282
- for (let i = 0; i < MAX_STOP_WAIT; i++) {
1283
- const result = spawnSync("systemctl", ["--user", "is-active", svcName], { stdio: "pipe" });
1284
- const status = result.stdout?.toString().trim();
1285
- if (status !== "active" && status !== "deactivating") {
1286
- console.log(` Service stopped (${status || "not found"}).`);
1287
- break;
1288
- }
1289
- if (i === MAX_STOP_WAIT - 1) {
1290
- console.log(` [WARN] Service still ${status} after ${MAX_STOP_WAIT}s — proceeding with directory wipe.`);
1291
- break;
1372
+ // Task 838 darwin uses `launchctl bootout` instead of systemctl; bootout
1373
+ // returns synchronously when the agent has exited.
1374
+ if (requireSupportedPlatform(process.platform) === "darwin") {
1375
+ spawnSync("launchctl", ["bootout", `${gui()}/${launchdLabel()}`], { stdio: "pipe", timeout: 15_000 });
1376
+ }
1377
+ else {
1378
+ // systemctl stop returns when the main process exits, but ExecStopPost (e.g. VNC cleanup)
1379
+ // may still hold file handles. Poll is-active to wait for full deactivation.
1380
+ const svcName = BRAND.serviceName.replace(".service", "");
1381
+ spawnSync("systemctl", ["--user", "stop", svcName], { stdio: "pipe" });
1382
+ const MAX_STOP_WAIT = 5;
1383
+ for (let i = 0; i < MAX_STOP_WAIT; i++) {
1384
+ const result = spawnSync("systemctl", ["--user", "is-active", svcName], { stdio: "pipe" });
1385
+ const status = result.stdout?.toString().trim();
1386
+ if (status !== "active" && status !== "deactivating") {
1387
+ console.log(` Service stopped (${status || "not found"}).`);
1388
+ break;
1389
+ }
1390
+ if (i === MAX_STOP_WAIT - 1) {
1391
+ console.log(` [WARN] Service still ${status} after ${MAX_STOP_WAIT}s — proceeding with directory wipe.`);
1392
+ break;
1393
+ }
1394
+ spawnSync("sleep", ["1"]);
1292
1395
  }
1293
- spawnSync("sleep", ["1"]);
1294
1396
  }
1295
1397
  // Migrate user data to {installDir}/data/ — outside the platform/ wipe zone.
1296
1398
  // Runs after service stop to avoid copying files while the agent is writing.
@@ -1764,11 +1866,149 @@ function installCrons() {
1764
1866
  // provisions the ttyd binary, writes a tmux conf, or installs a ttyd
1765
1867
  // systemd unit. The corresponding admin UI (RemoteTerminal, TerminalOverlay,
1766
1868
  // xterm.js) was deleted in the same task.
1869
+ // Task 838 — reverse-DNS LaunchAgent label. Becomes both the plist's
1870
+ // <key>Label</key> value and the file basename
1871
+ // (`~/Library/LaunchAgents/com.rubytech.<hostname>.plist`). Per-brand so
1872
+ // installing brand B never displaces brand A's agent (mirrors the per-brand
1873
+ // systemd unit invariant from Task 662).
1874
+ function launchdLabel() {
1875
+ return `com.rubytech.${BRAND.hostname}`;
1876
+ }
1877
+ function launchAgentsDir() {
1878
+ return resolve(process.env.HOME ?? "/", "Library/LaunchAgents");
1879
+ }
1880
+ function plistPath() {
1881
+ return join(launchAgentsDir(), `${launchdLabel()}.plist`);
1882
+ }
1883
+ function gui() {
1884
+ // launchd's gui domain is keyed on the user's UID. process.getuid is only
1885
+ // defined on POSIX (always present on darwin); typed conservatively.
1886
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1887
+ return `gui/${uid}`;
1888
+ }
1889
+ // Task 838 — darwin LaunchAgent supervisor. Mirrors the systemd-user body
1890
+ // below at the success-criteria level: process registered with the user's
1891
+ // session manager, KeepAlive respawns on crash, RunAtLoad starts the agent
1892
+ // on every login. Out-of-scope today (Task 839): brew-resolved node path,
1893
+ // dedicated Neo4j on a non-default port. Falls back to /usr/local/bin/node
1894
+ // (homebrew Intel + manual Apple-silicon installs) — Task 839 will replace
1895
+ // this with the resolver.
1896
+ function installServiceDarwin() {
1897
+ const persistDir = resolve(process.env.HOME ?? "/", BRAND.configDir);
1898
+ const logsDir = join(persistDir, "logs");
1899
+ mkdirSync(logsDir, { recursive: true });
1900
+ // Write install-time config to .env (the server reads it directly on
1901
+ // darwin; launchd does not have systemd's EnvironmentFile primitive).
1902
+ const envPath = join(persistDir, ".env");
1903
+ try {
1904
+ let envContent = "";
1905
+ try {
1906
+ envContent = readFileSync(envPath, "utf-8");
1907
+ }
1908
+ catch { /* first install */ }
1909
+ for (const [key, value] of [
1910
+ ["DISPLAY_MODE", DISPLAY_MODE],
1911
+ ["EMBED_MODEL", EMBED_MODEL],
1912
+ ["EMBED_DIMENSIONS", String(EMBED_DIMS)],
1913
+ ["NEO4J_URI", `bolt://localhost:${NEO4J_PORT}`],
1914
+ ["PORT", String(PORT)],
1915
+ ["MAXY_PLATFORM_ROOT", `${INSTALL_DIR}/platform`],
1916
+ ]) {
1917
+ const re = new RegExp(`^${key}=.*$`, "m");
1918
+ if (re.test(envContent)) {
1919
+ envContent = envContent.replace(re, `${key}=${value}`);
1920
+ }
1921
+ else {
1922
+ envContent = envContent.trimEnd() + (envContent.length > 0 ? "\n" : "") + `${key}=${value}\n`;
1923
+ }
1924
+ }
1925
+ writeFileSync(envPath, envContent);
1926
+ logFile(` .env: DISPLAY_MODE=${DISPLAY_MODE}, EMBED_MODEL=${EMBED_MODEL}, EMBED_DIMENSIONS=${EMBED_DIMS}, NEO4J_URI=bolt://localhost:${NEO4J_PORT}, PORT=${PORT}`);
1927
+ }
1928
+ catch (err) {
1929
+ console.error(` WARNING: failed to write .env to ${envPath}: ${err instanceof Error ? err.message : String(err)}`);
1930
+ }
1931
+ // Render the plist. The wrapper shell script reads .env before exec'ing
1932
+ // node so the runtime config (PORT, MAXY_PLATFORM_ROOT, NEO4J_URI) lands
1933
+ // in the child env. Without this, ProgramArguments executes node directly
1934
+ // and the .env values are unread — server binds the wrong port.
1935
+ const wrapperPath = join(persistDir, "launchd-wrapper.sh");
1936
+ // Resolve node binary at install time so the wrapper picks the right
1937
+ // path on both Intel (/usr/local/bin/node) and Apple Silicon
1938
+ // (/opt/homebrew/bin/node) — Homebrew's prefix differs by arch.
1939
+ const nodeProbe = spawnSync("command", ["-v", "node"], { encoding: "utf-8", shell: true });
1940
+ const nodeBin = (nodeProbe.stdout ?? "").trim() || "/usr/local/bin/node";
1941
+ const wrapperBody = [
1942
+ "#!/bin/bash",
1943
+ "# Task 838 — generated by create-maxy installService(). Reads .env then",
1944
+ "# execs node so launchd's child inherits PORT, NEO4J_URI, etc. Replaces",
1945
+ "# the systemd EnvironmentFile= directive that has no launchd analogue.",
1946
+ `set -a; [ -f "${envPath}" ] && . "${envPath}"; set +a`,
1947
+ `cd "${INSTALL_DIR}/server"`,
1948
+ `exec ${nodeBin} --require ./server-init.cjs server.js`,
1949
+ "",
1950
+ ].join("\n");
1951
+ writeFileSync(wrapperPath, wrapperBody);
1952
+ chmodSync(wrapperPath, 0o755);
1953
+ const label = launchdLabel();
1954
+ const plist = renderPlist({
1955
+ label,
1956
+ programArguments: ["/bin/bash", wrapperPath],
1957
+ stdoutPath: join(logsDir, "server.log"),
1958
+ stderrPath: join(logsDir, "server.log"),
1959
+ keepAlive: true,
1960
+ runAtLoad: true,
1961
+ workingDirectory: `${INSTALL_DIR}/server`,
1962
+ });
1963
+ mkdirSync(launchAgentsDir(), { recursive: true });
1964
+ const path = plistPath();
1965
+ writeFileSync(path, plist);
1966
+ logFile(` ${path} written (${plist.length} bytes)`);
1967
+ // Idempotent re-install: bootout the previous instance (if any) first so
1968
+ // the second `bootstrap` does not exit 5 ("already loaded"). Best-effort —
1969
+ // a missing service is the expected case on fresh installs.
1970
+ spawnSync("launchctl", ["bootout", `${gui()}/${label}`], { stdio: "pipe" });
1971
+ const bootstrap = spawnSync("launchctl", ["bootstrap", gui(), path], {
1972
+ stdio: "pipe",
1973
+ encoding: "utf-8",
1974
+ timeout: 15_000,
1975
+ });
1976
+ if (bootstrap.status === 0) {
1977
+ console.log(` [launchd] bootstrap ${gui()}/${label} ok`);
1978
+ logFile(` [launchd] bootstrap ${gui()}/${label} ok`);
1979
+ }
1980
+ else {
1981
+ const stderr = (bootstrap.stderr ?? "").trim();
1982
+ console.error(` [launchd] bootstrap returned ${bootstrap.status}: ${stderr}`);
1983
+ logFile(` [launchd] bootstrap returned ${bootstrap.status}: ${stderr}`);
1984
+ throw new Error(`launchctl bootstrap ${gui()} ${path} failed (exit ${bootstrap.status}): ${stderr}`);
1985
+ }
1986
+ // Wait for the server to come up.
1987
+ console.log(" Waiting for web server...");
1988
+ let webServerUp = false;
1989
+ for (let i = 0; i < 20; i++) {
1990
+ try {
1991
+ execFileSync("curl", ["-sf", `http://localhost:${PORT}`, "-o", "/dev/null"], { timeout: 3000 });
1992
+ webServerUp = true;
1993
+ break;
1994
+ }
1995
+ catch {
1996
+ spawnSync("sleep", ["2"]);
1997
+ }
1998
+ }
1999
+ if (!webServerUp) {
2000
+ console.log(` Server may still be starting. Check http://localhost:${PORT} in a moment.`);
2001
+ }
2002
+ }
1767
2003
  function installService() {
1768
2004
  log("11", TOTAL, `Starting ${BRAND.productName}...`);
1769
- if (!isLinux()) {
1770
- console.log(" Skipping systemd service (not Linux). Start manually with:");
1771
- console.log(` cd ${INSTALL_DIR}/server && MAXY_PLATFORM_ROOT=${INSTALL_DIR}/platform PORT=${PORT} HOSTNAME=0.0.0.0 KEEP_ALIVE_TIMEOUT=61000 node --require ./server-init.cjs server.js`);
2005
+ // Task 838 — branch on the Task 836 ternary instead of the legacy
2006
+ // `isLinux()` boolean. Linux falls through to the systemd-user body
2007
+ // below; darwin renders + bootstraps a LaunchAgent and returns;
2008
+ // unsupported throws the literal refusal at requireSupportedPlatform.
2009
+ const platform = requireSupportedPlatform(process.platform);
2010
+ if (platform === "darwin") {
2011
+ installServiceDarwin();
1772
2012
  return;
1773
2013
  }
1774
2014
  // Persist UDP buffer sizes for cloudflared QUIC stability (applied on every boot via sysctl.d)
@@ -2287,7 +2527,45 @@ const NEO4J_DEDICATED = NEO4J_PORT !== DEFAULT_NEO4J_PORT;
2287
2527
  // invocation. No TCP listener on the device needs to be reserved for an
2288
2528
  // interactive-shell surface any more.
2289
2529
  const PKG_VERSION = JSON.parse(readFileSync(resolve(import.meta.dirname, "../package.json"), "utf-8")).version;
2530
+ // ---------------------------------------------------------------------------
2531
+ // Task 840 — pre-flight platform refusal.
2532
+ //
2533
+ // Runs BEFORE initLogging() (LOG_DIR creation), port resolution, and any
2534
+ // brew/scutil/hostnamectl probe. Only Linux + darwin are supported (Task 836);
2535
+ // on darwin, macOS major must be ≥ 14 (the floor required by Tasks 838/839).
2536
+ // Older macOS partially succeeds, then breaks at the supervisor or brew-cellar
2537
+ // layer with cryptic errors — refusing loudly here is the contract.
2538
+ //
2539
+ // Platform-header line is emitted on every install start so operators can grep
2540
+ // `[create-maxy] platform=` to confirm pre-flight ran. On darwin the line also
2541
+ // carries `macos=<v>` (Task 840 token) so the macOS-14 floor check is visible.
2542
+ // ---------------------------------------------------------------------------
2543
+ const PLATFORM = requireSupportedPlatform(process.platform);
2544
+ let MACOS_VERSION = null;
2545
+ if (PLATFORM === "darwin") {
2546
+ const swVers = spawnSync("sw_vers", [], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
2547
+ const parsed = parseSwVers(swVers.stdout ?? "");
2548
+ if (!parsed) {
2549
+ const head = (swVers.stdout ?? "").split("\n").slice(0, 2).join(" | ");
2550
+ console.error(`[create-maxy] sw_vers stdout malformed: ${head}`);
2551
+ console.error(`[create-maxy] platform=darwin macos=<unknown> — refusing: macOS 14+ required`);
2552
+ process.exit(1);
2553
+ }
2554
+ MACOS_VERSION = parsed.version;
2555
+ if (!isSupportedMacosVersion(MACOS_VERSION)) {
2556
+ console.error(`[create-maxy] platform=darwin macos=${MACOS_VERSION} — refusing: macOS 14+ required`);
2557
+ process.exit(1);
2558
+ }
2559
+ }
2560
+ // Platform-header — first log line. Operators grep `[create-maxy] platform=`
2561
+ // to confirm pre-flight ran. The /* macos=<v> */ token is the Task 840 marker;
2562
+ // keep it on this same line so 3-way merges with parallel installer edits stay
2563
+ // mechanical (no rewrap, no split into two log lines).
2564
+ const PLATFORM_HEADER = `[create-maxy] platform=${PLATFORM} arch=${process.arch}` +
2565
+ (MACOS_VERSION ? ` macos=${MACOS_VERSION}` : ``) +
2566
+ ` version=${PKG_VERSION}`;
2290
2567
  initLogging();
2568
+ console.log(PLATFORM_HEADER);
2291
2569
  console.log("================================================================");
2292
2570
  console.log(` ${BRAND.productName} — ${BRAND.tagline}. (${BRAND.hostname} v${PKG_VERSION})`);
2293
2571
  console.log("================================================================");