@rubytech/create-maxy 1.0.679 → 1.0.681

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,7 +2,8 @@
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 } from "node:crypto";
5
+ import { randomBytes, createHash } from "node:crypto";
6
+ import { TTYD_VERSION, TTYD_SHA256_BY_ARCH, mapUnameToTtydArch, ttydDownloadUrl, } from "./pinned-binaries.js";
6
7
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
7
8
  // Brand manifest — read from payload to derive all brand-specific installation values.
8
9
  // The bundler stamps brand.json into the payload at build time.
@@ -332,6 +333,7 @@ function pkgsMissing(pkgs) {
332
333
  function installAptGroup(label, pkgs) {
333
334
  const pairs = pkgs.map((original) => ({ original, resolved: resolveAptName(original) }));
334
335
  logFile(` apt install (${label}): ${pairs.map((x) => x.resolved).join(" ")}`);
336
+ console.log(" [privileged] apt-get install");
335
337
  shell("apt-get", ["install", "-y", ...pairs.map((x) => x.resolved)], { sudo: true });
336
338
  const stillMissing = pairs.filter(({ resolved }) => {
337
339
  const r = spawnSync("dpkg", ["-s", resolved], { stdio: "pipe", timeout: 5_000 });
@@ -364,8 +366,12 @@ function installSystemDeps() {
364
366
  // assertion in vnc.sh check_window_on_display, closing the silent-fail
365
367
  // class where PID is alive but no window is mapped on the target display.
366
368
  const VNC_DEPS = ["tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium", "xterm", "xdotool"];
369
+ // Task 657: tmux powers the byte-stream admin terminal. ttyd attaches the
370
+ // shared `maxy-pty` tmux session, so scrollback survives WS reconnects and
371
+ // the same session is reused by the header overlay + upgrade modal.
372
+ const TERMINAL_DEPS = ["tmux"];
367
373
  const WIFI_DEPS = ["hostapd", "dnsmasq"];
368
- const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...WIFI_DEPS];
374
+ const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...TERMINAL_DEPS, ...WIFI_DEPS];
369
375
  // Task 634 — verify the "deps are present" assumption with `dpkg -s` instead
370
376
  // of asserting it (feedback_loud_failures.md). The previous silent-skip
371
377
  // branch was benign until Task 632 added xdotool (the first new apt dep
@@ -388,6 +394,7 @@ function installSystemDeps() {
388
394
  }
389
395
  console.log(` Missing apt deps (${missing.length}): ${missing.join(", ")}`);
390
396
  console.log(` Installing via sudo apt-get — sudo may prompt for your password...`);
397
+ console.log(" [privileged] apt-get update");
391
398
  shell("apt-get", ["update"], { sudo: true });
392
399
  installAptGroup("base utilities", BASE_DEPS);
393
400
  installAptGroup("VNC stack", VNC_DEPS);
@@ -402,9 +409,12 @@ function installSystemDeps() {
402
409
  // --hostname flag: set unconditionally, no detection, no preservation logic.
403
410
  console.log(` Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
404
411
  try {
412
+ console.log(" [privileged] hostnamectl set-hostname");
405
413
  shell("hostnamectl", ["set-hostname", HOSTNAME_FLAG], { sudo: true });
414
+ console.log(" [privileged] sed -i");
406
415
  shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${HOSTNAME_FLAG}/`, "/etc/hosts"], { sudo: true });
407
416
  try {
417
+ console.log(" [privileged] sed -i");
408
418
  shell("sed", ["-i", `s/^[#]*host-name=.*/host-name=${HOSTNAME_FLAG}/`, "/etc/avahi/avahi-daemon.conf"], { sudo: true });
409
419
  console.log(` Avahi host-name: ${HOSTNAME_FLAG} (updated avahi-daemon.conf)`);
410
420
  }
@@ -455,9 +465,12 @@ function installSystemDeps() {
455
465
  console.log(` Hostname: ${BRAND.hostname} (${reason})`);
456
466
  hostnameSetAttempted = true;
457
467
  try {
468
+ console.log(" [privileged] hostnamectl set-hostname");
458
469
  shell("hostnamectl", ["set-hostname", BRAND.hostname], { sudo: true });
470
+ console.log(" [privileged] sed -i");
459
471
  shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${BRAND.hostname}/`, "/etc/hosts"], { sudo: true });
460
472
  try {
473
+ console.log(" [privileged] sed -i");
461
474
  shell("sed", ["-i", `s/^[#]*host-name=.*/host-name=${BRAND.hostname}/`, "/etc/avahi/avahi-daemon.conf"], { sudo: true });
462
475
  console.log(` Avahi host-name: ${BRAND.hostname} (updated avahi-daemon.conf)`);
463
476
  }
@@ -500,8 +513,11 @@ function installSystemDeps() {
500
513
  const avahiDestPath = `/etc/avahi/services/${BRAND.hostname}.service`;
501
514
  try {
502
515
  writeFileSync(avahiTmpPath, avahiService);
516
+ console.log(" [privileged] cp");
503
517
  shell("cp", [avahiTmpPath, avahiDestPath], { sudo: true });
518
+ console.log(" [privileged] systemctl enable");
504
519
  shell("systemctl", ["enable", "avahi-daemon"], { sudo: true });
520
+ console.log(" [privileged] systemctl restart");
505
521
  shell("systemctl", ["restart", "avahi-daemon"], { sudo: true });
506
522
  }
507
523
  catch { /* not critical */ }
@@ -532,6 +548,7 @@ function installSystemDeps() {
532
548
  if (existsSync("/usr/bin/nmcli") && !existsSync(nmConfFile)) {
533
549
  console.log(" Disabling WiFi power save...");
534
550
  writeFileSync(`/tmp/${BRAND.hostname}-no-powersave.conf`, "[connection]\nwifi.powersave = 2\n");
551
+ console.log(" [privileged] cp");
535
552
  shell("cp", [`/tmp/${BRAND.hostname}-no-powersave.conf`, nmConfFile], { sudo: true });
536
553
  spawnSync("sudo", ["systemctl", "restart", "NetworkManager"], { stdio: "pipe" });
537
554
  }
@@ -549,6 +566,7 @@ function installNodejs() {
549
566
  throw new Error("Automatic Node.js installation is only supported on Linux. Install Node.js 20+ manually.");
550
567
  }
551
568
  spawnSync("bash", ["-c", "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -"], { stdio: "inherit" });
569
+ console.log(" [privileged] apt-get install");
552
570
  shell("apt-get", ["install", "-y", "nodejs"], { sudo: true });
553
571
  }
554
572
  function installClaudeCode() {
@@ -592,6 +610,7 @@ function installClaudeCode() {
592
610
  }
593
611
  else {
594
612
  console.log(" This may take 15–30 minutes on Raspberry Pi...");
613
+ console.log(" [privileged] npm install -g @anthropic-ai/claude-code@latest");
595
614
  shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
596
615
  3, 30);
597
616
  }
@@ -668,8 +687,10 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
668
687
  });
669
688
  }
670
689
  else {
690
+ console.log(" [privileged] neo4j-admin dbms");
671
691
  shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
672
692
  }
693
+ console.log(" [privileged] systemctl start");
673
694
  shell("systemctl", ["start", serviceName], { sudo: true });
674
695
  console.log(" Waiting for Neo4j to start...");
675
696
  for (let i = 0; i < 15; i++) {
@@ -797,11 +818,15 @@ function installNeo4j() {
797
818
  const has17 = policyResult.status === 0 && !policyOutput.includes("Candidate: (none)");
798
819
  const javaPackage = has17 ? "openjdk-17-jre-headless" : "openjdk-21-jre-headless";
799
820
  console.log(` Installing Java (${javaPackage})...`);
821
+ console.log(" [privileged] apt-get install");
800
822
  shell("apt-get", ["install", "-y", javaPackage], { sudo: true });
801
823
  spawnSync("bash", ["-c", "curl -fsSL https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --yes --dearmor -o /usr/share/keyrings/neo4j.gpg 2>/dev/null"], { stdio: "inherit" });
802
824
  spawnSync("bash", ["-c", 'echo "deb [signed-by=/usr/share/keyrings/neo4j.gpg] https://debian.neo4j.com stable 5" | sudo tee /etc/apt/sources.list.d/neo4j.list'], { stdio: "inherit" });
825
+ console.log(" [privileged] apt-get update");
803
826
  shell("apt-get", ["update"], { sudo: true });
827
+ console.log(" [privileged] apt-get install");
804
828
  shell("apt-get", ["install", "-y", "neo4j"], { sudo: true });
829
+ console.log(" [privileged] sed -i");
805
830
  shell("sed", ["-i", "s/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/", "/etc/neo4j/neo4j.conf"], { sudo: true });
806
831
  // Generate strong random password — stored in persistent location (~/{configDir}/)
807
832
  const password = randomBytes(24).toString("base64url");
@@ -812,8 +837,11 @@ function installNeo4j() {
812
837
  const configDir = resolve(INSTALL_DIR, "platform/config");
813
838
  mkdirSync(configDir, { recursive: true });
814
839
  writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
840
+ console.log(" [privileged] neo4j-admin dbms");
815
841
  shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
842
+ console.log(" [privileged] systemctl enable");
816
843
  shell("systemctl", ["enable", "neo4j"], { sudo: true });
844
+ console.log(" [privileged] systemctl start");
817
845
  shell("systemctl", ["start", "neo4j"], { sudo: true });
818
846
  console.log(" Neo4j started. Password stored securely.");
819
847
  }
@@ -851,11 +879,16 @@ function setupDedicatedNeo4j() {
851
879
  throw new Error("/etc/neo4j/neo4j.conf not found. Cannot create dedicated instance without base config.");
852
880
  }
853
881
  // 1. Copy base config
882
+ console.log(" [privileged] cp -r");
854
883
  shell("cp", ["-r", "/etc/neo4j", confDir], { sudo: true });
855
884
  // 2. Modify config for this instance: bolt port, HTTP port, data/log directories
885
+ console.log(" [privileged] sed -i");
856
886
  shell("sed", ["-i", `s/^#\\?server\\.bolt\\.listen_address=.*/server.bolt.listen_address=:${NEO4J_PORT}/`, `${confDir}/neo4j.conf`], { sudo: true });
887
+ console.log(" [privileged] sed -i");
857
888
  shell("sed", ["-i", `s/^#\\?server\\.http\\.listen_address=.*/server.http.listen_address=:${httpPort}/`, `${confDir}/neo4j.conf`], { sudo: true });
889
+ console.log(" [privileged] sed -i");
858
890
  shell("sed", ["-i", `s|^#\\?server\\.directories\\.data=.*|server.directories.data=${dataDir}/data|`, `${confDir}/neo4j.conf`], { sudo: true });
891
+ console.log(" [privileged] sed -i");
859
892
  shell("sed", ["-i", `s|^#\\?server\\.directories\\.logs=.*|server.directories.logs=${logDir}|`, `${confDir}/neo4j.conf`], { sudo: true });
860
893
  // Verify config was updated — sed silently no-ops if the key format changed
861
894
  const confContent = spawnSync("grep", [`server.bolt.listen_address=:${NEO4J_PORT}`, `${confDir}/neo4j.conf`], { stdio: "pipe" });
@@ -864,7 +897,9 @@ function setupDedicatedNeo4j() {
864
897
  logFile(` WARNING: sed verification failed — bolt port ${NEO4J_PORT} not found in ${confDir}/neo4j.conf`);
865
898
  }
866
899
  // 3. Create data and log directories
900
+ console.log(" [privileged] mkdir -p");
867
901
  shell("mkdir", ["-p", `${dataDir}/data`, logDir], { sudo: true });
902
+ console.log(" [privileged] chown -R");
868
903
  shell("chown", ["-R", "neo4j:neo4j", dataDir, logDir, confDir], { sudo: true });
869
904
  // 4. Create systemd service
870
905
  const serviceContent = `[Unit]
@@ -885,6 +920,7 @@ WantedBy=multi-user.target
885
920
  `;
886
921
  const tmpServicePath = `/tmp/${serviceName}.service`;
887
922
  writeFileSync(tmpServicePath, serviceContent);
923
+ console.log(" [privileged] cp");
888
924
  shell("cp", [tmpServicePath, `/etc/systemd/system/${serviceName}.service`], { sudo: true });
889
925
  spawnSync("rm", ["-f", tmpServicePath]);
890
926
  // 5. Set initial password before first start
@@ -901,7 +937,9 @@ WantedBy=multi-user.target
901
937
  });
902
938
  // 6. Enable and start the dedicated service
903
939
  spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
940
+ console.log(" [privileged] systemctl enable");
904
941
  shell("systemctl", ["enable", serviceName], { sudo: true });
942
+ console.log(" [privileged] systemctl start");
905
943
  shell("systemctl", ["start", serviceName], { sudo: true });
906
944
  // 7. Verify connectivity — poll until cypher-shell can connect
907
945
  console.log(` Waiting for dedicated Neo4j instance on port ${NEO4J_PORT}...`);
@@ -1046,6 +1084,7 @@ function installCloudflared() {
1046
1084
  const arch = isArm64() ? "arm64" : "amd64";
1047
1085
  const debPath = "/tmp/cloudflared.deb";
1048
1086
  shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
1087
+ console.log(" [privileged] dpkg -i");
1049
1088
  shell("dpkg", ["-i", debPath], { sudo: true });
1050
1089
  spawnSync("rm", ["-f", debPath]);
1051
1090
  }
@@ -1063,19 +1102,24 @@ function installWhisperCpp() {
1063
1102
  return;
1064
1103
  }
1065
1104
  // Build dependencies — cmake is required since whisper.cpp migrated from plain make
1105
+ console.log(" [privileged] apt-get install");
1066
1106
  shell("apt-get", ["install", "-y", "build-essential", "cmake"], { sudo: true });
1067
1107
  // Clone or update the repository
1068
1108
  if (!existsSync(WHISPER_DIR)) {
1069
1109
  console.log(" Cloning whisper.cpp...");
1110
+ console.log(" [privileged] git clone");
1070
1111
  shell("git", ["clone", "--depth", "1", "https://github.com/ggerganov/whisper.cpp.git", WHISPER_DIR], { sudo: true });
1071
1112
  }
1072
1113
  // Compile via cmake (whisper.cpp's Makefile is a thin cmake wrapper)
1073
1114
  console.log(" Compiling whisper.cpp (this takes a few minutes on Pi)...");
1115
+ console.log(" [privileged] cmake -B");
1074
1116
  shell("cmake", ["-B", "build"], { cwd: WHISPER_DIR, sudo: true, timeout: 120_000 });
1117
+ console.log(" [privileged] cmake --build");
1075
1118
  shell("cmake", ["--build", "build", "--config", "Release", "-j2"], { cwd: WHISPER_DIR, sudo: true, timeout: 600_000 });
1076
1119
  // Download the base model (~150MB)
1077
1120
  if (!existsSync(WHISPER_MODEL)) {
1078
1121
  console.log(" Downloading ggml-base model (~150MB)...");
1122
+ console.log(" [privileged] bash -c");
1079
1123
  shellRetry("bash", ["-c", `cd ${WHISPER_DIR} && bash models/download-ggml-model.sh base`], { sudo: true, timeout: 300_000 }, 3, 15);
1080
1124
  }
1081
1125
  console.log(" whisper.cpp installed successfully.");
@@ -1690,56 +1734,180 @@ function installCrons() {
1690
1734
  logFile(` crontab write failed: ${write.stderr}`);
1691
1735
  }
1692
1736
  }
1693
- // Task 645 idempotent tear-down of the Task 591 ttyd/tmux pipeline. Task 643
1694
- // collapsed the admin upgrade and header-Terminal surfaces onto the existing
1695
- // VNC terminal path; the parallel ttyd+xterm.js pipeline became orphan code.
1696
- // Fresh devices never provision it. Devices carrying the Task 591 install
1697
- // get their maxy-ttyd.service stopped, disabled, and removed on the first
1698
- // post-645 installer run. Subsequent runs are silent no-ops the absence
1699
- // of the unit file short-circuits before any systemctl call is reached.
1700
- function installTerminalService() {
1701
- log("11", TOTAL, "Removing legacy admin terminal service (maxy-ttyd)...");
1702
- if (!isLinux()) {
1703
- return;
1737
+ // Task 657 restored the Task-591 ttyd/tmux pipeline after Task 645's
1738
+ // tear-down. Rationale: Task 643 collapsed the upgrade surface onto VNC, but
1739
+ // the RFB + X-focus path silently drops keystrokes at `[sudo] password for`.
1740
+ // The byte-stream surface (ttyd + tmux + xterm.js) is SSH-equivalent — the
1741
+ // operator's stated success case and is now attached to `maxy-edge.service`
1742
+ // so the WS transport survives `systemctl --user restart maxy-ui` during an
1743
+ // in-browser upgrade (Task 647 invariant holds by construction).
1744
+ const TTYD_INSTALL_PATH = "/usr/local/bin/ttyd";
1745
+ function sha256File(path) {
1746
+ const hash = createHash("sha256");
1747
+ hash.update(readFileSync(path));
1748
+ return hash.digest("hex");
1749
+ }
1750
+ // Provision the upstream ttyd binary into /usr/local/bin/ttyd. Degrades with
1751
+ // a loud warning and a copy-pasteable remediation command on any failure —
1752
+ // never throws. Contract: the caller (installTerminalService) uses the
1753
+ // presence of TTYD_INSTALL_PATH after return to decide whether to enable the
1754
+ // maxy-ttyd.service systemd unit. ttyd is NOT in Debian Bookworm apt, so we
1755
+ // own the full download / verify / install flow here.
1756
+ function provisionTtydBinary() {
1757
+ const unameRaw = spawnSync("uname", ["-m"], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
1758
+ const uname = (unameRaw.stdout || "").trim();
1759
+ const arch = mapUnameToTtydArch(uname);
1760
+ if (arch === null) {
1761
+ console.error(` WARNING: ttyd — unsupported architecture 'uname -m'='${uname}'. Admin terminal will be unavailable.`);
1762
+ 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}'.`);
1763
+ return false;
1704
1764
  }
1705
- const homeDir = process.env.HOME ?? "/root";
1706
- const unitPath = resolve(homeDir, ".config/systemd/user/maxy-ttyd.service");
1707
- if (!existsSync(unitPath)) {
1708
- return;
1765
+ const pinnedDigest = TTYD_SHA256_BY_ARCH[arch];
1766
+ const url = ttydDownloadUrl(arch);
1767
+ const remediation = `curl -L -o /tmp/ttyd.${arch} '${url}' && sudo mv /tmp/ttyd.${arch} ${TTYD_INSTALL_PATH} && sudo chmod +x ${TTYD_INSTALL_PATH}`;
1768
+ // Idempotency: existing binary with matching pinned digest → skip download.
1769
+ if (existsSync(TTYD_INSTALL_PATH)) {
1770
+ try {
1771
+ const existingDigest = sha256File(TTYD_INSTALL_PATH);
1772
+ if (existingDigest === pinnedDigest) {
1773
+ console.log(` ttyd ${TTYD_VERSION} already installed at ${TTYD_INSTALL_PATH} (SHA256 match — skipping download)`);
1774
+ return true;
1775
+ }
1776
+ console.log(` ttyd at ${TTYD_INSTALL_PATH} has different digest — replacing with pinned ${TTYD_VERSION}`);
1777
+ }
1778
+ catch (err) {
1779
+ console.error(` WARNING: could not read existing ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)} — will overwrite`);
1780
+ }
1781
+ }
1782
+ if (!canSudo()) {
1783
+ console.error(` WARNING: ttyd — sudo unavailable non-interactively, cannot write ${TTYD_INSTALL_PATH}. Admin terminal will be unavailable.`);
1784
+ console.error(` Remediate: ${remediation}`);
1785
+ return false;
1786
+ }
1787
+ const tmpPath = `/tmp/ttyd.${arch}`;
1788
+ try {
1789
+ console.log(` Downloading ttyd ${TTYD_VERSION} for ${arch} from ${url}`);
1790
+ shellRetry("curl", ["-fL", "--retry", "3", "--retry-delay", "5", "-o", tmpPath, url], { timeout: 60_000 });
1791
+ }
1792
+ catch (err) {
1793
+ console.error(` WARNING: ttyd download failed: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1794
+ console.error(` Remediate: ${remediation}`);
1795
+ try {
1796
+ unlinkSync(tmpPath);
1797
+ }
1798
+ catch { /* nothing to clean */ }
1799
+ return false;
1800
+ }
1801
+ let actualDigest;
1802
+ try {
1803
+ actualDigest = sha256File(tmpPath);
1804
+ }
1805
+ catch (err) {
1806
+ console.error(` WARNING: ttyd — could not read downloaded file ${tmpPath}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1807
+ try {
1808
+ unlinkSync(tmpPath);
1809
+ }
1810
+ catch { /* nothing to clean */ }
1811
+ return false;
1709
1812
  }
1710
- console.error(" [installer] maxy-ttyd: stopping and removing stale service (Task 645 orphan cleanup)");
1711
- // Stop and disable are best-effort systemctl returns non-zero on a unit
1712
- // that's already inactive or never enabled, which is fine. stdio: "pipe"
1713
- // keeps the operator terminal clean; diagnostics go to the installer log
1714
- // via spawnSync's return if anyone needs them.
1715
- spawnSync("systemctl", ["--user", "stop", "maxy-ttyd"], { stdio: "pipe", timeout: 10_000 });
1716
- spawnSync("systemctl", ["--user", "disable", "maxy-ttyd"], { stdio: "pipe", timeout: 10_000 });
1813
+ if (actualDigest !== pinnedDigest) {
1814
+ console.error(` WARNING: ttyd SHA256 mismatchrefusing to install unverified binary.`);
1815
+ console.error(` expected: ${pinnedDigest}`);
1816
+ console.error(` actual: ${actualDigest}`);
1817
+ console.error(` Admin terminal will be unavailable. A later installer version may pin a newer digest.`);
1818
+ try {
1819
+ unlinkSync(tmpPath);
1820
+ }
1821
+ catch { /* nothing to clean */ }
1822
+ return false;
1823
+ }
1824
+ console.log(` ttyd ${TTYD_VERSION} SHA256 verified (${actualDigest.slice(0, 12)}…)`);
1717
1825
  try {
1718
- unlinkSync(unitPath);
1826
+ console.log(` [privileged] install ttyd binary to ${TTYD_INSTALL_PATH}`);
1827
+ shell("mv", [tmpPath, TTYD_INSTALL_PATH], { sudo: true });
1828
+ console.log(` [privileged] chmod +x ${TTYD_INSTALL_PATH}`);
1829
+ shell("chmod", ["+x", TTYD_INSTALL_PATH], { sudo: true });
1719
1830
  }
1720
1831
  catch (err) {
1721
- console.error(` WARNING: could not remove ${unitPath}: ${err instanceof Error ? err.message : String(err)}`);
1832
+ console.error(` WARNING: ttyd — could not install to ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
1833
+ console.error(` Remediate: ${remediation}`);
1834
+ try {
1835
+ unlinkSync(tmpPath);
1836
+ }
1837
+ catch { /* already moved or cleaned */ }
1838
+ return false;
1839
+ }
1840
+ console.log(` ttyd ${TTYD_VERSION} installed at ${TTYD_INSTALL_PATH}`);
1841
+ return true;
1842
+ }
1843
+ function installTerminalService() {
1844
+ log("11", TOTAL, "Installing admin terminal service (ttyd + tmux)...");
1845
+ if (!isLinux()) {
1846
+ console.log(" Skipping admin terminal service (not Linux). On macOS start manually:");
1847
+ console.log(" brew install ttyd tmux && ttyd -p 7681 -i 127.0.0.1 -W tmux new-session -A -s maxy-pty");
1722
1848
  return;
1723
1849
  }
1724
- spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe", timeout: 10_000 });
1725
- // ~/.tmux.conf cleanup: only remove if the contents match what the installer
1726
- // seeded (exact template or exact fallback). Anything else = operator-owned,
1727
- // leave untouched. Matches the Task 645 brief: "if the installer owned it,
1728
- // it's been removed; if the operator owns it, untouched."
1850
+ // ttyd is provisioned from upstream GitHub releases (pinned + SHA256-verified)
1851
+ // because Debian Bookworm's apt does NOT carry a ttyd package (Task 602).
1852
+ // A failure here is loud but non-fatal the rest of the install completes
1853
+ // and the admin UI degrades to "terminal unavailable" per Task 603.
1854
+ const ttydReady = provisionTtydBinary();
1855
+ // Default ~/.tmux.conf — only written if the operator doesn't already have
1856
+ // one. `history-limit 50000` is load-bearing: a closed-tab + reopen during
1857
+ // an upgrade must show every line the operator missed in scrollback.
1858
+ const homeDir = process.env.HOME ?? "/root";
1729
1859
  const tmuxConfDest = resolve(homeDir, ".tmux.conf");
1730
- if (existsSync(tmuxConfDest)) {
1860
+ if (!existsSync(tmuxConfDest)) {
1861
+ const tmuxConfTemplate = resolve(INSTALL_DIR, "platform/templates/dotfiles/.tmux.conf");
1731
1862
  try {
1732
- const contents = readFileSync(tmuxConfDest, "utf-8");
1733
- const seededFallback = "set -g history-limit 50000\n";
1734
- if (contents === seededFallback) {
1735
- unlinkSync(tmuxConfDest);
1736
- console.log(" Removed installer-seeded ~/.tmux.conf (orphan after Task 645)");
1863
+ if (existsSync(tmuxConfTemplate)) {
1864
+ writeFileSync(tmuxConfDest, readFileSync(tmuxConfTemplate, "utf-8"));
1865
+ console.log(` Wrote default ~/.tmux.conf (history-limit 50000)`);
1866
+ }
1867
+ else {
1868
+ // Fallback if the template was not in the payload for any reason —
1869
+ // preserves the load-bearing scrollback-size guarantee.
1870
+ writeFileSync(tmuxConfDest, "set -g history-limit 50000\n");
1871
+ console.log(` Wrote default ~/.tmux.conf (fallback — template missing)`);
1737
1872
  }
1738
1873
  }
1739
1874
  catch (err) {
1740
- console.error(` WARNING: could not inspect ~/.tmux.conf: ${err instanceof Error ? err.message : String(err)}`);
1875
+ console.error(` WARNING: failed to write ~/.tmux.conf: ${err instanceof Error ? err.message : String(err)}`);
1876
+ }
1877
+ }
1878
+ // Install and enable the maxy-ttyd.service --user unit. Independent of
1879
+ // BRAND.serviceName — a single device runs one admin terminal regardless of
1880
+ // brand, because the unit binds to 127.0.0.1:7681 which only one process can
1881
+ // hold anyway. On a multi-brand device, the first brand's install writes the
1882
+ // unit and every subsequent install is a no-op (idempotent overwrite).
1883
+ const systemdUserDir = resolve(homeDir, ".config/systemd/user");
1884
+ mkdirSync(systemdUserDir, { recursive: true });
1885
+ // Skip systemd-unit install if the ttyd binary is not in place — enabling
1886
+ // a unit whose ExecStart points at a missing file just churns systemd with
1887
+ // restart failures.
1888
+ if (!ttydReady) {
1889
+ console.error(" Skipping maxy-ttyd.service install — ttyd binary not present. Admin terminal will be unavailable until remediated.");
1890
+ return;
1891
+ }
1892
+ const ttydUnitTemplate = resolve(INSTALL_DIR, "platform/templates/systemd/maxy-ttyd.service");
1893
+ const ttydUnitDest = join(systemdUserDir, "maxy-ttyd.service");
1894
+ try {
1895
+ if (existsSync(ttydUnitTemplate)) {
1896
+ writeFileSync(ttydUnitDest, readFileSync(ttydUnitTemplate, "utf-8"));
1741
1897
  }
1898
+ else {
1899
+ console.error(` WARNING: maxy-ttyd.service template missing at ${ttydUnitTemplate} — admin terminal will not work`);
1900
+ return;
1901
+ }
1902
+ }
1903
+ catch (err) {
1904
+ console.error(` WARNING: failed to write ${ttydUnitDest}: ${err instanceof Error ? err.message : String(err)}`);
1905
+ return;
1742
1906
  }
1907
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
1908
+ spawnSync("systemctl", ["--user", "enable", "maxy-ttyd"], { stdio: "inherit" });
1909
+ spawnSync("systemctl", ["--user", "restart", "maxy-ttyd"], { stdio: "inherit" });
1910
+ console.log(" maxy-ttyd.service enabled — admin terminal available on 127.0.0.1:7681");
1743
1911
  }
1744
1912
  function installService() {
1745
1913
  log("12", TOTAL, `Starting ${BRAND.productName}...`);
@@ -1754,6 +1922,7 @@ function installService() {
1754
1922
  try {
1755
1923
  const sysctlConf = "net.core.rmem_max=7340032\nnet.core.wmem_max=7340032\n";
1756
1924
  writeFileSync(sysctlTmpPath, sysctlConf);
1925
+ console.log(" [privileged] cp");
1757
1926
  shell("cp", [sysctlTmpPath, sysctlDestPath], { sudo: true });
1758
1927
  spawnSync("rm", ["-f", sysctlTmpPath]);
1759
1928
  spawnSync("sudo", ["sysctl", "--system"], { stdio: "ignore", timeout: 10_000 });
@@ -1904,6 +2073,7 @@ WantedBy=multi-user.target
1904
2073
  try {
1905
2074
  const tmpPath = "/tmp/wifi-provision.service";
1906
2075
  writeFileSync(tmpPath, wifiProvisionService);
2076
+ console.log(" [privileged] cp");
1907
2077
  shell("cp", [tmpPath, wifiProvisionPath], { sudo: true });
1908
2078
  spawnSync("rm", ["-f", tmpPath]);
1909
2079
  spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
@@ -2317,7 +2487,7 @@ try {
2317
2487
  setupVncViewer();
2318
2488
  setupAccount();
2319
2489
  installTunnelScripts(); // ~/setup-tunnel.sh, ~/reset-tunnel.sh — the SKILL contract
2320
- installTerminalService(); // Task 645: tears down Task 591's orphan maxy-ttyd.service on upgrades
2490
+ installTerminalService(); // Task 657: installs maxy-ttyd.service (ttyd + tmux) for byte-stream admin terminal
2321
2491
  installService();
2322
2492
  console.log("");
2323
2493
  console.log("================================================================");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.679",
3
+ "version": "1.0.681",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -70,9 +70,11 @@ The logs will show which service failed to start and why. Common causes:
70
70
 
71
71
  Each Maxy device runs one `--user` systemd unit:
72
72
 
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.
73
+ - `maxy-ui.service` — the admin + public HTTP server on `127.0.0.1:19199`. Restarted by the upgrade flow; short downtime is expected during steps 8→12 of an upgrade.
74
+ - `maxy-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to `maxy-ui`, handles `/websockify` (VNC) and `/ttyd` (admin terminal) WebSocket upgrades locally. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
75
+ - `maxy-ttyd.service` — `ttyd` bound to `127.0.0.1:7681`, running `tmux new-session -A -s maxy-pty`. Owns the byte-stream admin terminal rendered by xterm.js in the header overlay and the Software Update modal (Task 657). Independent of `maxy-ui` and `maxy-edge`; outlives service restarts so scrollback is preserved.
74
76
 
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
+ If the admin terminal fails to open, check `sudo tail -n 50 ~/.maxy/logs/edge-boot.log`the `ttyd-ws-upgrade` / `ttyd-proxy-open` / `ttyd-proxy-close` lines carry a `corrId` that ties the full session lifecycle together. For unit health, `systemctl --user status maxy-ttyd` + `journalctl --user -u maxy-ttyd`.
76
78
 
77
79
  ## Upgrading
78
80
 
@@ -95,6 +95,8 @@ If the initial Cloudflare login fails during setup, Maxy will fall back to askin
95
95
 
96
96
  ## Software Update click shows an error instead of opening the terminal
97
97
 
98
+ > **Stale content — Task 657 replaced the VNC-terminal surface with byte-stream xterm.js over `/ttyd`.** The VNC launch-upgrade path described below no longer exists. First-line diagnostic for the new surface: `sudo systemctl --user status maxy-ttyd` plus `sudo grep 'ttyd-proxy' ~/.maxy/logs/edge-boot.log | tail -20`. Failure mode signals: `ttyd-ws-upgrade accepted` with no `ttyd-proxy-open` → `maxy-ttyd.service` is down; `ttyd-proxy-open` with no `ttyd-proxy-chunk dir=upstream→client` → ttyd/tmux is not attaching a PTY. Full rewrite tracked in Task 658. The section below is kept only as a historical reference for devices still on pre-Task-657 bundles.
99
+
98
100
  **Symptom:** You clicked **Upgrade** in the Software Update modal, but instead of the VNC terminal overlay appearing, the modal shows a red error row like:
99
101
 
100
102
  ```