@rubytech/create-maxy 1.0.686 → 1.0.688

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,193 @@
1
+ // Task 666 — acceptance gate for the port writer + drift-recovery flow.
2
+ //
3
+ // Three invariants this test protects:
4
+ // (a) PORT stays public across install → upgrade on a fresh fs fixture.
5
+ // Without this, each upgrade re-inherits the internal port as if it
6
+ // were public — the exact regression this task fixes.
7
+ // (b) Drift-recovery logs fire EXACTLY once when a pre-task maxy.service
8
+ // with a drifted PORT meets a post-task installer. A second upgrade
9
+ // on the same device must be silent.
10
+ // (c) MAXY_UI_INTERNAL_PORT is PORT + 1 in every written unit file, so
11
+ // maxy-ui binds the internal port while the tunnel still hits edge.
12
+ //
13
+ // Runs via Node's built-in test runner; no vitest dependency. Compiles to
14
+ // dist/__tests__/port-canonicalisation.test.js alongside the rest of the
15
+ // package so `node --test dist/__tests__/*.test.js` picks it up after build.
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { resolveInstallPort, resolveInstallPortFromFs, buildMaxyUnitFile, } from "../port-resolution.js";
22
+ function makeUnitFile(port, internal) {
23
+ return buildMaxyUnitFile({
24
+ productName: "Maxy",
25
+ brandHostname: "maxy",
26
+ neo4jDedicated: false,
27
+ installDir: "/home/me/maxy",
28
+ persistDir: "/home/me/.maxy",
29
+ port,
30
+ maxyUiInternalPort: internal,
31
+ });
32
+ }
33
+ function makeEdgeFile(edgePort) {
34
+ return `[Unit]\nDescription=Edge\n\n[Service]\nEnvironment=EDGE_PORT=${edgePort}\nEnvironment=MAXY_UI_PORT=${edgePort + 1}\n`;
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // (a) Fresh install writes PORT=public + MAXY_UI_INTERNAL_PORT=public+1 —
38
+ // and a subsequent upgrade reads the SAME public value back. No drift.
39
+ // ---------------------------------------------------------------------------
40
+ test("fresh install then upgrade keeps PORT public (no drift)", () => {
41
+ const PUBLIC = 19200;
42
+ const INTERNAL = PUBLIC + 1;
43
+ // Fresh install: no existing service file, no .env → default.
44
+ const first = resolveInstallPort({
45
+ envContent: null,
46
+ maxyServiceContent: null,
47
+ edgeServiceContent: null,
48
+ });
49
+ assert.equal(first.port, PUBLIC);
50
+ assert.equal(first.source, "default");
51
+ assert.equal(first.driftLogs.length, 0);
52
+ // Installer writes the unit files with PORT=public, MAXY_UI_INTERNAL_PORT=+1.
53
+ const maxyUnit = makeUnitFile(first.port, first.port + 1);
54
+ const edgeUnit = makeEdgeFile(first.port);
55
+ // Upgrade reads them back. Same public value, no drift.
56
+ const second = resolveInstallPort({
57
+ envContent: null,
58
+ maxyServiceContent: maxyUnit,
59
+ edgeServiceContent: edgeUnit,
60
+ });
61
+ assert.equal(second.port, PUBLIC, "upgrade must not drift the public port");
62
+ assert.equal(second.source, "running service");
63
+ assert.equal(second.driftLogs.length, 0, "no drift expected on clean device");
64
+ // And one more upgrade for good measure — still silent.
65
+ const third = resolveInstallPort({
66
+ envContent: null,
67
+ maxyServiceContent: maxyUnit,
68
+ edgeServiceContent: edgeUnit,
69
+ });
70
+ assert.equal(third.port, PUBLIC);
71
+ assert.equal(third.driftLogs.length, 0);
72
+ // Unit file literal invariants (c): PORT is the public, INTERNAL is +1.
73
+ assert.match(maxyUnit, /^Environment=PORT=19200$/m);
74
+ assert.match(maxyUnit, /^Environment=MAXY_UI_INTERNAL_PORT=19201$/m);
75
+ });
76
+ // ---------------------------------------------------------------------------
77
+ // (b) Drifted device: pre-task maxy.service has the INTERNAL port (19201) in
78
+ // the PORT slot, edge has the canonical public (19200). Recovery logs once,
79
+ // pins at edge, and the next pass on the rewritten files is silent.
80
+ // ---------------------------------------------------------------------------
81
+ test("drifted device recovers in one pass, subsequent upgrade is silent", () => {
82
+ const TRUE_PUBLIC = 19200;
83
+ // Pre-task unit: Environment=PORT=<internal>. Edge correctly carries public.
84
+ const driftedMaxy = makeUnitFile(TRUE_PUBLIC + 1, TRUE_PUBLIC + 2); // drifted
85
+ const edgeAtTruth = makeEdgeFile(TRUE_PUBLIC);
86
+ const recovery = resolveInstallPort({
87
+ envContent: null,
88
+ maxyServiceContent: driftedMaxy,
89
+ edgeServiceContent: edgeAtTruth,
90
+ });
91
+ assert.equal(recovery.port, TRUE_PUBLIC, "edge wins over drifted maxy");
92
+ assert.equal(recovery.source, "edge-recovery");
93
+ assert.equal(recovery.driftLogs.length, 1, "exactly one drift line");
94
+ assert.match(recovery.driftLogs[0], /\[port-recovery\] detected drift maxy=19201 edge=19200 — pinning at 19200/);
95
+ // After recovery, the installer writes a corrected maxy.service.
96
+ const rewritten = makeUnitFile(recovery.port, recovery.port + 1);
97
+ // Next upgrade on the same device: both files agree, no log line.
98
+ const silent = resolveInstallPort({
99
+ envContent: null,
100
+ maxyServiceContent: rewritten,
101
+ edgeServiceContent: edgeAtTruth,
102
+ });
103
+ assert.equal(silent.port, TRUE_PUBLIC);
104
+ assert.equal(silent.source, "running service");
105
+ assert.equal(silent.driftLogs.length, 0, "post-recovery upgrade must be silent");
106
+ });
107
+ // ---------------------------------------------------------------------------
108
+ // --port flag short-circuits every source, including drift recovery (the
109
+ // operator is asserting a port explicitly).
110
+ // ---------------------------------------------------------------------------
111
+ test("--port flag short-circuits drift recovery", () => {
112
+ const driftedMaxy = makeUnitFile(20001, 20002);
113
+ const edge = makeEdgeFile(19200);
114
+ const result = resolveInstallPort({
115
+ portFlag: 31000,
116
+ envContent: null,
117
+ maxyServiceContent: driftedMaxy,
118
+ edgeServiceContent: edge,
119
+ });
120
+ assert.equal(result.port, 31000);
121
+ assert.equal(result.source, "--port flag");
122
+ assert.equal(result.driftLogs.length, 0);
123
+ });
124
+ // ---------------------------------------------------------------------------
125
+ // Legacy pre-647 device (no edge unit): reader uses maxy.service's PORT value,
126
+ // no drift log emitted.
127
+ // ---------------------------------------------------------------------------
128
+ test("pre-647 device without edge unit reads maxy PORT, no drift log", () => {
129
+ const legacyMaxy = `[Service]\nEnvironment=PORT=19200\n`;
130
+ const result = resolveInstallPort({
131
+ envContent: null,
132
+ maxyServiceContent: legacyMaxy,
133
+ edgeServiceContent: null,
134
+ });
135
+ assert.equal(result.port, 19200);
136
+ assert.equal(result.source, "running service");
137
+ assert.equal(result.driftLogs.length, 0);
138
+ });
139
+ // ---------------------------------------------------------------------------
140
+ // .env override takes priority over the service file; drift recovery still
141
+ // runs against whatever was computed.
142
+ // ---------------------------------------------------------------------------
143
+ test(".env override wins over service file; drift recovery still runs", () => {
144
+ const env = "PORT=19500\nEMBED_MODEL=nomic\n";
145
+ const maxy = makeUnitFile(19200, 19201);
146
+ const edge = makeEdgeFile(19500);
147
+ // .env says 19500, maxy.service says 19200, edge says 19500. Reader
148
+ // picks .env (19500), edge confirms (19500) — no drift.
149
+ const resolved = resolveInstallPort({
150
+ envContent: env,
151
+ maxyServiceContent: maxy,
152
+ edgeServiceContent: edge,
153
+ });
154
+ assert.equal(resolved.port, 19500);
155
+ assert.equal(resolved.source, ".env override");
156
+ assert.equal(resolved.driftLogs.length, 0);
157
+ // Now the .env says 19202 (drifted), edge says 19500 — drift recovery wins.
158
+ const driftedEnv = "PORT=19202\nEMBED_MODEL=nomic\n";
159
+ const recovered = resolveInstallPort({
160
+ envContent: driftedEnv,
161
+ maxyServiceContent: maxy,
162
+ edgeServiceContent: edge,
163
+ });
164
+ assert.equal(recovered.port, 19500);
165
+ assert.equal(recovered.source, "edge-recovery");
166
+ assert.equal(recovered.driftLogs.length, 1);
167
+ });
168
+ // ---------------------------------------------------------------------------
169
+ // fs wrapper end-to-end: real tmpdir fixture, verifies the file-reading path.
170
+ // ---------------------------------------------------------------------------
171
+ test("fs wrapper reads real fixture files end-to-end", () => {
172
+ const tmp = mkdtempSync(join(tmpdir(), "maxy-port-test-"));
173
+ try {
174
+ const persistDir = join(tmp, ".maxy");
175
+ const serviceDir = join(tmp, ".config", "systemd", "user");
176
+ mkdirSync(persistDir, { recursive: true });
177
+ mkdirSync(serviceDir, { recursive: true });
178
+ writeFileSync(join(serviceDir, "maxy.service"), makeUnitFile(19300, 19301));
179
+ writeFileSync(join(serviceDir, "maxy-edge.service"), makeEdgeFile(19300));
180
+ const result = resolveInstallPortFromFs({
181
+ persistDir,
182
+ serviceDir,
183
+ brandServiceFileName: "maxy.service",
184
+ brandEdgeServiceFileName: "maxy-edge.service",
185
+ });
186
+ assert.equal(result.port, 19300);
187
+ assert.equal(result.source, "running service");
188
+ assert.equal(result.driftLogs.length, 0);
189
+ }
190
+ finally {
191
+ rmSync(tmp, { recursive: true, force: true });
192
+ }
193
+ });
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { randomBytes } from "node:crypto";
6
+ import { resolveInstallPortFromFs, buildMaxyUnitFile } from "./port-resolution.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.
@@ -1677,41 +1678,26 @@ function installService() {
1677
1678
  // the VNC stack; maxy-ui sits behind it on 127.0.0.1:MAXY_UI_INTERNAL_PORT.
1678
1679
  // PORT + 1 (derived) avoids a fixed-port collision if the operator chose a
1679
1680
  // non-default --port.
1681
+ //
1682
+ // Task 666 — PORT in maxy.service's Environment block is the PUBLIC port,
1683
+ // not the internal one. Previously we wrote `Environment=PORT=<internal>`,
1684
+ // which collided with the install-time reader further down, which
1685
+ // correctly treats Environment=PORT= as public. The overload caused +1
1686
+ // drift per upgrade: each run read the internal value, treated it as
1687
+ // public, wrote internal = old_internal + 1. maxy-ui now binds
1688
+ // MAXY_UI_INTERNAL_PORT (with a fallback to PORT for mixed-state installs).
1680
1689
  const MAXY_UI_INTERNAL_PORT = PORT + 1;
1681
- const neo4jServiceDep = NEO4J_DEDICATED ? `neo4j-${BRAND.hostname}.service` : "neo4j.service";
1682
1690
  const edgeUnitShort = `${BRAND.hostname}-edge`;
1683
1691
  const edgeUnitName = `${edgeUnitShort}.service`;
1684
- const serviceFile = `[Unit]
1685
- Description=${BRAND.productName} AI Assistant
1686
- After=${neo4jServiceDep} ${edgeUnitName}
1687
- Wants=${edgeUnitName}
1688
-
1689
- [Service]
1690
- Type=notify
1691
- NotifyAccess=all
1692
- WorkingDirectory=${INSTALL_DIR}/server
1693
- ExecStartPre=-/bin/bash ${INSTALL_DIR}/platform/scripts/resume-tunnel.sh
1694
- ExecStart=/usr/bin/node --require ./server-init.cjs server.js
1695
- Restart=on-failure
1696
- RestartSec=5
1697
- WatchdogSec=30
1698
- TimeoutStartSec=60
1699
- TimeoutStopSec=10
1700
- Environment=NODE_ENV=production
1701
- Environment=PORT=${MAXY_UI_INTERNAL_PORT}
1702
- Environment=HOSTNAME=127.0.0.1
1703
- Environment=KEEP_ALIVE_TIMEOUT=61000
1704
- Environment=DISPLAY=:99
1705
- Environment=PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
1706
- Environment=MAXY_PLATFORM_ROOT=${INSTALL_DIR}/platform
1707
- Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
1708
- EnvironmentFile=-${persistDir}/.env
1709
- StandardOutput=append:${persistDir}/logs/server.log
1710
- StandardError=append:${persistDir}/logs/server.log
1711
-
1712
- [Install]
1713
- WantedBy=default.target
1714
- `;
1692
+ const serviceFile = buildMaxyUnitFile({
1693
+ productName: BRAND.productName,
1694
+ brandHostname: BRAND.hostname,
1695
+ neo4jDedicated: NEO4J_DEDICATED,
1696
+ installDir: INSTALL_DIR,
1697
+ persistDir,
1698
+ port: PORT,
1699
+ maxyUiInternalPort: MAXY_UI_INTERNAL_PORT,
1700
+ });
1715
1701
  writeFileSync(join(serviceDir, BRAND.serviceName), serviceFile);
1716
1702
  // Task 647 — the edge service: always-on front door that owns the public
1717
1703
  // port (PORT) and the VNC stack (Xtigervnc + websockify). Its lifecycle is
@@ -1905,9 +1891,12 @@ if (_args.includes("--uninstall")) {
1905
1891
  // Priority: --port flag > .env override > existing service file > default 19200.
1906
1892
  // systemd applies EnvironmentFile=-~/.{brand}/.env AFTER Environment=PORT,
1907
1893
  // so .env is the final runtime truth and must be checked first on upgrade.
1894
+ //
1895
+ // Task 666 — this block also performs one-shot port-drift recovery against
1896
+ // maxy-edge.service's Environment=EDGE_PORT=. See port-resolution.ts for the
1897
+ // pure logic; unit tests live at __tests__/port-canonicalisation.test.mjs.
1908
1898
  // ---------------------------------------------------------------------------
1909
- let PORT = 19200;
1910
- let PORT_SOURCE = "default";
1899
+ let portFlag;
1911
1900
  const portIdx = _args.indexOf("--port");
1912
1901
  if (portIdx !== -1) {
1913
1902
  const raw = _args[portIdx + 1];
@@ -1917,47 +1906,19 @@ if (portIdx !== -1) {
1917
1906
  console.error(`Error: --port requires a numeric value between 1024 and 65535 (got: ${raw ?? "nothing"}).`);
1918
1907
  process.exit(1);
1919
1908
  }
1920
- PORT = parsed;
1921
- PORT_SOURCE = "--port flag";
1922
- }
1923
- else {
1924
- // Upgrade detection: check .env first (runtime override), then service file (install-time)
1925
- const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
1926
- const envPath = join(persistDir, ".env");
1927
- try {
1928
- if (existsSync(envPath)) {
1929
- const envContent = readFileSync(envPath, "utf-8");
1930
- const envMatch = envContent.match(/^PORT=(\d+)/m);
1931
- if (envMatch) {
1932
- const envPort = parseInt(envMatch[1], 10);
1933
- if (envPort >= 1024 && envPort <= 65535) {
1934
- PORT = envPort;
1935
- PORT_SOURCE = ".env override";
1936
- }
1937
- }
1938
- }
1939
- }
1940
- catch { /* non-critical */ }
1941
- // Fall back to the service file if .env didn't set PORT
1942
- if (PORT_SOURCE === "default") {
1943
- const serviceDir = resolve(process.env.HOME ?? "/root", ".config/systemd/user");
1944
- const servicePath = join(serviceDir, BRAND.serviceName);
1945
- try {
1946
- if (existsSync(servicePath)) {
1947
- const svcContent = readFileSync(servicePath, "utf-8");
1948
- const match = svcContent.match(/^Environment=PORT=(\d+)/m);
1949
- if (match) {
1950
- const existing = parseInt(match[1], 10);
1951
- if (existing >= 1024 && existing <= 65535) {
1952
- PORT = existing;
1953
- PORT_SOURCE = "running service";
1954
- }
1955
- }
1956
- }
1957
- }
1958
- catch { /* non-critical — fall through to default */ }
1959
- }
1909
+ portFlag = parsed;
1960
1910
  }
1911
+ const _portResolution = resolveInstallPortFromFs({
1912
+ portFlag,
1913
+ persistDir: resolve(process.env.HOME ?? "/root", BRAND.configDir),
1914
+ serviceDir: resolve(process.env.HOME ?? "/root", ".config/systemd/user"),
1915
+ brandServiceFileName: BRAND.serviceName,
1916
+ brandEdgeServiceFileName: `${BRAND.hostname}-edge.service`,
1917
+ });
1918
+ let PORT = _portResolution.port;
1919
+ let PORT_SOURCE = _portResolution.source;
1920
+ for (const line of _portResolution.driftLogs)
1921
+ console.log(line);
1961
1922
  // ---------------------------------------------------------------------------
1962
1923
  // Hostname — install-time flag, not solely a brand attribute.
1963
1924
  //
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const PORT_RE = /^PORT=(\d+)/m;
4
+ const ENV_PORT_RE = /^Environment=PORT=(\d+)/m;
5
+ const ENV_EDGE_PORT_RE = /^Environment=EDGE_PORT=(\d+)/m;
6
+ function parseValid(raw) {
7
+ if (!raw)
8
+ return null;
9
+ const n = parseInt(raw, 10);
10
+ if (Number.isNaN(n) || n < 1024 || n > 65535)
11
+ return null;
12
+ return n;
13
+ }
14
+ export function resolveInstallPort(opts) {
15
+ const defaultPort = opts.defaultPort ?? 19200;
16
+ // Flag beats everything. Caller is responsible for validation prior.
17
+ if (typeof opts.portFlag === "number") {
18
+ return { port: opts.portFlag, source: "--port flag", driftLogs: [] };
19
+ }
20
+ let port = defaultPort;
21
+ let source = "default";
22
+ const envPort = opts.envContent !== null ? parseValid(opts.envContent.match(PORT_RE)?.[1]) : null;
23
+ if (envPort !== null) {
24
+ port = envPort;
25
+ source = ".env override";
26
+ }
27
+ if (source === "default") {
28
+ const svcPort = opts.maxyServiceContent !== null
29
+ ? parseValid(opts.maxyServiceContent.match(ENV_PORT_RE)?.[1])
30
+ : null;
31
+ if (svcPort !== null) {
32
+ port = svcPort;
33
+ source = "running service";
34
+ }
35
+ }
36
+ // Task 666 — one-shot drift recovery. Pre-task `maxy.service` unit files
37
+ // carried the INTERNAL port in the `Environment=PORT=` slot, so the
38
+ // reader above just inherited drift. `maxy-edge.service`'s EDGE_PORT is
39
+ // authoritative for the tunnel (always has been since Task 647), so we
40
+ // trust it over the maxy.service value when they disagree — exactly
41
+ // once per device, logged loudly.
42
+ const driftLogs = [];
43
+ const edgePort = opts.edgeServiceContent !== null
44
+ ? parseValid(opts.edgeServiceContent.match(ENV_EDGE_PORT_RE)?.[1])
45
+ : null;
46
+ if (edgePort !== null && edgePort !== port) {
47
+ driftLogs.push(` [port-recovery] detected drift maxy=${port} edge=${edgePort} — pinning at ${edgePort}`);
48
+ port = edgePort;
49
+ source = "edge-recovery";
50
+ }
51
+ return { port, source, driftLogs };
52
+ }
53
+ export function resolveInstallPortFromFs(opts) {
54
+ const readIfExists = (path) => {
55
+ try {
56
+ if (!existsSync(path))
57
+ return null;
58
+ return readFileSync(path, "utf-8");
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ };
64
+ return resolveInstallPort({
65
+ portFlag: opts.portFlag,
66
+ envContent: readIfExists(join(opts.persistDir, ".env")),
67
+ maxyServiceContent: readIfExists(join(opts.serviceDir, opts.brandServiceFileName)),
68
+ edgeServiceContent: readIfExists(join(opts.serviceDir, opts.brandEdgeServiceFileName)),
69
+ defaultPort: opts.defaultPort,
70
+ });
71
+ }
72
+ export function buildMaxyUnitFile(o) {
73
+ const neo4jServiceDep = o.neo4jDedicated
74
+ ? `neo4j-${o.brandHostname}.service`
75
+ : "neo4j.service";
76
+ const edgeUnitName = `${o.brandHostname}-edge.service`;
77
+ return `[Unit]
78
+ Description=${o.productName} AI Assistant
79
+ After=${neo4jServiceDep} ${edgeUnitName}
80
+ Wants=${edgeUnitName}
81
+
82
+ [Service]
83
+ Type=notify
84
+ NotifyAccess=all
85
+ WorkingDirectory=${o.installDir}/server
86
+ ExecStartPre=-/bin/bash ${o.installDir}/platform/scripts/resume-tunnel.sh
87
+ ExecStart=/usr/bin/node --require ./server-init.cjs server.js
88
+ Restart=on-failure
89
+ RestartSec=5
90
+ WatchdogSec=30
91
+ TimeoutStartSec=60
92
+ TimeoutStopSec=10
93
+ Environment=NODE_ENV=production
94
+ Environment=PORT=${o.port}
95
+ Environment=MAXY_UI_INTERNAL_PORT=${o.maxyUiInternalPort}
96
+ Environment=HOSTNAME=127.0.0.1
97
+ Environment=KEEP_ALIVE_TIMEOUT=61000
98
+ Environment=DISPLAY=:99
99
+ Environment=PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
100
+ Environment=MAXY_PLATFORM_ROOT=${o.installDir}/platform
101
+ Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
102
+ EnvironmentFile=-${o.persistDir}/.env
103
+ StandardOutput=append:${o.persistDir}/logs/server.log
104
+ StandardError=append:${o.persistDir}/logs/server.log
105
+
106
+ [Install]
107
+ WantedBy=default.target
108
+ `;
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.686",
3
+ "version": "1.0.688",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -9,7 +9,8 @@
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "bundle": "node scripts/bundle.js",
12
- "prepublishOnly": "node ../../platform/ui/scripts/check-route-wiring.mjs && npm run build && chmod +x dist/index.js && npm run bundle && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
12
+ "test": "npm run build && node --test dist/__tests__/port-canonicalisation.test.js",
13
+ "prepublishOnly": "node ../../platform/ui/scripts/check-route-wiring.mjs && node ../../platform/ui/scripts/check-edge-admin-routes.mjs && npm run build && chmod +x dist/index.js && npm run bundle && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
13
14
  },
14
15
  "files": [
15
16
  "dist",
@@ -70,8 +70,10 @@ The logs will show which service failed to start and why. Common causes:
70
70
 
71
71
  Each installed brand runs two per-brand `--user` systemd units (Task 662 + Task 664 — unit filenames are prefixed with the brand's `hostname` so two brands on the same device never share a unit file):
72
72
 
73
- - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19199`. Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade.
74
- - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
73
+ - `{hostname}.service` — the admin + public HTTP server on `127.0.0.1:19201` (public port + 1). Restarted by the upgrade flow; short downtime is expected during steps 8→11 of an upgrade. Task 666: the unit carries two port env vars — `PORT=<public>` (canonical public port, read by the upgrade detector) and `MAXY_UI_INTERNAL_PORT=<public+1>` (the port maxy-ui actually binds).
74
+ - `{hostname}-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to the main brand service and handles `/websockify` (VNC) WebSocket upgrades locally. Task 666: also hosts `/api/admin/actions/*` and `/api/admin/version*` — the Software Update modal's own routes — so the log stream survives the brand service's restart window. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
75
+
76
+ **Port-drift recovery (Task 666).** Devices upgraded between Tasks 647 and 666 may have drifted +1 on every upgrade because the pre-Task-666 installer wrote `Environment=PORT=<internal>` into `{hostname}.service` and the upgrade reader correctly treated `PORT=` as public. The first post-Task-666 install detects this (comparing maxy's PORT against the edge's EDGE_PORT) and emits a one-shot loud log: `[port-recovery] detected drift maxy=<X> edge=<Y> — pinning at <Y>`. Subsequent upgrades are silent. If your Cloudflare tunnel was pointing at a drifted port, the ingress `config.yml` still needs a one-time manual fix: `sed -i 's|localhost:<old>|localhost:<current>|' ~/.{configDir}/cloudflared/config.yml && cloudflared tunnel ingress validate`. Maxy never rewrites cloudflared config programmatically.
75
77
 
76
78
  Upgrade and Cloudflare setup (Task 664) run as detached actions: `systemd-run --user` transient units per invocation with stdout+stderr persisted to `~/.maxy/logs/actions/<actionId>.log` and streamed to the UI via SSE. No boot-time service file exists for these.
77
79
 
@@ -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. 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. Login cookies are HMAC-signed with a shared key on disk, so both processes recognise the same session without any coordination and you do not have to log in again after an update. 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. The edge also hosts the update flow's own routes (the sudo prompt, the action launcher, the SSE progress stream, the installed-version poll), so the Software Update modal's log panel does not go blank during the app-server restart window — it keeps receiving lines, heartbeats, and the final exit event unbroken. Login cookies are HMAC-signed with a shared key on disk, so both processes recognise the same session without any coordination and you do not have to log in again after an update. 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.
@@ -95,7 +95,9 @@ If the initial Cloudflare login fails during setup, Maxy will fall back to askin
95
95
 
96
96
  ## Action runner — upgrade or Cloudflare setup appears stuck
97
97
 
98
- Task 664 replaced the ttyd/xterm admin terminal with a detached action runner. Upgrades and Cloudflare setup now run under transient `systemd-run --user` units whose stdout+stderr land in a persisted per-action log, streamed to the browser via SSE.
98
+ Task 664 replaced the ttyd/xterm admin terminal with a detached action runner. Upgrades and Cloudflare setup now run under transient `systemd-run --user` units whose stdout+stderr land in a persisted per-action log, streamed to the browser via SSE. Task 666 moved the four routes that serve the modal (`/api/admin/actions/*`, `/api/admin/version`) onto `maxy-edge.service`, so the log panel's stream survives a mid-run restart of `maxy.service` without reconnecting.
99
+
100
+ **"Connection lost — reconnecting…" banner appears during an upgrade.** On post-Task-666 bundles this should never appear while the upgrade is in flight — the routes live on the always-on edge. If you see the banner during steps 8→11 of an upgrade on a current bundle, it is a **regression**, not expected behaviour: the routes have drifted back onto `maxy.service` or the edge's Hono dispatcher is not intercepting them. Check `~/.maxy/logs/edge.log` for `[edge-admin]` entries during the window; absence means the edge never received the request. The prebuild gate `platform/ui/scripts/check-edge-admin-routes.mjs` exists specifically to catch this drift before it ships.
99
101
 
100
102
  **Heartbeat stalled** (log panel header shows rising `silent Ns` amber badge).
101
103