@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,68 @@
1
+ // Task 838 — pure plist XML rendering for the macOS LaunchAgent supervisor.
2
+ // Mirrors the apt-resolve.ts / port-resolution.ts pattern: inputs in, decision
3
+ // out, no I/O. The installer wraps this with writeFileSync + spawnSync(
4
+ // "launchctl", ["bootstrap", `gui/${uid}`, plistPath]) — all spawn/fs lives in
5
+ // index.ts so this module can be unit-tested with concrete fixtures and no
6
+ // side effects.
7
+ //
8
+ // The Linux supervisor (systemd-user persistent unit at
9
+ // `~/.config/systemd/user/<service>.service`) has no analogue here: launchd's
10
+ // LaunchAgent format is a property-list XML document at
11
+ // `~/Library/LaunchAgents/<label>.plist`, and the bootstrap-into-domain flow
12
+ // uses launchctl(1) instead of systemctl. Both supervisors achieve the same
13
+ // success-criteria set: process registered with the user's session manager,
14
+ // auto-restart on crash, restart on logout/login, RunAtLoad on reboot.
15
+ /**
16
+ * Escape the five XML predefined entities, in order. Order matters: replace
17
+ * `&` first so an input of `<` doesn't double-escape via the `&` produced by
18
+ * the `<` rule. The output is safe to embed inside an XML <string> element.
19
+ */
20
+ function xmlEscape(s) {
21
+ return s
22
+ .replace(/&/g, "&amp;")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;")
25
+ .replace(/"/g, "&quot;")
26
+ .replace(/'/g, "&apos;");
27
+ }
28
+ /**
29
+ * Render a launchd LaunchAgent plist as XML. Pure: same input → same output
30
+ * forever. The caller writes this to `~/Library/LaunchAgents/<label>.plist`
31
+ * with mode 0644 and then runs `launchctl bootstrap gui/$UID <path>`.
32
+ *
33
+ * The output is the canonical plist 1.0 form launchd accepts — XML preamble,
34
+ * PLIST PUBLIC DOCTYPE, <plist version="1.0"> wrapping a single <dict>. Key
35
+ * order inside the dict mirrors Apple's LaunchAgent examples for grep-ability:
36
+ * Label, ProgramArguments, StandardOutPath, StandardErrorPath, [WorkingDirectory,]
37
+ * KeepAlive, RunAtLoad.
38
+ */
39
+ export function renderPlist(spec) {
40
+ const lines = [];
41
+ lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
42
+ lines.push(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`);
43
+ lines.push(`<plist version="1.0">`);
44
+ lines.push(`<dict>`);
45
+ lines.push(` <key>Label</key>`);
46
+ lines.push(` <string>${xmlEscape(spec.label)}</string>`);
47
+ lines.push(` <key>ProgramArguments</key>`);
48
+ lines.push(` <array>`);
49
+ for (const arg of spec.programArguments) {
50
+ lines.push(` <string>${xmlEscape(arg)}</string>`);
51
+ }
52
+ lines.push(` </array>`);
53
+ lines.push(` <key>StandardOutPath</key>`);
54
+ lines.push(` <string>${xmlEscape(spec.stdoutPath)}</string>`);
55
+ lines.push(` <key>StandardErrorPath</key>`);
56
+ lines.push(` <string>${xmlEscape(spec.stderrPath)}</string>`);
57
+ if (spec.workingDirectory !== undefined) {
58
+ lines.push(` <key>WorkingDirectory</key>`);
59
+ lines.push(` <string>${xmlEscape(spec.workingDirectory)}</string>`);
60
+ }
61
+ lines.push(` <key>KeepAlive</key>`);
62
+ lines.push(` ${spec.keepAlive ? "<true/>" : "<false/>"}`);
63
+ lines.push(` <key>RunAtLoad</key>`);
64
+ lines.push(` ${spec.runAtLoad ? "<true/>" : "<false/>"}`);
65
+ lines.push(`</dict>`);
66
+ lines.push(`</plist>`);
67
+ return lines.join("\n") + "\n";
68
+ }
@@ -0,0 +1,53 @@
1
+ // Task 840 — pure macOS-version classification for the installer.
2
+ //
3
+ // The installer needs two darwin facts at pre-flight: the macOS version (from
4
+ // `sw_vers`) and whether that version meets the floor required by Tasks 838
5
+ // (modern `launchctl bootstrap gui/$UID …`) and 839 (Apple-Silicon Homebrew).
6
+ // Older macOS partially succeeds, then breaks at the supervisor or brew-cellar
7
+ // layer with cryptic errors — refusing loudly at pre-flight is the contract.
8
+ //
9
+ // Pure logic with no I/O — caller spawns `sw_vers`, captures stdout, and feeds
10
+ // it through parseSwVers + isSupportedMacosVersion. Mirrors the apt-resolve.ts
11
+ // pattern (Task 638): inputs in, decision out, every branch testable with
12
+ // real captured fixtures.
13
+ /**
14
+ * Parse the stdout of macOS's `sw_vers` command. Real output has the shape:
15
+ *
16
+ * ProductName: macOS
17
+ * ProductVersion: 14.4.1
18
+ * BuildVersion: 23E224
19
+ *
20
+ * Returns null when either ProductName or ProductVersion is absent — the
21
+ * caller treats null as "refuse with malformed-stdout diagnostic" rather than
22
+ * silently coercing to undefined and producing a confusing version check.
23
+ */
24
+ export function parseSwVers(stdout) {
25
+ const productMatch = stdout.match(/^ProductName:\s*(.+)$/m);
26
+ const versionMatch = stdout.match(/^ProductVersion:\s*(.+)$/m);
27
+ if (!productMatch || !versionMatch)
28
+ return null;
29
+ return {
30
+ product: productMatch[1].trim(),
31
+ version: versionMatch[1].trim(),
32
+ };
33
+ }
34
+ /**
35
+ * True when `version`'s major component is ≥ 14 (the floor required by Tasks
36
+ * 838 + 839). Returns false for empty strings, non-numeric leading components,
37
+ * and any major < 14. A numeric-only check is sufficient — Apple's published
38
+ * macOS versions use a strict `MAJOR.MINOR[.PATCH]` integer scheme.
39
+ */
40
+ export function isSupportedMacosVersion(version) {
41
+ const m = version.match(/^(\d+)\./);
42
+ if (!m) {
43
+ // Fall back to whole-version-as-integer for the macOS 14.0 / 15 form
44
+ // where the leading number isn't followed by a dot. Defence in depth —
45
+ // the regex above already handles "14.0", but a bare "14" should also
46
+ // pass.
47
+ const bare = version.match(/^(\d+)$/);
48
+ if (!bare)
49
+ return false;
50
+ return parseInt(bare[1], 10) >= 14;
51
+ }
52
+ return parseInt(m[1], 10) >= 14;
53
+ }
@@ -0,0 +1,36 @@
1
+ // Task 836 wedge — pure platform classification for the installer.
2
+ //
3
+ // The installer's existing `isLinux()` checks (26 sites in index.ts) collapse
4
+ // process.platform into a boolean and treat every non-Linux value as "skip
5
+ // silently." Follow-up tasks 838/839/840 widen that to an explicit ternary
6
+ // (linux | darwin | unsupported) so unsupported targets refuse loudly with a
7
+ // log line operators can grep, instead of completing in a non-functional state.
8
+ //
9
+ // This module ships dead until those follow-ups land. Pure logic with no I/O —
10
+ // caller injects process.platform so tests pass arbitrary fixtures without
11
+ // touching the host's actual platform.
12
+ /**
13
+ * Classify a Node process.platform value into the ternary the installer cares
14
+ * about. Anything not in { linux, darwin } returns "unsupported"; the caller
15
+ * decides whether to refuse the install or no-op.
16
+ */
17
+ export function detectPlatform(nodePlatform) {
18
+ if (nodePlatform === "linux")
19
+ return "linux";
20
+ if (nodePlatform === "darwin")
21
+ return "darwin";
22
+ return "unsupported";
23
+ }
24
+ /**
25
+ * Narrow a Node process.platform to a SupportedPlatform or throw with the
26
+ * literal refusal message follow-up tasks log + grep on. Use this at install
27
+ * pre-flight where continuing on an unsupported OS would silently complete a
28
+ * broken install.
29
+ */
30
+ export function requireSupportedPlatform(nodePlatform) {
31
+ const detected = detectPlatform(nodePlatform);
32
+ if (detected === "unsupported") {
33
+ throw new Error(`[create-maxy] platform=${nodePlatform} — refusing: only linux and darwin are supported`);
34
+ }
35
+ return detected;
36
+ }
package/dist/uninstall.js CHANGED
@@ -64,6 +64,23 @@ function shell(command, args, options) {
64
64
  function isLinux() {
65
65
  return process.platform === "linux";
66
66
  }
67
+ function isDarwin() {
68
+ return process.platform === "darwin";
69
+ }
70
+ // Task 838 — mirror the launchd identifiers from index.ts so the uninstall
71
+ // can locate the LaunchAgent without reaching across modules. Per the
72
+ // `uninstall.ts:` "Shell helpers (duplicated from index.ts ...)" policy,
73
+ // these are intentionally duplicated rather than shared.
74
+ function launchdLabel() {
75
+ return `com.rubytech.${BRAND.hostname}`;
76
+ }
77
+ function plistPath() {
78
+ return resolve(HOME, "Library/LaunchAgents", `${launchdLabel()}.plist`);
79
+ }
80
+ function gui() {
81
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
82
+ return `gui/${uid}`;
83
+ }
67
84
  function commandExists(cmd) {
68
85
  try {
69
86
  execFileSync("which", [cmd], { stdio: "pipe" });
@@ -120,6 +137,15 @@ function peerBrandPresent() {
120
137
  // ---------------------------------------------------------------------------
121
138
  function stopServices() {
122
139
  log("1", "Stopping services...");
140
+ // Task 838 — darwin: bootout the LaunchAgent. bootout exits 0 on success,
141
+ // 113 ("Unknown service") when the agent isn't loaded — both are
142
+ // acceptable end-states for "service stopped". The plist removal in
143
+ // removeSystemdService() finishes the cleanup.
144
+ if (isDarwin()) {
145
+ spawnSync("launchctl", ["bootout", `${gui()}/${launchdLabel()}`], { stdio: "pipe", timeout: 15_000 });
146
+ console.log(` Booted out ${launchdLabel()}`);
147
+ return;
148
+ }
123
149
  // Stop platform user service
124
150
  try {
125
151
  spawnSync("systemctl", ["--user", "stop", BRAND.serviceName.replace(".service", "")], { stdio: "pipe", timeout: 15_000 });
@@ -563,6 +589,27 @@ function removeSystemConfig() {
563
589
  // ---------------------------------------------------------------------------
564
590
  function removeSystemdService() {
565
591
  log("8", "Removing systemd service...");
592
+ // Task 838 — darwin: bootout (idempotent — already done in stopServices,
593
+ // re-running is a no-op exit 113) and delete the plist. The wrapper
594
+ // shell script lives in CONFIG_DIR which step 4 (removeAppDirs) wipes,
595
+ // so no separate cleanup is needed.
596
+ if (isDarwin()) {
597
+ spawnSync("launchctl", ["bootout", `${gui()}/${launchdLabel()}`], { stdio: "pipe", timeout: 15_000 });
598
+ const plist = plistPath();
599
+ if (existsSync(plist)) {
600
+ try {
601
+ rmSync(plist);
602
+ console.log(` Removed ${plist}`);
603
+ }
604
+ catch (err) {
605
+ console.log(` Failed to remove ${plist}: ${err instanceof Error ? err.message : String(err)}`);
606
+ }
607
+ }
608
+ else {
609
+ console.log(` ${plist} not found — skipping`);
610
+ }
611
+ return;
612
+ }
566
613
  // Disable the service
567
614
  const svcUnit = BRAND.serviceName.replace(".service", "");
568
615
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.792",
3
+ "version": "1.0.793",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -2,11 +2,23 @@
2
2
 
3
3
  ## Hardware Requirements
4
4
 
5
- - Raspberry Pi 5 (16GB RAM minimum)
6
- - 256GB storage (microSD or NVMe)
7
- - Raspberry Pi OS
5
+ - Raspberry Pi 5 (16GB RAM minimum) with Raspberry Pi OS, **or**
6
+ - Mac with macOS 14 (Sonoma) or newer — both Apple Silicon and Intel
7
+ - 256GB storage minimum
8
8
  - Always-on power and network connection
9
9
 
10
+ ## macOS install
11
+
12
+ On macOS the installer uses Homebrew + launchd instead of apt + systemd, but the operator command is the same:
13
+
14
+ ```bash
15
+ npx -y @rubytech/create-maxy
16
+ ```
17
+
18
+ **Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run.
19
+
20
+ The installer registers {{productName}} as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.maxy.plist` — survives logout/login and reboot. Use `launchctl print gui/$UID/com.rubytech.maxy` for service state. `--hostname <h>` sets HostName / LocalHostName / ComputerName via `sudo scutil`. macOS < 14 is refused at pre-flight.
21
+
10
22
  ## Initial Setup
11
23
 
12
24
  The {{productName}} installer handles the full setup. Run it on your Pi: