@rubytech/create-maxy 1.0.676 → 1.0.677

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/index.js CHANGED
@@ -2,8 +2,7 @@
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, symlinkSync, unlinkSync, lstatSync, readlinkSync, accessSync, constants as fsConstants } from "node:fs";
4
4
  import { resolve, join, dirname } from "node:path";
5
- import { randomBytes, createHash } from "node:crypto";
6
- import { TTYD_VERSION, TTYD_SHA256_BY_ARCH, mapUnameToTtydArch, ttydDownloadUrl, } from "./pinned-binaries.js";
5
+ import { randomBytes } from "node:crypto";
7
6
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
8
7
  // Brand manifest — read from payload to derive all brand-specific installation values.
9
8
  // The bundler stamps brand.json into the payload at build time.
@@ -366,11 +365,7 @@ function installSystemDeps() {
366
365
  // class where PID is alive but no window is mapped on the target display.
367
366
  const VNC_DEPS = ["tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium", "xterm", "xdotool"];
368
367
  const WIFI_DEPS = ["hostapd", "dnsmasq"];
369
- // tmux backs the admin terminal's persistent named session (Task 591).
370
- // ttyd is NOT in Debian Bookworm's apt repo (Task 602) — it ships as a
371
- // pinned upstream binary installed by provisionTtydBinary() in step 11.
372
- const TERMINAL_DEPS = ["tmux"];
373
- const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...WIFI_DEPS, ...TERMINAL_DEPS];
368
+ const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...WIFI_DEPS];
374
369
  // Task 634 — verify the "deps are present" assumption with `dpkg -s` instead
375
370
  // of asserting it (feedback_loud_failures.md). The previous silent-skip
376
371
  // branch was benign until Task 632 added xdotool (the first new apt dep
@@ -397,7 +392,6 @@ function installSystemDeps() {
397
392
  installAptGroup("base utilities", BASE_DEPS);
398
393
  installAptGroup("VNC stack", VNC_DEPS);
399
394
  installAptGroup("WiFi AP", WIFI_DEPS);
400
- installAptGroup("terminal", TERMINAL_DEPS);
401
395
  }
402
396
  // Hostname resolution — four sources, in priority order:
403
397
  // 1. --hostname flag (unconditional — the caller is the authority)
@@ -1674,171 +1668,56 @@ function installCrons() {
1674
1668
  logFile(` crontab write failed: ${write.stderr}`);
1675
1669
  }
1676
1670
  }
