@rubytech/create-maxy 1.0.792 → 1.0.794
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/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +8 -0
- package/payload/platform/neo4j/edge-annotations.json +20 -0
- package/payload/platform/neo4j/migrations/002-project-public-agents.ts +191 -0
- package/payload/platform/neo4j/schema.cypher +69 -2
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +5 -2
- package/payload/platform/plugins/docs/references/deployment.md +15 -3
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/server/chunk-2N7XJW6Q.js +3428 -0
- package/payload/server/chunk-3SQJW5Y5.js +9892 -0
- package/payload/server/chunk-ZVUVUP6R.js +9892 -0
- package/payload/server/client-pool-CTMWNDMO.js +28 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/public/assets/{admin-CBDpia8P.js → admin-DEhQ1wNO.js} +7 -7
- package/payload/server/public/assets/{graph-CT4W30GR.js → graph-DwzwJvlu.js} +1 -1
- package/payload/server/public/assets/page-BuoQU1c6.js +50 -0
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +47 -7
- package/payload/server/public/assets/page-Cs2i--Z2.js +0 -50
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Task 839 — acceptance grid for brew-install.ts.
|
|
2
|
+
//
|
|
3
|
+
// Mirror of apt-resolve.test.ts shape: pure decision logic exercised against
|
|
4
|
+
// fixture spawnSync results. The wrapper itself is a thin orchestration layer
|
|
5
|
+
// over `brew list --versions <pkg>` (post-check) and `brew install <pkgs...>`
|
|
6
|
+
// (install). Tests inject the spawn function so no real brew is invoked —
|
|
7
|
+
// these run identically on Linux CI and Mac developer boxes.
|
|
8
|
+
//
|
|
9
|
+
// Branches covered:
|
|
10
|
+
// 1. ensureBrewAvailable — `which brew` exits 0 → ok; non-zero → loud refusal.
|
|
11
|
+
// 2. classifyBrewState — per-pkg installed/missing decision from
|
|
12
|
+
// `brew list --versions` exit codes + parsed versions.
|
|
13
|
+
// 3. installAll — given a classification, decide what to feed `brew install`
|
|
14
|
+
// and what to log. Already-installed pkgs are skipped (no install line).
|
|
15
|
+
// 4. verifyAll — post-install, every pkg must have at least one version token
|
|
16
|
+
// from `brew list --versions`. Partial-install case (exit 0 but pkg
|
|
17
|
+
// missing) throws with the install-still-missing diagnostic.
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { ensureBrewAvailable, classifyBrewState, decideBrewInstallPlan, verifyBrewInstalled, } from "../brew-install.js";
|
|
21
|
+
function ok(stdout) {
|
|
22
|
+
return { status: 0, stdout };
|
|
23
|
+
}
|
|
24
|
+
function fail(stdout = "") {
|
|
25
|
+
return { status: 1, stdout };
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// ensureBrewAvailable — pre-flight refusal.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
test("ensureBrewAvailable: brew on PATH (which brew exits 0) → returns silently", () => {
|
|
31
|
+
let called = false;
|
|
32
|
+
ensureBrewAvailable((cmd, args) => {
|
|
33
|
+
called = true;
|
|
34
|
+
assert.equal(cmd, "which");
|
|
35
|
+
assert.deepEqual(args, ["brew"]);
|
|
36
|
+
return ok("/opt/homebrew/bin/brew\n");
|
|
37
|
+
});
|
|
38
|
+
assert.equal(called, true);
|
|
39
|
+
});
|
|
40
|
+
test("ensureBrewAvailable: brew not on PATH → throws loud refusal", () => {
|
|
41
|
+
assert.throws(() => ensureBrewAvailable(() => fail()), /Homebrew not found\. Install from https:\/\/brew\.sh and re-run\./);
|
|
42
|
+
});
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// classifyBrewState — per-pkg installed-vs-missing classification.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test("classifyBrewState: installed pkg with versions returns installed=true and version list", () => {
|
|
47
|
+
const r = classifyBrewState("node@22", ok("node@22 22.18.1 22.18.0\n"));
|
|
48
|
+
assert.equal(r.installed, true);
|
|
49
|
+
assert.deepEqual(r.versions, ["22.18.1", "22.18.0"]);
|
|
50
|
+
});
|
|
51
|
+
test("classifyBrewState: missing pkg (brew exit 1, empty stdout) returns installed=false", () => {
|
|
52
|
+
const r = classifyBrewState("neo4j", fail());
|
|
53
|
+
assert.equal(r.installed, false);
|
|
54
|
+
assert.deepEqual(r.versions, []);
|
|
55
|
+
});
|
|
56
|
+
test("classifyBrewState: name-only stdout (cellar half-uninstalled) treated as not-installed", () => {
|
|
57
|
+
// brew exited 0 but reported only the package name with no version tokens.
|
|
58
|
+
// Treat as uninstalled so the install pass re-runs and the post-check fails
|
|
59
|
+
// loudly if it still doesn't land — matches the apt-resolve fail-loud contract.
|
|
60
|
+
const r = classifyBrewState("node@22", ok("node@22\n"));
|
|
61
|
+
assert.equal(r.installed, false);
|
|
62
|
+
assert.deepEqual(r.versions, []);
|
|
63
|
+
});
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// decideBrewInstallPlan — given the apt-side input names + per-name installed
|
|
66
|
+
// state, produce the resolved formula list to install plus per-pkg log lines.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
test("decideBrewInstallPlan: all pkgs already installed → empty install list, only 'already installed' logs", () => {
|
|
69
|
+
const plan = decideBrewInstallPlan({
|
|
70
|
+
pkgs: [
|
|
71
|
+
{ original: "nodejs", resolvedFormula: "node@22", installed: true, versions: ["22.18.1"] },
|
|
72
|
+
{ original: "neo4j", resolvedFormula: "neo4j", installed: true, versions: ["5.20.0"] },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(plan.toInstall, []);
|
|
76
|
+
assert.deepEqual(plan.logs, [
|
|
77
|
+
" [homebrew] node@22 already installed (v22.18.1)",
|
|
78
|
+
" [homebrew] neo4j already installed (v5.20.0)",
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
test("decideBrewInstallPlan: some missing → install list contains only missing formulas, install + already logs interleaved", () => {
|
|
82
|
+
const plan = decideBrewInstallPlan({
|
|
83
|
+
pkgs: [
|
|
84
|
+
{ original: "nodejs", resolvedFormula: "node@22", installed: true, versions: ["22.18.1"] },
|
|
85
|
+
{ original: "neo4j", resolvedFormula: "neo4j", installed: false, versions: [] },
|
|
86
|
+
{ original: "cloudflared", resolvedFormula: "cloudflared", installed: false, versions: [] },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual(plan.toInstall, ["neo4j", "cloudflared"]);
|
|
90
|
+
assert.deepEqual(plan.logs, [
|
|
91
|
+
" [homebrew] node@22 already installed (v22.18.1)",
|
|
92
|
+
" [homebrew] installing neo4j …",
|
|
93
|
+
" [homebrew] installing cloudflared …",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
test("decideBrewInstallPlan: all missing → install list = every formula, only install logs", () => {
|
|
97
|
+
const plan = decideBrewInstallPlan({
|
|
98
|
+
pkgs: [
|
|
99
|
+
{ original: "nodejs", resolvedFormula: "node@22", installed: false, versions: [] },
|
|
100
|
+
{ original: "neo4j", resolvedFormula: "neo4j", installed: false, versions: [] },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
assert.deepEqual(plan.toInstall, ["node@22", "neo4j"]);
|
|
104
|
+
assert.deepEqual(plan.logs, [
|
|
105
|
+
" [homebrew] installing node@22 …",
|
|
106
|
+
" [homebrew] installing neo4j …",
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// verifyBrewInstalled — post-install check. Every formula must have ≥1 version
|
|
111
|
+
// token from `brew list --versions`. Pure logic over an injected probe.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
test("verifyBrewInstalled: every pkg present per brew list --versions → returns verified logs", () => {
|
|
114
|
+
const versionResults = {
|
|
115
|
+
"node@22": ok("node@22 22.18.1\n"),
|
|
116
|
+
"neo4j": ok("neo4j 5.20.0\n"),
|
|
117
|
+
};
|
|
118
|
+
const verified = verifyBrewInstalled(["node@22", "neo4j"], (pkg) => versionResults[pkg]);
|
|
119
|
+
assert.deepEqual(verified, [
|
|
120
|
+
" [homebrew] node@22 verified post-install",
|
|
121
|
+
" [homebrew] neo4j verified post-install",
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
test("verifyBrewInstalled: install returned 0 but pkg still missing → throws loud install-still-missing diagnostic", () => {
|
|
125
|
+
// The exact failure-mode mirror of installAptGroup — apt-get install exits 0
|
|
126
|
+
// but dpkg -s says the package isn't there. The brew analogue: brew install
|
|
127
|
+
// exits 0 but `brew list --versions` reports nothing.
|
|
128
|
+
const versionResults = {
|
|
129
|
+
"node@22": ok("node@22 22.18.1\n"),
|
|
130
|
+
"neo4j": fail(),
|
|
131
|
+
};
|
|
132
|
+
assert.throws(() => verifyBrewInstalled(["node@22", "neo4j"], (pkg) => versionResults[pkg]), /\[homebrew\] installing neo4j exited 0 but brew list --versions reports missing: neo4j/);
|
|
133
|
+
});
|
|
134
|
+
test("verifyBrewInstalled: multiple still-missing → all listed in diagnostic", () => {
|
|
135
|
+
const versionResults = {
|
|
136
|
+
"node@22": fail(),
|
|
137
|
+
"neo4j": ok("neo4j 5.20.0\n"),
|
|
138
|
+
"cloudflared": fail(),
|
|
139
|
+
};
|
|
140
|
+
assert.throws(() => verifyBrewInstalled(["node@22", "neo4j", "cloudflared"], (pkg) => versionResults[pkg]), /reports missing: node@22, cloudflared/);
|
|
141
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Task 836 wedge — acceptance grid for brew-resolve.ts.
|
|
2
|
+
//
|
|
3
|
+
// Mirror of apt-resolve.test.ts: pure decision logic exercised against real
|
|
4
|
+
// `brew list --versions` fixtures and the DARWIN_ALIASES seed map. Caller
|
|
5
|
+
// supplies the brewInstalled boolean and the parsed package name; the resolver
|
|
6
|
+
// returns the name to feed `brew install` and an optional log line for the
|
|
7
|
+
// alias path. Runs identically on Linux CI and Mac developer boxes — no
|
|
8
|
+
// spawnSync, no fs reads.
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { DARWIN_ALIASES, parseBrewListVersions, decideBrewResolution, } from "../brew-resolve.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Real fixtures — captured from `brew list --versions` on macOS.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Multi-version line — Homebrew keeps prior versions in the cellar until
|
|
16
|
+
// `brew cleanup`, so a `--versions` query against a long-lived install can
|
|
17
|
+
// return more than one token after the package name. The resolver returns
|
|
18
|
+
// every token; the caller decides which version is "current."
|
|
19
|
+
const NODE22_MULTI_VERSION = "node@22 22.18.1 22.18.0\n";
|
|
20
|
+
// Installed package, single version — the steady-state shape for a fresh install.
|
|
21
|
+
const CLOUDFLARED_SINGLE_VERSION = "cloudflared 2025.1.4\n";
|
|
22
|
+
// Missing package — `brew list --versions <pkg>` exits 1 with empty stdout.
|
|
23
|
+
// The resolver still receives the empty string and must return [] without
|
|
24
|
+
// throwing (the install caller decides whether absence is fatal).
|
|
25
|
+
const MISSING_STDOUT = "";
|
|
26
|
+
// Edge: only the package name with trailing newline (older Homebrew cellars
|
|
27
|
+
// can produce this if a formula was uninstalled mid-sequence).
|
|
28
|
+
const NAME_ONLY = "node@22\n";
|
|
29
|
+
// Edge: whitespace-only stdout (caller piped a stale buffer, etc.).
|
|
30
|
+
const WHITESPACE_ONLY = " \n";
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// parseBrewListVersions — split-and-skip-first-token semantics.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
test("parseBrewListVersions: multi-version line returns every token after pkg name", () => {
|
|
35
|
+
assert.deepEqual(parseBrewListVersions(NODE22_MULTI_VERSION), ["22.18.1", "22.18.0"]);
|
|
36
|
+
});
|
|
37
|
+
test("parseBrewListVersions: single-version line returns one token", () => {
|
|
38
|
+
assert.deepEqual(parseBrewListVersions(CLOUDFLARED_SINGLE_VERSION), ["2025.1.4"]);
|
|
39
|
+
});
|
|
40
|
+
test("parseBrewListVersions: empty stdout (pkg missing) returns []", () => {
|
|
41
|
+
assert.deepEqual(parseBrewListVersions(MISSING_STDOUT), []);
|
|
42
|
+
});
|
|
43
|
+
test("parseBrewListVersions: name-only line returns []", () => {
|
|
44
|
+
assert.deepEqual(parseBrewListVersions(NAME_ONLY), []);
|
|
45
|
+
});
|
|
46
|
+
test("parseBrewListVersions: whitespace-only stdout returns []", () => {
|
|
47
|
+
// Trip on naïve `.split(' ')[1]` returning ''. Resolver must reject empty
|
|
48
|
+
// tokens, not return [''].
|
|
49
|
+
assert.deepEqual(parseBrewListVersions(WHITESPACE_ONLY), []);
|
|
50
|
+
});
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// decideBrewResolution — three branches.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
test("decideBrewResolution: brew-installed pkg returns pkg, no log", () => {
|
|
55
|
+
// Branch 1: caller's brewInstalled probe came back true — name is concrete
|
|
56
|
+
// and present. Return as-is, follow-up's post-check uses the same name.
|
|
57
|
+
const r = decideBrewResolution({
|
|
58
|
+
pkg: "node@22",
|
|
59
|
+
brewInstalled: true,
|
|
60
|
+
});
|
|
61
|
+
assert.equal(r.resolved, "node@22");
|
|
62
|
+
assert.equal(r.log, null);
|
|
63
|
+
});
|
|
64
|
+
test("decideBrewResolution: alias-mapped pkg returns alias with log line", () => {
|
|
65
|
+
// Branch 2: pkg is in DARWIN_ALIASES — return the brew formula name and
|
|
66
|
+
// emit the diagnostic line installer logs grep on.
|
|
67
|
+
const r = decideBrewResolution({
|
|
68
|
+
pkg: "nodejs",
|
|
69
|
+
brewInstalled: false,
|
|
70
|
+
});
|
|
71
|
+
assert.equal(r.resolved, "node@22");
|
|
72
|
+
assert.match(r.log ?? "", /^ brew-resolve nodejs → node@22 \(reason=darwin-alias\)$/);
|
|
73
|
+
});
|
|
74
|
+
test("decideBrewResolution: unaliased pkg returns pkg unchanged, no log", () => {
|
|
75
|
+
// Branch 3: not installed AND not in alias map — return pkg unchanged.
|
|
76
|
+
// Preserves the apt-resolve.ts loud-failure contract: the install caller's
|
|
77
|
+
// post-check throws when the package genuinely doesn't exist.
|
|
78
|
+
const r = decideBrewResolution({
|
|
79
|
+
pkg: "cloudflared",
|
|
80
|
+
brewInstalled: false,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(r.resolved, "cloudflared");
|
|
83
|
+
assert.equal(r.log, null);
|
|
84
|
+
});
|
|
85
|
+
test("decideBrewResolution: brewInstalled=true takes precedence over alias map", () => {
|
|
86
|
+
// Edge: pkg is in the alias map AND already installed under the alias name.
|
|
87
|
+
// brewInstalled=true means the post-check would already pass — return pkg
|
|
88
|
+
// unchanged so the caller doesn't double-resolve.
|
|
89
|
+
const r = decideBrewResolution({
|
|
90
|
+
pkg: "nodejs",
|
|
91
|
+
brewInstalled: true,
|
|
92
|
+
});
|
|
93
|
+
assert.equal(r.resolved, "nodejs");
|
|
94
|
+
assert.equal(r.log, null);
|
|
95
|
+
});
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// DARWIN_ALIASES — shape lock. Adding a new alias must be a deliberate edit
|
|
98
|
+
// to this test grid, not a silent expansion.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
test("DARWIN_ALIASES contains nodejs → node@22 and is the only entry", () => {
|
|
101
|
+
assert.equal(DARWIN_ALIASES.nodejs, "node@22");
|
|
102
|
+
assert.equal(Object.keys(DARWIN_ALIASES).length, 1);
|
|
103
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Task 838 — acceptance grid for launchd-plist.ts.
|
|
2
|
+
//
|
|
3
|
+
// Locks the plist render contract that the macOS supervisor branch in
|
|
4
|
+
// installService() depends on: standard render, XML escaping of `<` `&` `"`
|
|
5
|
+
// embedded in paths, array `programArguments`, and absence of optional fields.
|
|
6
|
+
// Pure logic — no I/O, no launchctl invocation. The installer wraps this with
|
|
7
|
+
// writeFileSync + spawnSync("launchctl", ...) calls.
|
|
8
|
+
//
|
|
9
|
+
// Compiles to dist/__tests__/launchd-plist.test.js alongside the rest of the
|
|
10
|
+
// package so `node --test dist/__tests__/*.test.js` picks it up after build.
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { renderPlist } from "../launchd-plist.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Standard render — every required field present, no escaping needed.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
test("renderPlist standard render — required fields and structure", () => {
|
|
18
|
+
const xml = renderPlist({
|
|
19
|
+
label: "com.rubytech.maxy",
|
|
20
|
+
programArguments: ["/usr/local/bin/node", "/Users/alice/maxy/server/server.js"],
|
|
21
|
+
stdoutPath: "/Users/alice/.maxy/logs/server.log",
|
|
22
|
+
stderrPath: "/Users/alice/.maxy/logs/server.err.log",
|
|
23
|
+
keepAlive: true,
|
|
24
|
+
runAtLoad: true,
|
|
25
|
+
workingDirectory: "/Users/alice/maxy/server",
|
|
26
|
+
});
|
|
27
|
+
// XML preamble + plist DOCTYPE — launchctl rejects plists missing either.
|
|
28
|
+
assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>\n/, "XML preamble required");
|
|
29
|
+
assert.match(xml, /<!DOCTYPE plist PUBLIC "-\/\/Apple\/\/DTD PLIST 1\.0\/\/EN"/, "PLIST DOCTYPE required");
|
|
30
|
+
assert.match(xml, /<plist version="1\.0">[\s\S]*<\/plist>\s*$/, "plist root element wraps the dict");
|
|
31
|
+
// Label, paths, working directory.
|
|
32
|
+
assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy<\/string>/);
|
|
33
|
+
assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/Users\/alice\/\.maxy\/logs\/server\.log<\/string>/);
|
|
34
|
+
assert.match(xml, /<key>StandardErrorPath<\/key>\s*<string>\/Users\/alice\/\.maxy\/logs\/server\.err\.log<\/string>/);
|
|
35
|
+
assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/maxy\/server<\/string>/);
|
|
36
|
+
// Booleans render as <true/> / <false/> (launchd's documented form).
|
|
37
|
+
assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
|
|
38
|
+
assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
|
|
39
|
+
});
|
|
40
|
+
test("renderPlist programArguments renders as <array> of <string> in order", () => {
|
|
41
|
+
// Order matters: launchd execs argv[0] with argv[1..] as positional args.
|
|
42
|
+
// The renderer must preserve input order verbatim.
|
|
43
|
+
const xml = renderPlist({
|
|
44
|
+
label: "com.rubytech.maxy",
|
|
45
|
+
programArguments: ["/usr/local/bin/node", "--enable-source-maps", "/Users/alice/maxy/server/server.js", "--port=19200"],
|
|
46
|
+
stdoutPath: "/dev/null",
|
|
47
|
+
stderrPath: "/dev/null",
|
|
48
|
+
keepAlive: true,
|
|
49
|
+
runAtLoad: true,
|
|
50
|
+
});
|
|
51
|
+
// Match the full ProgramArguments block — order must be exact.
|
|
52
|
+
const block = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
53
|
+
assert.ok(block, "ProgramArguments array must be present");
|
|
54
|
+
const inner = block[1];
|
|
55
|
+
// Each <string> in the order supplied.
|
|
56
|
+
const strings = [...inner.matchAll(/<string>(.*?)<\/string>/g)].map((m) => m[1]);
|
|
57
|
+
assert.deepEqual(strings, [
|
|
58
|
+
"/usr/local/bin/node",
|
|
59
|
+
"--enable-source-maps",
|
|
60
|
+
"/Users/alice/maxy/server/server.js",
|
|
61
|
+
"--port=19200",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// KeepAlive + RunAtLoad falsy paths — render as <false/>.
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
test("renderPlist keepAlive=false / runAtLoad=false render <false/>", () => {
|
|
68
|
+
const xml = renderPlist({
|
|
69
|
+
label: "com.rubytech.maxy",
|
|
70
|
+
programArguments: ["/usr/local/bin/node", "/srv/server.js"],
|
|
71
|
+
stdoutPath: "/dev/null",
|
|
72
|
+
stderrPath: "/dev/null",
|
|
73
|
+
keepAlive: false,
|
|
74
|
+
runAtLoad: false,
|
|
75
|
+
});
|
|
76
|
+
assert.match(xml, /<key>KeepAlive<\/key>\s*<false\/>/);
|
|
77
|
+
assert.match(xml, /<key>RunAtLoad<\/key>\s*<false\/>/);
|
|
78
|
+
});
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// XML escaping — `<`, `&`, `>`, `"`, `'` in any string field must be escaped
|
|
81
|
+
// to the five XML predefined entities. Without this, a path with `&` (rare
|
|
82
|
+
// but legal on macOS) produces a parse error: `launchctl bootstrap` exits
|
|
83
|
+
// with "Bootstrap failed: 5: Input/output error" and the install completes
|
|
84
|
+
// in a non-functional state.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
test("renderPlist escapes <, &, >, \", ' in string fields", () => {
|
|
87
|
+
const xml = renderPlist({
|
|
88
|
+
label: 'com.rubytech.maxy & "test" <bracket>',
|
|
89
|
+
programArguments: ["/usr/local/bin/node", "/path/with & ampersand/server.js"],
|
|
90
|
+
stdoutPath: "/path/with <angle>/log.out",
|
|
91
|
+
stderrPath: "/path/with 'apostrophe'/log.err",
|
|
92
|
+
keepAlive: true,
|
|
93
|
+
runAtLoad: true,
|
|
94
|
+
workingDirectory: '/path/with "quote"/cwd',
|
|
95
|
+
});
|
|
96
|
+
// Label: every metacharacter escaped, none of the raw forms leak through.
|
|
97
|
+
assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy & "test" <bracket><\/string>/);
|
|
98
|
+
// None of the literal metacharacters survive inside the Label string slot.
|
|
99
|
+
assert.ok(!/<string>com\.rubytech\.maxy &(?!(amp|quot|lt|gt|apos);)/.test(xml), "raw & must not appear inside Label");
|
|
100
|
+
// ProgramArguments entry with `&`.
|
|
101
|
+
assert.match(xml, /<string>\/path\/with & ampersand\/server\.js<\/string>/);
|
|
102
|
+
// StandardOutPath with `<` `>`.
|
|
103
|
+
assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/path\/with <angle>\/log\.out<\/string>/);
|
|
104
|
+
// StandardErrorPath with `'` — escaped to '.
|
|
105
|
+
assert.match(xml, /<key>StandardErrorPath<\/key>\s*<string>\/path\/with 'apostrophe'\/log\.err<\/string>/);
|
|
106
|
+
// WorkingDirectory with `"` — escaped to ".
|
|
107
|
+
assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/path\/with "quote"\/cwd<\/string>/);
|
|
108
|
+
});
|
|
109
|
+
test("renderPlist escapes `&` once, never double-escapes", () => {
|
|
110
|
+
// Regression guard: a naive escape that runs the replacements in the wrong
|
|
111
|
+
// order produces &lt; for an input of `<`. Keep the order &-first so a
|
|
112
|
+
// raw `&` becomes `&` and a raw `<` stays a single `<`.
|
|
113
|
+
const xml = renderPlist({
|
|
114
|
+
label: "a&b<c",
|
|
115
|
+
programArguments: ["/x"],
|
|
116
|
+
stdoutPath: "/dev/null",
|
|
117
|
+
stderrPath: "/dev/null",
|
|
118
|
+
keepAlive: true,
|
|
119
|
+
runAtLoad: true,
|
|
120
|
+
});
|
|
121
|
+
assert.match(xml, /<string>a&b<c<\/string>/);
|
|
122
|
+
assert.ok(!/&amp;|&lt;/.test(xml), "no double-escaping");
|
|
123
|
+
});
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Optional-field absence — workingDirectory is the only optional field. When
|
|
126
|
+
// omitted, the corresponding <key>/<value> pair must not appear in the output.
|
|
127
|
+
// Emitting `<key>WorkingDirectory</key><string></string>` would chdir launchd's
|
|
128
|
+
// child to "" (current dir = launchd's cwd, /), which silently changes
|
|
129
|
+
// behaviour from "stay where invoked" to "/" — a different bug than absence.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
test("renderPlist omits WorkingDirectory key when workingDirectory is absent", () => {
|
|
132
|
+
const xml = renderPlist({
|
|
133
|
+
label: "com.rubytech.maxy",
|
|
134
|
+
programArguments: ["/usr/local/bin/node", "/srv/server.js"],
|
|
135
|
+
stdoutPath: "/dev/null",
|
|
136
|
+
stderrPath: "/dev/null",
|
|
137
|
+
keepAlive: true,
|
|
138
|
+
runAtLoad: true,
|
|
139
|
+
// workingDirectory omitted on purpose
|
|
140
|
+
});
|
|
141
|
+
assert.ok(!/WorkingDirectory/.test(xml), "WorkingDirectory key must not appear when omitted");
|
|
142
|
+
// Required fields still present.
|
|
143
|
+
assert.match(xml, /<key>Label<\/key>/);
|
|
144
|
+
assert.match(xml, /<key>ProgramArguments<\/key>/);
|
|
145
|
+
assert.match(xml, /<key>StandardOutPath<\/key>/);
|
|
146
|
+
assert.match(xml, /<key>StandardErrorPath<\/key>/);
|
|
147
|
+
assert.match(xml, /<key>KeepAlive<\/key>/);
|
|
148
|
+
assert.match(xml, /<key>RunAtLoad<\/key>/);
|
|
149
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Task 840 — acceptance grid for macos-version.ts.
|
|
2
|
+
//
|
|
3
|
+
// Pure two-export module: parseSwVers extracts ProductName + ProductVersion
|
|
4
|
+
// from `sw_vers` stdout; isSupportedMacosVersion checks whether the version's
|
|
5
|
+
// major component is ≥ 14 (the floor Tasks 838/839 require for modern
|
|
6
|
+
// `launchctl bootstrap gui/$UID …` and Apple-Silicon Homebrew). No I/O — the
|
|
7
|
+
// caller spawns `sw_vers` and feeds stdout in, mirroring apt-resolve.ts.
|
|
8
|
+
//
|
|
9
|
+
// Runs via Node's built-in test runner; no vitest. Compiles to
|
|
10
|
+
// dist/__tests__/macos-version.test.js so `node --test dist/__tests__/*.test.js`
|
|
11
|
+
// picks it up after build.
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { parseSwVers, isSupportedMacosVersion, } from "../macos-version.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Real fixtures — captured from `sw_vers` on actual macOS hosts. The trailing
|
|
17
|
+
// newline matches subprocess stdout exactly; parseSwVers must tolerate it.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const SW_VERS_MACOS_14_4_1 = `ProductName: macOS
|
|
20
|
+
ProductVersion: 14.4.1
|
|
21
|
+
BuildVersion: 23E224
|
|
22
|
+
`;
|
|
23
|
+
const SW_VERS_MACOS_13_7_1 = `ProductName: macOS
|
|
24
|
+
ProductVersion: 13.7.1
|
|
25
|
+
BuildVersion: 22H313
|
|
26
|
+
`;
|
|
27
|
+
const SW_VERS_MACOS_14_0 = `ProductName: macOS
|
|
28
|
+
ProductVersion: 14.0
|
|
29
|
+
BuildVersion: 23A344
|
|
30
|
+
`;
|
|
31
|
+
const SW_VERS_MACOS_15_2 = `ProductName: macOS
|
|
32
|
+
ProductVersion: 15.2
|
|
33
|
+
BuildVersion: 24C101
|
|
34
|
+
`;
|
|
35
|
+
const SW_VERS_MACOS_12_7_6 = `ProductName: macOS
|
|
36
|
+
ProductVersion: 12.7.6
|
|
37
|
+
BuildVersion: 21H1320
|
|
38
|
+
`;
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// parseSwVers — extracts ProductName + ProductVersion; null on malformed.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
test("parseSwVers extracts product + version from macOS 14.4.1 fixture", () => {
|
|
43
|
+
const r = parseSwVers(SW_VERS_MACOS_14_4_1);
|
|
44
|
+
assert.deepEqual(r, { product: "macOS", version: "14.4.1" });
|
|
45
|
+
});
|
|
46
|
+
test("parseSwVers extracts product + version from macOS 13.7.1 fixture", () => {
|
|
47
|
+
const r = parseSwVers(SW_VERS_MACOS_13_7_1);
|
|
48
|
+
assert.deepEqual(r, { product: "macOS", version: "13.7.1" });
|
|
49
|
+
});
|
|
50
|
+
test("parseSwVers handles macOS 14.0 (no patch component)", () => {
|
|
51
|
+
const r = parseSwVers(SW_VERS_MACOS_14_0);
|
|
52
|
+
assert.deepEqual(r, { product: "macOS", version: "14.0" });
|
|
53
|
+
});
|
|
54
|
+
test("parseSwVers handles macOS 12.7.6 (legacy three-component)", () => {
|
|
55
|
+
const r = parseSwVers(SW_VERS_MACOS_12_7_6);
|
|
56
|
+
assert.deepEqual(r, { product: "macOS", version: "12.7.6" });
|
|
57
|
+
});
|
|
58
|
+
test("parseSwVers returns null on empty stdout (spawn failed)", () => {
|
|
59
|
+
assert.equal(parseSwVers(""), null);
|
|
60
|
+
});
|
|
61
|
+
test("parseSwVers returns null on stdout missing ProductVersion line", () => {
|
|
62
|
+
// Defensive: future `sw_vers` schema change must not silently produce
|
|
63
|
+
// `{ product: "macOS", version: undefined }` — we want a hard null so the
|
|
64
|
+
// caller refuses with the malformed-stdout diagnostic.
|
|
65
|
+
assert.equal(parseSwVers("ProductName: macOS\nBuildVersion: 23E224\n"), null);
|
|
66
|
+
});
|
|
67
|
+
test("parseSwVers returns null on stdout missing ProductName line", () => {
|
|
68
|
+
assert.equal(parseSwVers("ProductVersion: 14.4.1\nBuildVersion: 23E224\n"), null);
|
|
69
|
+
});
|
|
70
|
+
test("parseSwVers returns null on totally unrelated stdout", () => {
|
|
71
|
+
assert.equal(parseSwVers("hello world\n"), null);
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// isSupportedMacosVersion — boundary at major ≥ 14.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
test("isSupportedMacosVersion: 14.0 → true (boundary, exactly the floor)", () => {
|
|
77
|
+
assert.equal(isSupportedMacosVersion("14.0"), true);
|
|
78
|
+
});
|
|
79
|
+
test("isSupportedMacosVersion: 14.4.1 → true (current shipping macOS 14)", () => {
|
|
80
|
+
assert.equal(isSupportedMacosVersion("14.4.1"), true);
|
|
81
|
+
});
|
|
82
|
+
test("isSupportedMacosVersion: 15.2 → true (future macOS 15)", () => {
|
|
83
|
+
assert.equal(isSupportedMacosVersion(parseSwVers(SW_VERS_MACOS_15_2)?.version ?? ""), true);
|
|
84
|
+
});
|
|
85
|
+
test("isSupportedMacosVersion: 13.7.1 → false (one minor below floor)", () => {
|
|
86
|
+
assert.equal(isSupportedMacosVersion("13.7.1"), false);
|
|
87
|
+
});
|
|
88
|
+
test("isSupportedMacosVersion: 12.7.6 → false (well below floor)", () => {
|
|
89
|
+
assert.equal(isSupportedMacosVersion("12.7.6"), false);
|
|
90
|
+
});
|
|
91
|
+
test("isSupportedMacosVersion: empty string → false (parse failure surrogate)", () => {
|
|
92
|
+
assert.equal(isSupportedMacosVersion(""), false);
|
|
93
|
+
});
|
|
94
|
+
test("isSupportedMacosVersion: garbage string → false (no leading integer)", () => {
|
|
95
|
+
assert.equal(isSupportedMacosVersion("not a version"), false);
|
|
96
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Task 836 wedge — acceptance grid for platform-detect.ts.
|
|
2
|
+
//
|
|
3
|
+
// Pure two-export module: detectPlatform classifies any process.platform value
|
|
4
|
+
// into the ternary the installer cares about; requireSupportedPlatform throws on
|
|
5
|
+
// unsupported with the literal refusal message that follow-up tasks (838/839/840)
|
|
6
|
+
// will surface in install logs. Caller injects the platform value so tests run
|
|
7
|
+
// identically on Linux CI and Mac developer boxes — no process.platform reads
|
|
8
|
+
// inside the module under test.
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { detectPlatform, requireSupportedPlatform, } from "../platform-detect.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// detectPlatform — ternary classifier over Node's process.platform values.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
test("detectPlatform: linux maps to linux", () => {
|
|
16
|
+
assert.equal(detectPlatform("linux"), "linux");
|
|
17
|
+
});
|
|
18
|
+
test("detectPlatform: darwin maps to darwin", () => {
|
|
19
|
+
assert.equal(detectPlatform("darwin"), "darwin");
|
|
20
|
+
});
|
|
21
|
+
test("detectPlatform: win32 maps to unsupported", () => {
|
|
22
|
+
assert.equal(detectPlatform("win32"), "unsupported");
|
|
23
|
+
});
|
|
24
|
+
test("detectPlatform: every other Node platform maps to unsupported", () => {
|
|
25
|
+
// Lock the set so a future Node version that adds a new platform string
|
|
26
|
+
// doesn't accidentally fall into 'linux' or 'darwin' via a typo.
|
|
27
|
+
for (const p of ["freebsd", "openbsd", "sunos", "aix", "cygwin", "netbsd", "android", "haiku"]) {
|
|
28
|
+
assert.equal(detectPlatform(p), "unsupported", `${p} must be unsupported`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// requireSupportedPlatform — return narrowed value or throw with literal msg.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
test("requireSupportedPlatform: linux returns 'linux'", () => {
|
|
35
|
+
assert.equal(requireSupportedPlatform("linux"), "linux");
|
|
36
|
+
});
|
|
37
|
+
test("requireSupportedPlatform: darwin returns 'darwin'", () => {
|
|
38
|
+
assert.equal(requireSupportedPlatform("darwin"), "darwin");
|
|
39
|
+
});
|
|
40
|
+
test("requireSupportedPlatform: win32 throws with the literal refusal message", () => {
|
|
41
|
+
assert.throws(() => requireSupportedPlatform("win32"), {
|
|
42
|
+
message: "[create-maxy] platform=win32 — refusing: only linux and darwin are supported",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
test("requireSupportedPlatform: refusal message embeds the actual platform string", () => {
|
|
46
|
+
// The platform token in the message is load-bearing for log-grep diagnostics
|
|
47
|
+
// — Task 836 spec calls out `[create-maxy] platform=darwin macos=<v> — refusing`
|
|
48
|
+
// as the failure signature for macOS-14 pre-flight. Pin the token verbatim.
|
|
49
|
+
assert.throws(() => requireSupportedPlatform("freebsd"), /^Error: \[create-maxy\] platform=freebsd — refusing: only linux and darwin are supported$/);
|
|
50
|
+
});
|