@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.
- package/dist/__tests__/brew-install.test.js +141 -0
- package/dist/__tests__/brew-resolve.test.js +103 -0
- package/dist/__tests__/launchd-plist.test.js +149 -0
- package/dist/__tests__/macos-version.test.js +96 -0
- package/dist/__tests__/platform-detect.test.js +50 -0
- package/dist/brew-install.js +175 -0
- package/dist/brew-resolve.js +68 -0
- package/dist/index.js +305 -27
- package/dist/launchd-plist.js +68 -0
- package/dist/macos-version.js +53 -0
- package/dist/platform-detect.js +36 -0
- package/dist/uninstall.js +47 -0
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +15 -3
- package/payload/server/chunk-ZVUVUP6R.js +9892 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +2 -2
|
@@ -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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
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
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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("================================================================");
|