1677
- const TTYD_INSTALL_PATH = "/usr/local/bin/ttyd";
1678
- function sha256File(path) {
1679
- const hash = createHash("sha256");
1680
- hash.update(readFileSync(path));
1681
- return hash.digest("hex");
1682
- }
1683
- // Provision the upstream ttyd binary into /usr/local/bin/ttyd. Degrades with
1684
- // a loud warning and a copy-pasteable remediation command on any failure —
1685
- // never throws. Contract: the caller (installTerminalService) uses the
1686
- // presence of TTYD_INSTALL_PATH after return to decide whether to enable the
1687
- // maxy-ttyd.service systemd unit. ttyd is NOT in Debian Bookworm apt, so we
1688
- // own the full download / verify / install flow here.
1689
- function provisionTtydBinary() {
1690
- const unameRaw = spawnSync("uname", ["-m"], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
1691
- const uname = (unameRaw.stdout || "").trim();
1692
- const arch = mapUnameToTtydArch(uname);
1693
- if (arch === null) {
1694
- console.error(` WARNING: ttyd — unsupported architecture 'uname -m'='${uname}'. Admin terminal will be unavailable.`);
1695
- console.error(` Remediate: install ttyd ${TTYD_VERSION} manually for your platform and place it at ${TTYD_INSTALL_PATH}, then 'sudo chmod +x ${TTYD_INSTALL_PATH}'.`);
1696
- return false;
1697
- }
1698
- const pinnedDigest = TTYD_SHA256_BY_ARCH[arch];
1699
- const url = ttydDownloadUrl(arch);
1700
- const remediation = `curl -L -o /tmp/ttyd.${arch} '${url}' && sudo mv /tmp/ttyd.${arch} ${TTYD_INSTALL_PATH} && sudo chmod +x ${TTYD_INSTALL_PATH}`;
1701
- // Idempotency: existing binary with matching pinned digest → skip download.
1702
- if (existsSync(TTYD_INSTALL_PATH)) {
1703
- try {
1704
- const existingDigest = sha256File(TTYD_INSTALL_PATH);
1705
- if (existingDigest === pinnedDigest) {
1706
- console.log(` ttyd ${TTYD_VERSION} already installed at ${TTYD_INSTALL_PATH} (SHA256 match — skipping download)`);
1707
- return true;
1708
- }
1709
- console.log(` ttyd at ${TTYD_INSTALL_PATH} has different digest — replacing with pinned ${TTYD_VERSION}`);
1710
- }
1711
- catch (err) {
1712
- console.error(` WARNING: could not read existing ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)} — will overwrite`);
1713
- }
1714
- }
1715
- if (!canSudo()) {
1716
- console.error(` WARNING: ttyd — sudo unavailable non-interactively, cannot write ${TTYD_INSTALL_PATH}. Admin terminal will be unavailable.`);
1717
- console.error(` Remediate: ${remediation}`);
1718
- return false;
1719
- }
1720
- const tmpPath = `/tmp/ttyd.${arch}`;
1721
- try {
1722
- console.log(` Downloading ttyd ${TTYD_VERSION} for ${arch} from ${url}`);
1723
- shellRetry("curl", ["-fL", "--retry", "3", "--retry-delay", "5", "-o", tmpPath, url], { timeout: 60_000 });
1724
- }
1725
- catch (err) {
1726
- console.error(` WARNING: ttyd download failed: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1727
- console.error(` Remediate: ${remediation}`);
1728
- try {
1729
- unlinkSync(tmpPath);
1730
- }
1731
- catch { /* nothing to clean */ }
1732
- return false;
1733
- }
1734
- let actualDigest;
1735
- try {
1736
- actualDigest = sha256File(tmpPath);
1737
- }
1738
- catch (err) {
1739
- console.error(` WARNING: ttyd — could not read downloaded file ${tmpPath}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1740
- try {
1741
- unlinkSync(tmpPath);
1742
- }
1743
- catch { /* nothing to clean */ }
1744
- return false;
1671
+ // Task 645 — idempotent tear-down of the Task 591 ttyd/tmux pipeline. Task 643
1672
+ // collapsed the admin upgrade and header-Terminal surfaces onto the existing
1673
+ // VNC terminal path; the parallel ttyd+xterm.js pipeline became orphan code.
1674
+ // Fresh devices never provision it. Devices carrying the Task 591 install
1675
+ // get their maxy-ttyd.service stopped, disabled, and removed on the first
1676
+ // post-645 installer run. Subsequent runs are silent no-ops — the absence
1677
+ // of the unit file short-circuits before any systemctl call is reached.
1678
+ function installTerminalService() {
1679
+ log("11", TOTAL, "Removing legacy admin terminal service (maxy-ttyd)...");
1680
+ if (!isLinux()) {
1681
+ return;
1745
1682
  }
1746
- if (actualDigest !== pinnedDigest) {
1747
- console.error(` WARNING: ttyd SHA256 mismatch — refusing to install unverified binary.`);
1748
- console.error(` expected: ${pinnedDigest}`);
1749
- console.error(` actual: ${actualDigest}`);
1750
- console.error(` Admin terminal will be unavailable. A later installer version may pin a newer digest.`);
1751
- try {
1752
- unlinkSync(tmpPath);
1753
- }
1754
- catch { /* nothing to clean */ }
1755
- return false;
1683
+ const homeDir = process.env.HOME ?? "/root";
1684
+ const unitPath = resolve(homeDir, ".config/systemd/user/maxy-ttyd.service");
1685
+ if (!existsSync(unitPath)) {
1686
+ return;
1756
1687
  }
1757
- console.log(` ttyd ${TTYD_VERSION} SHA256 verified (${actualDigest.slice(0, 12)}…)`);
1688
+ console.error(" [installer] maxy-ttyd: stopping and removing stale service (Task 645 orphan cleanup)");
1689
+ // Stop and disable are best-effort — systemctl returns non-zero on a unit
1690
+ // that's already inactive or never enabled, which is fine. stdio: "pipe"
1691
+ // keeps the operator terminal clean; diagnostics go to the installer log
1692
+ // via spawnSync's return if anyone needs them.
1693
+ spawnSync("systemctl", ["--user", "stop", "maxy-ttyd"], { stdio: "pipe", timeout: 10_000 });
1694
+ spawnSync("systemctl", ["--user", "disable", "maxy-ttyd"], { stdio: "pipe", timeout: 10_000 });
1758
1695
  try {
1759
- shell("mv", [tmpPath, TTYD_INSTALL_PATH], { sudo: true });
1760
- shell("chmod", ["+x", TTYD_INSTALL_PATH], { sudo: true });
1696
+ unlinkSync(unitPath);
1761
1697
  }
1762
1698
  catch (err) {
1763
- console.error(` WARNING: ttyd — could not install to ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1764
- console.error(` Remediate: ${remediation}`);
1765
- try {
1766
- unlinkSync(tmpPath);
1767
- }
1768
- catch { /* already moved or cleaned */ }
1769
- return false;
1770
- }
1771
- console.log(` ttyd ${TTYD_VERSION} installed at ${TTYD_INSTALL_PATH}`);
1772
- return true;
1773
- }
1774
- function installTerminalService() {
1775
- log("11", TOTAL, "Installing admin terminal service (ttyd + tmux)...");
1776
- if (!isLinux()) {
1777
- console.log(" Skipping admin terminal service (not Linux). On macOS start manually:");
1778
- console.log(" brew install ttyd tmux && ttyd -p 7681 -i 127.0.0.1 -W tmux new-session -A -s maxy-pty");
1699
+ console.error(` WARNING: could not remove ${unitPath}: ${err instanceof Error ? err.message : String(err)}`);
1779
1700
  return;
1780
1701
  }
1781
- // ttyd is provisioned from upstream GitHub releases (pinned + SHA256-verified)
1782
- // because Debian Bookworm's apt does NOT carry a ttyd package (Task 602).
1783
- // A failure here is loud but non-fatal the rest of the install completes
1784
- // and the admin UI degrades to "terminal unavailable" per Task 603.
1785
- const ttydReady = provisionTtydBinary();
1786
- // Default ~/.tmux.conf — only written if the operator doesn't already have
1787
- // one. `history-limit 50000` is load-bearing: a closed-tab + reopen during
1788
- // an upgrade must show every line the operator missed in scrollback.
1789
- const homeDir = process.env.HOME ?? "/root";
1702
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe", timeout: 10_000 });
1703
+ // ~/.tmux.conf cleanup: only remove if the contents match what the installer
1704
+ // seeded (exact template or exact fallback). Anything else = operator-owned,
1705
+ // leave untouched. Matches the Task 645 brief: "if the installer owned it,
1706
+ // it's been removed; if the operator owns it, untouched."
1790
1707
  const tmuxConfDest = resolve(homeDir, ".tmux.conf");
1791
- if (!existsSync(tmuxConfDest)) {
1792
- const tmuxConfTemplate = resolve(INSTALL_DIR, "platform/templates/dotfiles/.tmux.conf");
1708
+ if (existsSync(tmuxConfDest)) {
1793
1709
  try {
1794
- if (existsSync(tmuxConfTemplate)) {
1795
- writeFileSync(tmuxConfDest, readFileSync(tmuxConfTemplate, "utf-8"));
1796
- console.log(` Wrote default ~/.tmux.conf (history-limit 50000)`);
1797
- }
1798
- else {
1799
- // Fallback if the template was not in the payload for any reason —
1800
- // preserves the load-bearing scrollback-size guarantee.
1801
- writeFileSync(tmuxConfDest, "set -g history-limit 50000\n");
1802
- console.log(` Wrote default ~/.tmux.conf (fallback — template missing)`);
1710
+ const contents = readFileSync(tmuxConfDest, "utf-8");
1711
+ const seededFallback = "set -g history-limit 50000\n";
1712
+ if (contents === seededFallback) {
1713
+ unlinkSync(tmuxConfDest);
1714
+ console.log(" Removed installer-seeded ~/.tmux.conf (orphan after Task 645)");
1803
1715
  }
1804
1716
  }
1805
1717
  catch (err) {
1806
- console.error(` WARNING: failed to write ~/.tmux.conf: ${err instanceof Error ? err.message : String(err)}`);
1807
- }
1808
- }
1809
- // Install and enable the maxy-ttyd.service --user unit. Independent of
1810
- // BRAND.serviceName — a single device runs one admin terminal regardless of
1811
- // brand, because the unit binds to 127.0.0.1:7681 which only one process can
1812
- // hold anyway. On a multi-brand device, the first brand's install writes the
1813
- // unit and every subsequent install is a no-op (idempotent overwrite).
1814
- const systemdUserDir = resolve(homeDir, ".config/systemd/user");
1815
- mkdirSync(systemdUserDir, { recursive: true });
1816
- // Skip systemd-unit install if the ttyd binary is not in place — enabling
1817
- // a unit whose ExecStart points at a missing file just churns systemd with
1818
- // restart failures.
1819
- if (!ttydReady) {
1820
- console.error(" Skipping maxy-ttyd.service install — ttyd binary not present. Admin terminal will be unavailable until remediated.");
1821
- return;
1822
- }
1823
- const ttydUnitTemplate = resolve(INSTALL_DIR, "platform/templates/systemd/maxy-ttyd.service");
1824
- const ttydUnitDest = join(systemdUserDir, "maxy-ttyd.service");
1825
- try {
1826
- if (existsSync(ttydUnitTemplate)) {
1827
- writeFileSync(ttydUnitDest, readFileSync(ttydUnitTemplate, "utf-8"));
1718
+ console.error(` WARNING: could not inspect ~/.tmux.conf: ${err instanceof Error ? err.message : String(err)}`);
1828
1719
  }
1829
- else {
1830
- console.error(` WARNING: maxy-ttyd.service template missing at ${ttydUnitTemplate} — admin terminal will not work`);
1831
- return;
1832
- }
1833
- }
1834
- catch (err) {
1835
- console.error(` WARNING: failed to write ${ttydUnitDest}: ${err instanceof Error ? err.message : String(err)}`);
1836
- return;
1837
1720
  }
1838
- spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
1839
- spawnSync("systemctl", ["--user", "enable", "maxy-ttyd"], { stdio: "inherit" });
1840
- spawnSync("systemctl", ["--user", "restart", "maxy-ttyd"], { stdio: "inherit" });
1841
- console.log(" maxy-ttyd.service enabled — admin terminal available on 127.0.0.1:7681");
1842
1721
  }
1843
1722
  function installService() {
1844
1723
  log("12", TOTAL, `Starting ${BRAND.productName}...`);
@@ -1914,27 +1793,32 @@ function installService() {
1914
1793
  // Propagate to child processes — seed-neo4j.sh reads both variables.
1915
1794
  process.env.EMBED_DIMENSIONS = String(EMBED_DIMS);
1916
1795
  process.env.NEO4J_URI = `bolt://localhost:${NEO4J_PORT}`;
1796
+ // Task 647: maxy-ui runs on an internal-only port so a restart does not
1797
+ // drop the public TCP socket. maxy-edge.service owns the public port and
1798
+ // the VNC stack; maxy-ui sits behind it on 127.0.0.1:MAXY_UI_INTERNAL_PORT.
1799
+ // PORT + 1 (derived) avoids a fixed-port collision if the operator chose a
1800
+ // non-default --port.
1801
+ const MAXY_UI_INTERNAL_PORT = PORT + 1;
1917
1802
  const neo4jServiceDep = NEO4J_DEDICATED ? `neo4j-${BRAND.hostname}.service` : "neo4j.service";
1918
1803
  const serviceFile = `[Unit]
1919
1804
  Description=${BRAND.productName} AI Assistant
1920
- After=${neo4jServiceDep}
1805
+ After=${neo4jServiceDep} maxy-edge.service
1806
+ Wants=maxy-edge.service
1921
1807
 
1922
1808
  [Service]
1923
1809
  Type=notify
1924
1810
  NotifyAccess=all
1925
1811
  WorkingDirectory=${INSTALL_DIR}/server
1926
- ExecStartPre=/bin/bash ${INSTALL_DIR}/platform/scripts/vnc.sh start
1927
1812
  ExecStartPre=-/bin/bash ${INSTALL_DIR}/platform/scripts/resume-tunnel.sh
1928
1813
  ExecStart=/usr/bin/node --require ./server-init.cjs server.js
1929
- ExecStopPost=/bin/bash ${INSTALL_DIR}/platform/scripts/vnc.sh stop
1930
1814
  Restart=on-failure
1931
1815
  RestartSec=5
1932
1816
  WatchdogSec=30
1933
1817
  TimeoutStartSec=60
1934
1818
  TimeoutStopSec=10
1935
1819
  Environment=NODE_ENV=production
1936
- Environment=PORT=${PORT}
1937
- Environment=HOSTNAME=0.0.0.0
1820
+ Environment=PORT=${MAXY_UI_INTERNAL_PORT}
1821
+ Environment=HOSTNAME=127.0.0.1
1938
1822
  Environment=KEEP_ALIVE_TIMEOUT=61000
1939
1823
  Environment=DISPLAY=:99
1940
1824
  Environment=PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
@@ -1948,6 +1832,24 @@ StandardError=append:${persistDir}/logs/server.log
1948
1832
  WantedBy=default.target
1949
1833
  `;
1950
1834
  writeFileSync(join(serviceDir, BRAND.serviceName), serviceFile);
1835
+ // Task 647 — maxy-edge.service: the always-on front door that owns the
1836
+ // public port (PORT) and the VNC stack (Xtigervnc + websockify). Its
1837
+ // lifecycle is independent of maxy-ui, so an in-place upgrade triggered
1838
+ // from the admin terminal can restart maxy-ui without disconnecting the
1839
+ // browser's remote terminal WebSocket.
1840
+ const edgeTemplatePath = resolve(INSTALL_DIR, "platform/templates/systemd/maxy-edge.service");
1841
+ if (existsSync(edgeTemplatePath)) {
1842
+ const edgeServiceContent = readFileSync(edgeTemplatePath, "utf-8")
1843
+ .replace(/__INSTALL_DIR__/g, INSTALL_DIR)
1844
+ .replace(/__EDGE_PORT__/g, String(PORT))
1845
+ .replace(/__MAXY_UI_PORT__/g, String(MAXY_UI_INTERNAL_PORT))
1846
+ .replace(/__PERSIST_DIR__/g, persistDir);
1847
+ writeFileSync(join(serviceDir, "maxy-edge.service"), edgeServiceContent);
1848
+ logFile(` maxy-edge.service: EDGE_PORT=${PORT} MAXY_UI_PORT=${MAXY_UI_INTERNAL_PORT}`);
1849
+ }
1850
+ else {
1851
+ console.error(` WARNING: maxy-edge.service template missing at ${edgeTemplatePath} — remote terminal will disconnect during upgrade`);
1852
+ }
1951
1853
  // Task 560: the unit declares Environment=PATH=%h/.local/bin:... so the graph
1952
1854
  // MCP shim's spawn("uvx", ...) resolves against uv's install location. Without
1953
1855
  // this line, the service inherits the default systemd-user PATH — which
@@ -2000,10 +1902,19 @@ WantedBy=multi-user.target
2000
1902
  spawnSync("sudo", ["loginctl", "enable-linger", currentUser], { stdio: "inherit" });
2001
1903
  }
2002
1904
  catch { /* not critical */ }
2003
- // Reload and (re)start
1905
+ // Reload and (re)start.
1906
+ //
1907
+ // Task 647 ordering: on upgrades, the old maxy-ui still holds the public
1908
+ // port (PORT). Stop it FIRST so maxy-edge can bind that socket; starting
1909
+ // maxy-edge first would race against the old maxy-ui and fail with EADDRINUSE.
1910
+ // Fresh installs: the stop is a no-op.
2004
1911
  const unitName = BRAND.serviceName.replace(".service", "");
1912
+ spawnSync("systemctl", ["--user", "stop", unitName], { stdio: "inherit" });
2005
1913
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
1914
+ spawnSync("systemctl", ["--user", "enable", "maxy-edge"], { stdio: "inherit" });
2006
1915
  spawnSync("systemctl", ["--user", "enable", unitName], { stdio: "inherit" });
1916
+ // maxy-edge first: binds public port + starts VNC stack. Then maxy-ui.
1917
+ spawnSync("systemctl", ["--user", "restart", "maxy-edge"], { stdio: "inherit" });
2007
1918
  spawnSync("systemctl", ["--user", "restart", unitName], { stdio: "inherit" });
2008
1919
  // Wait for the server to come up
2009
1920
  console.log(" Waiting for web server...");
@@ -2383,7 +2294,7 @@ try {
2383
2294
  setupVncViewer();
2384
2295
  setupAccount();
2385
2296
  installTunnelScripts(); // ~/setup-tunnel.sh, ~/reset-tunnel.sh — the SKILL contract
2386
- installTerminalService(); // Task 591: ttyd + tmux systemd --user unit (sibling of maxy-ui)
2297
+ installTerminalService(); // Task 645: tears down Task 591's orphan maxy-ttyd.service on upgrades
2387
2298
  installService();
2388
2299
  console.log("");
2389
2300
  console.log("================================================================");
package/dist/uninstall.js CHANGED
@@ -92,6 +92,15 @@ function stopServices() {
92
92
  catch {
93
93
  console.log(` ${BRAND.serviceName} not running`);
94
94
  }
95
+ // Task 647: maxy-edge owns the public port and VNC stack. Stop it after
96
+ // maxy-ui so the edge's ExecStopPost (vnc.sh stop) runs cleanly.
97
+ try {
98
+ spawnSync("systemctl", ["--user", "stop", "maxy-edge"], { stdio: "pipe", timeout: 15_000 });
99
+ console.log(" Stopped maxy-edge");
100
+ }
101
+ catch {
102
+ console.log(" maxy-edge not running");
103
+ }
95
104
  // Stop Neo4j
96
105
  try {
97
106
  spawnSync("sudo", ["systemctl", "stop", "neo4j"], { stdio: "pipe", timeout: 15_000 });
@@ -462,6 +471,21 @@ function removeSystemdService() {
462
471
  console.log(` Failed to remove service file: ${err instanceof Error ? err.message : String(err)}`);
463
472
  }
464
473
  }
474
+ // Task 647: remove maxy-edge.service alongside maxy-ui.
475
+ try {
476
+ spawnSync("systemctl", ["--user", "disable", "maxy-edge"], { stdio: "pipe" });
477
+ }
478
+ catch { /* ignore */ }
479
+ const edgeServiceFile = resolve(HOME, ".config/systemd/user/maxy-edge.service");
480
+ if (existsSync(edgeServiceFile)) {
481
+ try {
482
+ rmSync(edgeServiceFile);
483
+ console.log(" Removed maxy-edge.service");
484
+ }
485
+ catch (err) {
486
+ console.log(` Failed to remove maxy-edge.service: ${err instanceof Error ? err.message : String(err)}`);
487
+ }
488
+ }
465
489
  // Reload daemon
466
490
  try {
467
491
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.676",
3
+ "version": "1.0.677",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -68,12 +68,11 @@ The logs will show which service failed to start and why. Common causes:
68
68
 
69
69
  ## Systemd units on each device
70
70
 
71
- Each Maxy device runs two independent `--user` systemd units:
71
+ Each Maxy device runs one `--user` systemd unit:
72
72
 
73
73
  - `maxy-ui.service` — the admin + public HTTP server (default port 19200). Restarted by the upgrade flow; short downtime is expected during steps 8→12 of an upgrade.
74
- - `maxy-ttyd.service` — a persistent PTY over WebSocket on `127.0.0.1:7681` attached to a tmux session named `maxy-pty`. Independent of `maxy-ui` — restarting the UI does not interrupt shell work, which is exactly the property the upgrade flow relies on to survive its own admin-server restart. The `ttyd` binary is installed at `/usr/local/bin/ttyd` from a pinned upstream GitHub release (SHA256-verified by the installer). It is NOT an apt package — Raspberry Pi OS (Bookworm-based) does not ship `ttyd` in apt.
75
74
 
76
- If the Software Update window's terminal goes blank and does not reconnect, `sudo systemctl --user status maxy-ttyd` is the first thing to check. Restarting the unit (`sudo systemctl --user restart maxy-ttyd`) is safe: the tmux session survives because `tmux new-session -A -s maxy-pty` is idempotent.
75
+ The admin terminal (header Terminal button and Software Update window) runs inside the VNC framebuffer — a `gnome-terminal`/`xterm` process spawned on display `:99` or the operator's native session and rendered in the admin UI via a noVNC iframe. The terminal is ephemeral and does not have its own systemd unit. If the Software Update window fails to open or the terminal doesn't appear, check `sudo tail -n 50 ~/.maxy/logs/terminal-launch.log` the VNC stack and spawn pipeline are the only components involved.
77
76
 
78
77
  ## Upgrading
79
78
 
@@ -52,7 +52,7 @@ The memory graph is stored on your Pi. It never leaves your network.
52
52
 
53
53
  ## The Web Interface
54
54
 
55
- The web app runs on your Pi on port 19200. It provides:
55
+ The web app runs on your Pi on port 19200. A small always-on front door (`maxy-edge`) owns that port and the remote terminal transport — so when the Software Update command restarts the app server, the browser-side terminal keeps streaming bytes exactly like an SSH session would. It provides:
56
56
 
57
57
  - **Admin chat** (at `/`) — your primary interface, PIN-protected
58
58
  - **Public chat** (at `/{agent-name}`) — visitor-facing agents, each with their own URL. On public hostnames, the root path serves the default agent.
@@ -124,7 +124,7 @@ DISPLAY=:99 xdpyinfo >/dev/null 2>&1 && echo "display :99 ok" || echo "display d
124
124
  **Common upgrade-specific failures:**
125
125
 
126
126
  - `err="no terminal emulator installed"` → run `sudo apt-get install -y xterm xdotool` or re-run `npx -y @rubytech/create-maxy@latest` — the installer provisions both as hard deps.
127
- - `err="spawn detached but no terminal PID visible within 1s" ... reason=upgrade` → X server on `:99` is wedged; `sudo systemctl --user restart maxy-ui` cycles the VNC stack via `vnc.sh start`. Then click **Try again** in the modal.
127
+ - `err="spawn detached but no terminal PID visible within 1s" ... reason=upgrade` → X server on `:99` is wedged. Since Task 647 the VNC stack is owned by `maxy-edge`, so the recovery is `sudo systemctl --user restart maxy-edge` (cycles `vnc.sh start` via that unit's ExecStartPre). Then click **Try again** in the modal.
128
128
  - `err="window absent from target display after spawn" ... reason=upgrade` → the spawn succeeded but landed on the wrong display (Task 632 class). Re-run the installer to refresh the `resolve_terminal_bin` logic. If a stale `gnome-terminal` is hitting `:99` via D-Bus delegation, the installer's `xterm` fallback is the fix.
129
129
  - `err="xdotool not installed — re-run installer to repair"` → Task 634 preflight. `sudo apt-get install -y xdotool` or re-run the installer.
130
130
  - `err="VNC failed to start after recovery attempt"` → `Xtigervnc` itself is not coming up. Inspect `~/.maxy/logs/vnc-boot.log` for tigervnc startup lines; `ss -ltn '( sport = 6080 or sport = 5900 )'` should show both ports listening.
@@ -158,7 +158,7 @@ If the `cmd=` field does not contain `-e bash -c`, re-run the installer — the
158
158
 
159
159
  **Symptom:** You opened the burger menu and clicked **Terminal**, but instead of the overlay appearing you got an inline error like "Terminal failed to start" or "VNC failed to start".
160
160
 
161
- **What it means:** `POST /api/admin/terminal/launch` returned a 502 — either the VNC stack on port 5900 is down or the terminal emulator could not be spawned on display `:99`. The header Terminal is decoupled from `ttyd`; this is a VNC-stack or display-spawn failure, not an upgrade-terminal problem.
161
+ **What it means:** `POST /api/admin/terminal/launch` returned a 502 — either the VNC stack on port 5900 is down or the terminal emulator could not be spawned on display `:99`. This is a VNC-stack or display-spawn failure.
162
162
 
163
163
  Step-by-step diagnosis:
164
164
 
@@ -212,9 +212,8 @@ start_chrome() {
212
212
  # on the right display here — no bug reachable on loopback)
213
213
  #
214
214
  # Prints "<bin>\t<flags>" on stdout (tab-separated), or exits non-zero with a
215
- # loud-fail log if neither is installed matches the operator invariant
216
- # "no ttyd fallback, no silent substitution" from Task 627. gnome-terminal
217
- # retains `--wait` (see Task 627 pgrep-visibility fix). xterm takes no flag.
215
+ # loud-fail log if neither is installed. gnome-terminal retains `--wait`
216
+ # (see Task 627 pgrep-visibility fix). xterm takes no flag.
218
217
  # xterm flags for the :99 VNC framebuffer. Fontconfig alias `monospace`
219
218
  # resolves via /etc/fonts to the distro's default monospace face (DejaVu
220
219
  # Sans Mono on Ubuntu/Debian) — no quoting needed, so the flag string
@@ -494,17 +493,39 @@ start_terminal_upgrade_on() {
494
493
  ;;
495
494
  esac
496
495
 
497
- log "Starting ${bin} ${flags} ${dispatch_flag} bash -c \"${upgrade_cmd}; exec bash\" on ${target_display} (${label}) reason=upgrade"
498
-
499
- # setsid -f detaches from this script's process group so the spawned terminal
500
- # survives vnc.sh exiting. Output to TERMINAL_LOG so spawn-time stderr is
501
- # captured. $flags stays unquoted for word-splitting (xterm's flags have
502
- # multiple tokens); bash_wrapper stays quoted so the whole command string
503
- # reaches bash -c as one argument.
504
- if [ -n "$flags" ]; then
505
- DISPLAY="${target_display}" setsid -f "$bin" $flags "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 || true
496
+ # Task 647: the upgrade command (`npx -y @rubytech/create-maxy@latest`) calls
497
+ # `systemctl --user restart maxy-ui` partway through. If the terminal shell
498
+ # lives in maxy-ui's cgroup it gets SIGKILL'd by that restart and the install
499
+ # aborts. `systemd-run --user --scope` places the shell in its own transient
500
+ # scope unit whose lifetime is independent of whichever service triggered
501
+ # vnc.sh. `setsid -f` is no longer needed scope units handle detachment.
502
+ # Fallback (no systemd-run): `setsid -f`, with the known caveat that the
503
+ # caller must not be inside a unit scheduled for restart during the install.
504
+ local run_prefix=""
505
+ if command -v systemd-run >/dev/null 2>&1; then
506
+ run_prefix="systemd-run --user --quiet --scope --unit=maxy-upgrade-terminal-$$.scope --collect --"
507
+ log "Wrapping upgrade terminal in systemd-run --user --scope (Task 647)"
508
+ else
509
+ log "WARNING: systemd-run not available — falling back to setsid -f (terminal may die on maxy-ui restart)"
510
+ fi
511
+ log "Starting ${run_prefix} ${bin} ${flags} ${dispatch_flag} bash -c \"${upgrade_cmd}; exec bash\" on ${target_display} (${label}) reason=upgrade"
512
+
513
+ # $flags stays unquoted for word-splitting (xterm's flags have multiple
514
+ # tokens); bash_wrapper stays quoted so the whole command string reaches
515
+ # bash -c as one argument. $run_prefix is unquoted so its words expand.
516
+ if [ -n "$run_prefix" ]; then
517
+ if [ -n "$flags" ]; then
518
+ DISPLAY="${target_display}" $run_prefix "$bin" $flags "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 &
519
+ else
520
+ DISPLAY="${target_display}" $run_prefix "$bin" "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 &
521
+ fi
522
+ disown 2>/dev/null || true
506
523
  else
507
- DISPLAY="${target_display}" setsid -f "$bin" "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 || true
524
+ if [ -n "$flags" ]; then
525
+ DISPLAY="${target_display}" setsid -f "$bin" $flags "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 || true
526
+ else
527
+ DISPLAY="${target_display}" setsid -f "$bin" "$dispatch_flag" bash -c "$bash_wrapper" >> "$TERMINAL_LOG" 2>&1 || true
528
+ fi
508
529
  fi
509
530
 
510
531
  if ! wait_for_terminal; then
@@ -0,0 +1,33 @@
1
+ [Unit]
2
+ Description=Maxy Edge (public port + VNC transport — independent of maxy-ui)
3
+ # No ordering dependency on maxy-ui: the edge is the long-running front door.
4
+ # maxy-ui.service declares Wants/After this unit so maxy-ui only starts once
5
+ # the edge is listening, but this unit has no such reciprocal dependency.
6
+
7
+ [Service]
8
+ Type=simple
9
+ # ExecStartPre owns the VNC stack so an `npx -y @rubytech/create-maxy@latest`
10
+ # run from the admin terminal can restart maxy-ui without taking Xtigervnc,
11
+ # websockify, or the upgrade shell down with it. Task 647.
12
+ ExecStartPre=/bin/bash __INSTALL_DIR__/platform/scripts/vnc.sh start
13
+ ExecStart=/usr/bin/node __INSTALL_DIR__/server/maxy-edge.js
14
+ ExecStopPost=/bin/bash __INSTALL_DIR__/platform/scripts/vnc.sh stop
15
+ Restart=on-failure
16
+ RestartSec=5
17
+ TimeoutStartSec=60
18
+ TimeoutStopSec=10
19
+ Environment=NODE_ENV=production
20
+ Environment=EDGE_PORT=__EDGE_PORT__
21
+ Environment=EDGE_HOSTNAME=0.0.0.0
22
+ Environment=MAXY_UI_HOST=127.0.0.1
23
+ Environment=MAXY_UI_PORT=__MAXY_UI_PORT__
24
+ Environment=WEBSOCKIFY_HOST=127.0.0.1
25
+ Environment=WEBSOCKIFY_PORT=6080
26
+ Environment=DISPLAY=:99
27
+ Environment=MAXY_PLATFORM_ROOT=__INSTALL_DIR__/platform
28
+ Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
29
+ StandardOutput=append:__PERSIST_DIR__/logs/edge.log
30
+ StandardError=append:__PERSIST_DIR__/logs/edge.log
31
+
32
+ [Install]
33
+ WantedBy=default.target