@rubytech/create-maxy-code 0.1.193 → 0.1.195
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/samba-provision.test.js +40 -16
- package/dist/index.js +37 -18
- package/dist/samba-provision.js +11 -8
- package/dist/uninstall.js +19 -3
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/access-control.md +4 -0
- package/payload/platform/plugins/docs/references/deployment.md +1 -17
- package/payload/platform/plugins/docs/references/samba.md +80 -0
- package/payload/platform/plugins/docs/references/troubleshooting.md +14 -0
- package/payload/server/{chunk-WWD4TCJJ.js → chunk-EXIPYITB.js} +2 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +134 -86
|
@@ -62,7 +62,7 @@ test("pickLanInterface: returns null when the only non-loopback iface has no IPv
|
|
|
62
62
|
// renderBrandStanza — exact spec match
|
|
63
63
|
// ---------------------------------------------------------------------------
|
|
64
64
|
test("renderBrandStanza emits the spec directives in order", () => {
|
|
65
|
-
const stanza = renderBrandStanza({ brand: "maxy-code", sharePath: "/home/admin/maxy-code" });
|
|
65
|
+
const stanza = renderBrandStanza({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", installOwner: "admin" });
|
|
66
66
|
assert.equal(stanza, [
|
|
67
67
|
"[maxy-code]",
|
|
68
68
|
" path = /home/admin/maxy-code",
|
|
@@ -76,11 +76,34 @@ test("renderBrandStanza emits the spec directives in order", () => {
|
|
|
76
76
|
].join("\n"));
|
|
77
77
|
});
|
|
78
78
|
test("renderBrandStanza handles realagent-code with its own share path", () => {
|
|
79
|
-
const stanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
|
|
79
|
+
const stanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" });
|
|
80
80
|
assert.match(stanza, /^\[realagent-code\]\n/);
|
|
81
81
|
assert.match(stanza, /\n path = \/home\/admin\/realagent-code\n/);
|
|
82
82
|
assert.match(stanza, /\n force user = admin\n/);
|
|
83
83
|
});
|
|
84
|
+
test("renderBrandStanza substitutes a non-admin install owner into valid users and force user", () => {
|
|
85
|
+
// Laptop case (Task 534): the install owner is the operator's Unix user
|
|
86
|
+
// (`neo` etc.), not the literal `admin`. The same renderer must produce a
|
|
87
|
+
// stanza whose `valid users` and `force user` name that owner, so SMB
|
|
88
|
+
// clients authenticate as a Unix user that actually exists on the device.
|
|
89
|
+
const stanza = renderBrandStanza({ brand: "maxy-code", sharePath: "/home/neo/maxy-code", installOwner: "neo" });
|
|
90
|
+
assert.match(stanza, /\n valid users = neo\n/);
|
|
91
|
+
assert.match(stanza, /\n force user = neo\n/);
|
|
92
|
+
assert.doesNotMatch(stanza, /valid users = admin/);
|
|
93
|
+
assert.doesNotMatch(stanza, /force user = admin/);
|
|
94
|
+
});
|
|
95
|
+
test("mergeSmbConf preserves a peer brand stanza when the new install owner differs", () => {
|
|
96
|
+
// Peer-brand isolation must hold across owners: a laptop install
|
|
97
|
+
// (installOwner=neo) adding its `[maxy-code]` stanza to a conf that
|
|
98
|
+
// already holds a peer `[realagent-code]` stanza provisioned for owner
|
|
99
|
+
// `admin` must leave the peer stanza byte-identical. The new stanza
|
|
100
|
+
// carries the laptop owner; the peer keeps its own. Task 534.
|
|
101
|
+
const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" });
|
|
102
|
+
const existing = renderGlobalSection({ lanInterface: "eth0" }) + peerStanza;
|
|
103
|
+
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/neo/maxy-code", lanInterface: "wlan0", installOwner: "neo" });
|
|
104
|
+
assert.ok(merged.includes(peerStanza), "peer brand stanza must survive unchanged");
|
|
105
|
+
assert.match(merged, /\[maxy-code\][\s\S]+valid users = neo[\s\S]+force user = neo/);
|
|
106
|
+
});
|
|
84
107
|
// ---------------------------------------------------------------------------
|
|
85
108
|
// renderGlobalSection — LAN-only enforcement
|
|
86
109
|
// ---------------------------------------------------------------------------
|
|
@@ -96,7 +119,7 @@ test("renderGlobalSection binds to lo + LAN interface only", () => {
|
|
|
96
119
|
// renderFullSmbConf — composition order
|
|
97
120
|
// ---------------------------------------------------------------------------
|
|
98
121
|
test("renderFullSmbConf places globals before the brand stanza", () => {
|
|
99
|
-
const conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
122
|
+
const conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
100
123
|
const globalIdx = conf.indexOf("[global]");
|
|
101
124
|
const brandIdx = conf.indexOf("[maxy-code]");
|
|
102
125
|
assert.ok(globalIdx >= 0 && brandIdx > globalIdx, "[global] must come before [maxy-code]");
|
|
@@ -105,13 +128,13 @@ test("renderFullSmbConf places globals before the brand stanza", () => {
|
|
|
105
128
|
// mergeSmbConf — idempotency and section preservation
|
|
106
129
|
// ---------------------------------------------------------------------------
|
|
107
130
|
test("mergeSmbConf: empty existing conf gets globals + brand stanza", () => {
|
|
108
|
-
const merged = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
131
|
+
const merged = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
109
132
|
assert.match(merged, /\[global\][\s\S]+\[maxy-code\]/);
|
|
110
133
|
assert.match(merged, /interfaces = lo wlan0/);
|
|
111
134
|
});
|
|
112
135
|
test("mergeSmbConf: idempotent on second application", () => {
|
|
113
|
-
const once = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
114
|
-
const twice = mergeSmbConf({ existing: once, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
136
|
+
const once = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
137
|
+
const twice = mergeSmbConf({ existing: once, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
115
138
|
assert.equal(twice, once, "second merge must produce byte-identical output");
|
|
116
139
|
});
|
|
117
140
|
test("mergeSmbConf: replaces an existing global section in place, preserving peer stanzas", () => {
|
|
@@ -125,26 +148,26 @@ test("mergeSmbConf: replaces an existing global section in place, preserving pee
|
|
|
125
148
|
" guest ok = yes",
|
|
126
149
|
"",
|
|
127
150
|
].join("\n");
|
|
128
|
-
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
151
|
+
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
129
152
|
assert.match(merged, /interfaces = lo wlan0/);
|
|
130
153
|
assert.doesNotMatch(merged, /workgroup = OLDGROUP/);
|
|
131
154
|
assert.match(merged, /\[printers\][\s\S]+path = \/var\/spool\/samba/, "[printers] stanza must survive");
|
|
132
155
|
assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/);
|
|
133
156
|
});
|
|
134
157
|
test("mergeSmbConf: replaces an existing brand stanza in place, leaves peer brand alone", () => {
|
|
135
|
-
const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
|
|
158
|
+
const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" });
|
|
136
159
|
const oldBrandStanza = "[maxy-code]\n path = /OLD/PATH\n read only = yes\n\n";
|
|
137
160
|
const existing = renderGlobalSection({ lanInterface: "eth0" }) + oldBrandStanza + peerStanza;
|
|
138
|
-
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
161
|
+
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
139
162
|
assert.doesNotMatch(merged, /\/OLD\/PATH/, "old maxy-code path must be replaced");
|
|
140
163
|
assert.doesNotMatch(merged, /read only = yes/, "old maxy-code directives must be replaced");
|
|
141
164
|
assert.match(merged, /\[realagent-code\][\s\S]+path = \/home\/admin\/realagent-code/, "peer brand stanza must survive");
|
|
142
165
|
assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/);
|
|
143
166
|
});
|
|
144
167
|
test("mergeSmbConf: appends brand stanza when not present, keeps existing peer stanzas", () => {
|
|
145
|
-
const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
|
|
168
|
+
const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" });
|
|
146
169
|
const existing = renderGlobalSection({ lanInterface: "eth0" }) + peerStanza;
|
|
147
|
-
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
170
|
+
const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
148
171
|
assert.match(merged, /\[realagent-code\]/, "peer brand stanza must survive");
|
|
149
172
|
assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/, "new brand stanza must be appended");
|
|
150
173
|
});
|
|
@@ -153,10 +176,11 @@ test("mergeSmbConf: appends brand stanza when not present, keeps existing peer s
|
|
|
153
176
|
// ---------------------------------------------------------------------------
|
|
154
177
|
test("removeBrandStanza: removes only this brand, leaves peer brand and global intact", () => {
|
|
155
178
|
const conf = mergeSmbConf({
|
|
156
|
-
existing: renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" }),
|
|
179
|
+
existing: renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" }),
|
|
157
180
|
brand: "maxy-code",
|
|
158
181
|
sharePath: "/home/admin/maxy-code",
|
|
159
182
|
lanInterface: "wlan0",
|
|
183
|
+
installOwner: "admin",
|
|
160
184
|
});
|
|
161
185
|
const after = removeBrandStanza({ existing: conf, brand: "maxy-code" });
|
|
162
186
|
assert.doesNotMatch(after, /\[maxy-code\]/);
|
|
@@ -164,7 +188,7 @@ test("removeBrandStanza: removes only this brand, leaves peer brand and global i
|
|
|
164
188
|
assert.match(after, /\[global\]/);
|
|
165
189
|
});
|
|
166
190
|
test("removeBrandStanza: returns input unchanged when stanza is absent", () => {
|
|
167
|
-
const conf = mergeSmbConf({ existing: "", brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0" });
|
|
191
|
+
const conf = mergeSmbConf({ existing: "", brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
168
192
|
const after = removeBrandStanza({ existing: conf, brand: "maxy-code" });
|
|
169
193
|
assert.equal(after, conf);
|
|
170
194
|
});
|
|
@@ -177,12 +201,12 @@ test("hasAnyBrandStanza: false when only [global] is present", () => {
|
|
|
177
201
|
});
|
|
178
202
|
test("hasAnyBrandStanza: true when any non-global stanza is present", () => {
|
|
179
203
|
const conf = renderGlobalSection({ lanInterface: "wlan0" }) +
|
|
180
|
-
renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
|
|
204
|
+
renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code", installOwner: "admin" });
|
|
181
205
|
assert.equal(hasAnyBrandStanza(conf), true);
|
|
182
206
|
});
|
|
183
207
|
test("hasAnyBrandStanza: false when both brand stanzas have been removed", () => {
|
|
184
|
-
let conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
|
|
185
|
-
conf = mergeSmbConf({ existing: conf, brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0" });
|
|
208
|
+
let conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
209
|
+
conf = mergeSmbConf({ existing: conf, brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0", installOwner: "admin" });
|
|
186
210
|
conf = removeBrandStanza({ existing: conf, brand: "maxy-code" });
|
|
187
211
|
conf = removeBrandStanza({ existing: conf, brand: "realagent-code" });
|
|
188
212
|
assert.equal(hasAnyBrandStanza(conf), false);
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
|
|
|
18
18
|
import { parsePluginList, computeInstallActions, computeConfigureActions, parseExternalPlugins, } from "./lib/plugin-install.js";
|
|
19
19
|
import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
|
|
20
20
|
import { pickLanInterface, mergeSmbConf, formatSambaMarker, } from "./samba-provision.js";
|
|
21
|
-
import { networkInterfaces } from "node:os";
|
|
21
|
+
import { networkInterfaces, userInfo } from "node:os";
|
|
22
22
|
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
|
|
23
23
|
// Brand manifest — read from payload to derive all brand-specific installation values.
|
|
24
24
|
// The bundler stamps brand.json into the payload at build time.
|
|
@@ -3371,12 +3371,16 @@ WantedBy=multi-user.target
|
|
|
3371
3371
|
// marker:
|
|
3372
3372
|
// apt — install the `samba` package
|
|
3373
3373
|
// conf — write the brand stanza + LAN-only globals to /etc/samba/smb.conf
|
|
3374
|
-
// user — smbpasswd the
|
|
3375
|
-
// Pi installs (no plaintext PIN until the operator runs set-pin)
|
|
3374
|
+
// user — smbpasswd the install-owner Linux user; deferred at install time
|
|
3375
|
+
// on fresh Pi installs (no plaintext PIN until the operator runs set-pin)
|
|
3376
3376
|
// units — systemctl enable --now smbd nmbd
|
|
3377
3377
|
//
|
|
3378
3378
|
// The platform's set-pin route handler closes the deferral loop by running
|
|
3379
|
-
// `smbpasswd -a
|
|
3379
|
+
// `smbpasswd -a -s <install-owner>` inline whenever the PIN is set or
|
|
3380
|
+
// rotated. Install owner is the Unix user the installer is running as —
|
|
3381
|
+
// `admin` on a Pi/Hetzner box, `neo` (etc.) on a self-hosted laptop. It is
|
|
3382
|
+
// persisted to `~/.<brand>/.install-owner` so every later read uses the same
|
|
3383
|
+
// value the installer wrote.
|
|
3380
3384
|
// ---------------------------------------------------------------------------
|
|
3381
3385
|
function emitSambaMarker(step, state) {
|
|
3382
3386
|
const line = formatSambaMarker(step, state);
|
|
@@ -3390,6 +3394,16 @@ function provisionSamba() {
|
|
|
3390
3394
|
}
|
|
3391
3395
|
const brand = BRAND.hostname;
|
|
3392
3396
|
const sharePath = INSTALL_DIR;
|
|
3397
|
+
// Install owner — the Unix user that owns this brand's install on the
|
|
3398
|
+
// device. On a Pi/Hetzner box this is `admin`; on a self-hosted laptop it
|
|
3399
|
+
// is the operator's Unix user (`neo` etc.). Persisted to a file next to
|
|
3400
|
+
// the other install identity (`.neo4j-password`, `.admin-pin`) so the
|
|
3401
|
+
// platform's set-pin route and the uninstall path read the same value the
|
|
3402
|
+
// installer wrote, rather than re-detecting at request time. Task 534.
|
|
3403
|
+
const installOwner = userInfo().username;
|
|
3404
|
+
const installOwnerPersistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
|
|
3405
|
+
mkdirSync(installOwnerPersistDir, { recursive: true });
|
|
3406
|
+
writeFileSync(join(installOwnerPersistDir, ".install-owner"), `${installOwner}\n`, { mode: 0o644 });
|
|
3393
3407
|
// Step 1 — apt install samba. Precheck `dpkg -s samba` first; if the
|
|
3394
3408
|
// package is already installed, skip apt entirely so the install does not
|
|
3395
3409
|
// contend with `unattended-upgrades` for `/var/lib/dpkg/lock-frontend`
|
|
@@ -3426,7 +3440,7 @@ function provisionSamba() {
|
|
|
3426
3440
|
}
|
|
3427
3441
|
catch { /* treat as empty */ }
|
|
3428
3442
|
}
|
|
3429
|
-
const merged = mergeSmbConf({ existing, brand, sharePath, lanInterface });
|
|
3443
|
+
const merged = mergeSmbConf({ existing, brand, sharePath, lanInterface, installOwner });
|
|
3430
3444
|
try {
|
|
3431
3445
|
const tee = spawnSync("sudo", ["tee", SMB_CONF], { input: merged, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
|
|
3432
3446
|
if (tee.status !== 0) {
|
|
@@ -3436,15 +3450,18 @@ function provisionSamba() {
|
|
|
3436
3450
|
if (testparm.status !== 0) {
|
|
3437
3451
|
throw new Error(`testparm rejected merged smb.conf: ${(testparm.stderr ?? "").trim()}`);
|
|
3438
3452
|
}
|
|
3439
|
-
// Grant passwordless sudo to the
|
|
3440
|
-
// invocations the platform's set-pin route makes (add-user-
|
|
3441
|
-
// and remove-user). Without this, the user systemd-unit-
|
|
3442
|
-
// server cannot sync the SMB password when the operator
|
|
3443
|
-
// the PIN. Scoped tight: only these two literal arg-vectors
|
|
3444
|
-
// permitted; every other smbpasswd flavour still prompts.
|
|
3453
|
+
// Grant passwordless sudo to the install-owner Unix user for the two
|
|
3454
|
+
// smbpasswd invocations the platform's set-pin route makes (add-user-
|
|
3455
|
+
// with-stdin and remove-user). Without this, the user systemd-unit-
|
|
3456
|
+
// spawned admin server cannot sync the SMB password when the operator
|
|
3457
|
+
// sets/rotates the PIN. Scoped tight: only these two literal arg-vectors
|
|
3458
|
+
// are permitted; every other smbpasswd flavour still prompts. Principal
|
|
3459
|
+
// and `-a -s <user>` / `-x <user>` arguments are the install owner, not
|
|
3460
|
+
// a literal `admin`, so the grant works on a Pi (owner=admin) and on a
|
|
3461
|
+
// laptop (owner=neo) without divergence. Task 534.
|
|
3445
3462
|
const SUDOERS = "/etc/sudoers.d/maxy-samba";
|
|
3446
3463
|
const sudoersBody = "# maxy-code Samba provisioning (smbpasswd sync from platform set-pin route).\n" +
|
|
3447
|
-
|
|
3464
|
+
`${installOwner} ALL=(root) NOPASSWD: /usr/bin/smbpasswd -a -s ${installOwner}, /usr/bin/smbpasswd -x ${installOwner}\n`;
|
|
3448
3465
|
const sudoersTee = spawnSync("sudo", ["tee", SUDOERS], { input: sudoersBody, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5_000 });
|
|
3449
3466
|
if (sudoersTee.status !== 0) {
|
|
3450
3467
|
throw new Error(`sudo tee ${SUDOERS} exited ${sudoersTee.status}: ${(sudoersTee.stderr ?? "").trim()}`);
|
|
@@ -3454,18 +3471,20 @@ function provisionSamba() {
|
|
|
3454
3471
|
if (visudoCheck.status !== 0) {
|
|
3455
3472
|
throw new Error(`visudo rejected ${SUDOERS}: ${(visudoCheck.stderr ?? "").trim()}`);
|
|
3456
3473
|
}
|
|
3457
|
-
emitSambaMarker("conf", `ok lan=${lanInterface}`);
|
|
3474
|
+
emitSambaMarker("conf", `ok lan=${lanInterface} owner=${installOwner}`);
|
|
3458
3475
|
}
|
|
3459
3476
|
catch (err) {
|
|
3460
3477
|
const stderr = err instanceof Error ? err.message : String(err);
|
|
3461
3478
|
emitSambaMarker("conf", `fail: ${stderr}`);
|
|
3462
3479
|
throw err;
|
|
3463
3480
|
}
|
|
3464
|
-
// Step 3 — smbpasswd for the
|
|
3465
|
-
// is not in any file on disk (users.json stores the SHA-256
|
|
3466
|
-
// platform's set-pin route is responsible for syncing on
|
|
3467
|
-
// Mark as deferred so the four-marker contract still fires
|
|
3468
|
-
|
|
3481
|
+
// Step 3 — smbpasswd for the install-owner Unix user. At install time the
|
|
3482
|
+
// plaintext PIN is not in any file on disk (users.json stores the SHA-256
|
|
3483
|
+
// hash only); the platform's set-pin route is responsible for syncing on
|
|
3484
|
+
// PIN set/rotate. Mark as deferred so the four-marker contract still fires
|
|
3485
|
+
// unbroken. Owner is recorded in the marker state so the install log shows
|
|
3486
|
+
// which Unix user the smbpasswd entry will target. Task 534.
|
|
3487
|
+
emitSambaMarker("user", `deferred reason=no-plaintext-pin-at-install owner=${installOwner} (set-pin route syncs)`);
|
|
3469
3488
|
// Step 4 — enable + start smbd and nmbd.
|
|
3470
3489
|
try {
|
|
3471
3490
|
shell("systemctl", ["enable", "--now", "smbd", "nmbd"], { sudo: true, timeout: 30_000 });
|
package/dist/samba-provision.js
CHANGED
|
@@ -19,17 +19,20 @@
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
/**
|
|
21
21
|
* Render the `[<brand>]` share stanza. Exact directives are the task's spec
|
|
22
|
-
* verbatim: share rooted at `sharePath`, writable by `
|
|
23
|
-
* to
|
|
24
|
-
* created over SSH, and standard create/dir masks for an ext4
|
|
22
|
+
* verbatim: share rooted at `sharePath`, writable by `installOwner` only,
|
|
23
|
+
* force-uid to `installOwner` so files created via SMB are owned by the same
|
|
24
|
+
* uid as files created over SSH, and standard create/dir masks for an ext4
|
|
25
|
+
* home directory. `installOwner` is the Unix user that owns this brand's
|
|
26
|
+
* install on the device — `admin` on a Pi or Hetzner box, the operator's
|
|
27
|
+
* Unix user on a self-hosted laptop. Task 534.
|
|
25
28
|
*/
|
|
26
29
|
export function renderBrandStanza(input) {
|
|
27
30
|
return [
|
|
28
31
|
`[${input.brand}]`,
|
|
29
32
|
` path = ${input.sharePath}`,
|
|
30
33
|
` read only = no`,
|
|
31
|
-
` valid users =
|
|
32
|
-
` force user =
|
|
34
|
+
` valid users = ${input.installOwner}`,
|
|
35
|
+
` force user = ${input.installOwner}`,
|
|
33
36
|
` browseable = yes`,
|
|
34
37
|
` create mask = 0664`,
|
|
35
38
|
` directory mask = 0775`,
|
|
@@ -69,7 +72,7 @@ export function renderGlobalSection(input) {
|
|
|
69
72
|
*/
|
|
70
73
|
export function renderFullSmbConf(input) {
|
|
71
74
|
return renderGlobalSection({ lanInterface: input.lanInterface }) +
|
|
72
|
-
renderBrandStanza({ brand: input.brand, sharePath: input.sharePath });
|
|
75
|
+
renderBrandStanza({ brand: input.brand, sharePath: input.sharePath, installOwner: input.installOwner });
|
|
73
76
|
}
|
|
74
77
|
// ---------------------------------------------------------------------------
|
|
75
78
|
// LAN interface detection
|
|
@@ -148,9 +151,9 @@ function findSectionRange(conf, section) {
|
|
|
148
151
|
* brand stanzas (other `[…]` blocks) are preserved verbatim.
|
|
149
152
|
*/
|
|
150
153
|
export function mergeSmbConf(input) {
|
|
151
|
-
const { existing, brand, sharePath, lanInterface } = input;
|
|
154
|
+
const { existing, brand, sharePath, lanInterface, installOwner } = input;
|
|
152
155
|
const newGlobals = renderGlobalSection({ lanInterface });
|
|
153
|
-
const newBrand = renderBrandStanza({ brand, sharePath });
|
|
156
|
+
const newBrand = renderBrandStanza({ brand, sharePath, installOwner });
|
|
154
157
|
let conf = existing;
|
|
155
158
|
// Replace or insert the global section.
|
|
156
159
|
const globalRange = findSectionRange(conf, "global");
|
package/dist/uninstall.js
CHANGED
|
@@ -759,9 +759,25 @@ function removeSamba() {
|
|
|
759
759
|
catch (err) {
|
|
760
760
|
console.log(` Failed to disable smbd/nmbd: ${err instanceof Error ? err.message : String(err)}`);
|
|
761
761
|
}
|
|
762
|
-
// Drop the
|
|
763
|
-
// if the user isn't in the passdb — both are acceptable
|
|
764
|
-
|
|
762
|
+
// Drop the install-owner's smbpasswd entry. `smbpasswd -x` exits 0 on
|
|
763
|
+
// success, non-zero if the user isn't in the passdb — both are acceptable
|
|
764
|
+
// end-states. Owner is read from the persisted file the installer wrote
|
|
765
|
+
// so the cleanup targets the same Unix user the provisioning step keyed
|
|
766
|
+
// on (Task 534): `admin` on a Pi/Hetzner box, `neo` (etc.) on a laptop.
|
|
767
|
+
// Missing file falls back to `admin` because an install that ran before
|
|
768
|
+
// the install-owner persistence existed only ever provisioned `admin`.
|
|
769
|
+
const installOwnerFile = join(CONFIG_DIR, ".install-owner");
|
|
770
|
+
let installOwner = "admin";
|
|
771
|
+
if (existsSync(installOwnerFile)) {
|
|
772
|
+
try {
|
|
773
|
+
const raw = readFileSync(installOwnerFile, "utf-8").trim();
|
|
774
|
+
if (raw.length > 0)
|
|
775
|
+
installOwner = raw;
|
|
776
|
+
}
|
|
777
|
+
catch { /* keep default */ }
|
|
778
|
+
}
|
|
779
|
+
spawnSync("sudo", ["smbpasswd", "-x", installOwner], { stdio: "pipe", timeout: 10_000 });
|
|
780
|
+
console.log(` smbpasswd -x ${installOwner}`);
|
|
765
781
|
try {
|
|
766
782
|
shell("apt-get", ["remove", "--purge", "-y", "samba", "samba-common-bin"], { sudo: true, timeout: 120_000 });
|
|
767
783
|
console.log(" Purged samba package.");
|
package/package.json
CHANGED
|
@@ -78,3 +78,7 @@ Tell {{productName}} to change which actions require approval:
|
|
|
78
78
|
- "What actions currently require my approval?" — lists the current policy
|
|
79
79
|
|
|
80
80
|
Changes are per-account and take effect immediately.
|
|
81
|
+
|
|
82
|
+
## Filesystem Access (SMB Share)
|
|
83
|
+
|
|
84
|
+
Brand isolation extends to the device filesystem. Every {{productName}} install provisions an SMB share scoped to that brand's install folder, credentialled by the brand's install owner and the {{productName}} PIN. A device that hosts more than one brand carries one share per brand; tearing one brand down never exposes another brand's files. See [Samba Share](./samba.md) for the credential model, per-OS mount syntax, and peer-brand lifecycle.
|
|
@@ -121,23 +121,7 @@ If you need to restart the service manually (rare), ask {{productName}} to do it
|
|
|
121
121
|
|
|
122
122
|
## Browsing the brand filesystem on your LAN (SMB)
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
The share is **LAN-only**: it binds to the loopback and your Pi's Wi-Fi/Ethernet interface only. Cloudflare tunnels carry HTTPS for the web UI, not SMB, so the share is invisible from the public internet. Off-LAN access for travelling operators is handled by a separate route (in progress).
|
|
127
|
-
|
|
128
|
-
**Credentials:** SMB user is `admin`; password is your {{productName}} PIN. The installer wires the sync so every time you set or rotate the PIN in the admin UI, the SMB password updates with it.
|
|
129
|
-
|
|
130
|
-
**macOS Finder**: Press `Cmd-K` from any Finder window, type `smb://<hostname>.local` (use the hostname your installer printed — for example `smb://maxy-code.local` or `smb://realagent-code.local`), click Connect, sign in as `admin` with your PIN. The share appears in the Finder sidebar; drag-and-drop works in both directions.
|
|
131
|
-
|
|
132
|
-
**Windows Explorer**: Open File Explorer, type `\\<hostname>.local\<brand>` in the address bar (for example `\\maxy-code.local\maxy-code`), press Enter, sign in as `admin` with your PIN. To keep it across reboots, right-click → "Map network drive".
|
|
133
|
-
|
|
134
|
-
**iOS Files**: Open Files → tap the `…` menu (top-right) → "Connect to Server" → enter `smb://<hostname>.local` → "Registered User" → username `admin`, password is your PIN.
|
|
135
|
-
|
|
136
|
-
**Android (Solid Explorer, CX File Explorer)**: Add a new connection of type SMB / Network. Host = `<hostname>.local`, share = `<brand>` (same as your install folder name), user = `admin`, password = your PIN.
|
|
137
|
-
|
|
138
|
-
**Troubleshooting:** if the mount fails with "logon failure", change your PIN in the admin UI and try again — that re-triggers the smbpasswd sync. If the share doesn't show up at all, your client may need `<hostname>.local` resolved by mDNS — try the Pi's LAN IP address as a fallback (`smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows).
|
|
139
|
-
|
|
140
|
-
The installer maintains the share automatically. To remove it, uninstalling the brand strips its stanza from `/etc/samba/smb.conf` and (when no peer brand remains on the device) stops `smbd`, drops the smbpasswd entry, and purges the samba package.
|
|
124
|
+
Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only binding posture.
|
|
141
125
|
|
|
142
126
|
## Remote Access via Cloudflare
|
|
143
127
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Samba Share
|
|
2
|
+
|
|
3
|
+
Every {{productName}} install provisions a per-brand SMB network share so you can read and write the brand's install folder from Finder, File Explorer, the Files app on iOS, or any SMB-capable client on Android or Linux. No client install required — every modern OS speaks SMB natively.
|
|
4
|
+
|
|
5
|
+
The share lives next to the rest of the brand. On a device that runs more than one brand, each brand gets its own stanza, its own credentials, and its own lifecycle. Tearing one brand down never touches another brand's share.
|
|
6
|
+
|
|
7
|
+
## What gets provisioned
|
|
8
|
+
|
|
9
|
+
The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
|
|
10
|
+
|
|
11
|
+
1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
|
|
12
|
+
2. **conf** — writes `/etc/samba/smb.conf` with a LAN-only `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`.
|
|
13
|
+
3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
|
|
14
|
+
4. **units** — `systemctl enable --now smbd nmbd` so the share is reachable as soon as the install finishes.
|
|
15
|
+
|
|
16
|
+
macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
|
|
17
|
+
|
|
18
|
+
## Share path
|
|
19
|
+
|
|
20
|
+
| Client | Address |
|
|
21
|
+
|---|---|
|
|
22
|
+
| macOS Finder | `smb://<hostname>.local` then pick the `<brand>` share |
|
|
23
|
+
| Windows Explorer | `\\<hostname>.local\<brand>` |
|
|
24
|
+
| Linux (`mount.cifs`, Nautilus, KDE) | `//<hostname>.local/<brand>` |
|
|
25
|
+
| iOS Files | `smb://<hostname>.local` |
|
|
26
|
+
| Android (Solid Explorer, CX File Explorer) | Host `<hostname>.local`, share `<brand>` |
|
|
27
|
+
|
|
28
|
+
`<hostname>` is whatever the installer printed at the end of `npx @rubytech/create-<brand>-code install` — usually the brand name on a fresh Pi (`maxy-code.local`, `realagent-code.local`). `<brand>` is the same string — it is also the install folder name under the install owner's home.
|
|
29
|
+
|
|
30
|
+
If `<hostname>.local` does not resolve from your client (some networks do not route mDNS), fall back to the LAN IP: `smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows.
|
|
31
|
+
|
|
32
|
+
## Credentials
|
|
33
|
+
|
|
34
|
+
- **Username** — the Unix user that owns the install on the device. On a Pi or Hetzner box this is `admin`; on a self-hosted Linux laptop it is whatever Linux user ran the installer (for example `neo`). The installer persists this value to `~/.<brand>/.install-owner` so every later read uses the same identity the installer wrote.
|
|
35
|
+
- **Password** — your current {{productName}} PIN. The same PIN that unlocks the admin UI unlocks the SMB share.
|
|
36
|
+
|
|
37
|
+
Both halves are required. SMB never accepts a guest connection; `map to guest = bad user` is set in the global stanza.
|
|
38
|
+
|
|
39
|
+
## PIN rotation
|
|
40
|
+
|
|
41
|
+
There is no separate "SMB password." When you set or rotate the PIN in the admin UI, the platform's `set-pin` route runs `sudo -n smbpasswd -a -s <install-owner>` inline with the new PIN, behind a `NOPASSWD` sudoers grant written at install time and scoped to that exact command.
|
|
42
|
+
|
|
43
|
+
So:
|
|
44
|
+
|
|
45
|
+
- On a fresh Pi or Hetzner box, the share is reachable as soon as you set the first PIN. Before that point the `smbpasswd` entry does not exist and the mount fails with a logon error — that is expected.
|
|
46
|
+
- Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
|
|
47
|
+
- If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
|
|
48
|
+
|
|
49
|
+
## LAN-only binding
|
|
50
|
+
|
|
51
|
+
The `[global]` section binds smbd to loopback plus one LAN interface:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
interfaces = lo <lan>
|
|
55
|
+
bind interfaces only = yes
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`<lan>` is whichever non-loopback interface has an IPv4 address — `wlan0` preferred, then `eth0`, then the first other interface with an address. If the device has no LAN interface at all, the installer refuses to provision and exits — there is nothing safe to bind to.
|
|
59
|
+
|
|
60
|
+
This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB. **On a Hetzner box the share is therefore not reachable from the public internet** — operators reach it by `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445`, or by running the Hetzner box on a private network that the operator's machine also joins.
|
|
61
|
+
|
|
62
|
+
## Peer-brand lifecycle
|
|
63
|
+
|
|
64
|
+
A device that hosts more than one brand carries one stanza per brand in `/etc/samba/smb.conf`. The provisioner is idempotent and peer-safe:
|
|
65
|
+
|
|
66
|
+
- **Install a second brand** — the new brand's stanza is appended next to the existing one. The shared `[global]` section is rewritten to keep the LAN-only directives current but is otherwise unchanged. Peer-brand stanzas are preserved byte-for-byte.
|
|
67
|
+
- **Re-run the installer on an existing brand** — the brand's own stanza is replaced in place. Peer stanzas are not touched.
|
|
68
|
+
- **Uninstall one brand** — only that brand's stanza is stripped. `smbd` is then `reload`ed so the brand share disappears from the running config without dropping connections to peer shares.
|
|
69
|
+
- **Uninstall the last brand** — after the stanza is removed, the uninstaller checks `hasAnyBrandStanza()` and, if false, stops and disables `smbd`/`nmbd`, runs `smbpasswd -x <install-owner>` to drop the smbpasswd entry, and `apt-purge samba`. If any peer stanza remains, the units stay running and the package stays installed — the uninstaller logs `Leaving smbd/nmbd + samba package in place — other brand stanza remains`.
|
|
70
|
+
|
|
71
|
+
The brand-stanza name is the only identifier the uninstaller matches on, so two brands with different `BRAND.hostname` values cannot collide.
|
|
72
|
+
|
|
73
|
+
## Troubleshooting
|
|
74
|
+
|
|
75
|
+
- **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
|
|
76
|
+
- **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
|
|
77
|
+
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd nmbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
|
|
78
|
+
- **Hetzner share not reachable from outside the box.** This is by design — see "LAN-only binding" above. Use SSH port forwarding.
|
|
79
|
+
|
|
80
|
+
Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
|
|
@@ -134,6 +134,20 @@ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
|
|
|
134
134
|
|
|
135
135
|
---
|
|
136
136
|
|
|
137
|
+
## Cannot Mount the SMB Share
|
|
138
|
+
|
|
139
|
+
**Symptom:** Mounting `smb://<hostname>.local` (or `\\<hostname>.local\<brand>`) fails with a "logon failure" or the share does not appear in your network browser.
|
|
140
|
+
|
|
141
|
+
**Check:**
|
|
142
|
+
1. Confirm you have set a PIN in the admin UI at least once. On a fresh Pi or Hetzner box the `smbpasswd` entry does not exist until the first set-pin runs — mounts before that point always fail.
|
|
143
|
+
2. Use the install owner as the username (`admin` on a Pi or Hetzner box; the Linux user that ran the installer on a self-hosted laptop) and the current {{productName}} PIN as the password. The SMB password is not stored separately — it is the PIN.
|
|
144
|
+
3. If `<hostname>.local` does not resolve from your client, mount by LAN IP instead (`smb://192.168.1.50` on macOS, `\\192.168.1.50\<brand>` on Windows).
|
|
145
|
+
4. Rotate the PIN in the admin UI. That re-triggers the `smbpasswd` sync on the device. If the resync log line reads `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing`, restore `~/.<brand>/.install-owner` from the installer log.
|
|
146
|
+
|
|
147
|
+
See [Samba Share](./samba.md) for the full credential model and per-OS mount syntax.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
137
151
|
## Restarting the Platform
|
|
138
152
|
|
|
139
153
|
From the admin interface, ask {{productName}}: "Restart the platform."
|
|
@@ -2705,6 +2705,7 @@ var BIN_DIR = resolve(MAXY_DIR, "bin");
|
|
|
2705
2705
|
var REMOTE_PASSWORD_FILE = resolve(MAXY_DIR, ".remote-password");
|
|
2706
2706
|
var REMOTE_SESSION_SECRET_FILE = resolve(MAXY_DIR, "credentials", "remote-session-secret");
|
|
2707
2707
|
var ADMIN_SESSION_SECRET_FILE = resolve(MAXY_DIR, "credentials", "admin-session-secret");
|
|
2708
|
+
var INSTALL_OWNER_FILE = resolve(MAXY_DIR, ".install-owner");
|
|
2708
2709
|
var TELEGRAM_WEBHOOK_SECRET_FILE = resolve(MAXY_DIR, ".telegram-webhook-secret");
|
|
2709
2710
|
var TELEGRAM_ADMIN_WEBHOOK_SECRET_FILE = resolve(MAXY_DIR, ".telegram-admin-webhook-secret");
|
|
2710
2711
|
var VISITOR_TOKEN_SECRET_FILE = resolve(MAXY_DIR, "credentials", "visitor-token-secret");
|
|
@@ -5454,6 +5455,7 @@ export {
|
|
|
5454
5455
|
USERS_FILE,
|
|
5455
5456
|
LOG_DIR,
|
|
5456
5457
|
BIN_DIR,
|
|
5458
|
+
INSTALL_OWNER_FILE,
|
|
5457
5459
|
VISITOR_TOKEN_SECRET_FILE,
|
|
5458
5460
|
CLAUDE_CREDENTIALS_FILE,
|
|
5459
5461
|
vncLog,
|
package/payload/server/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
COMMERCIAL_MODE,
|
|
10
10
|
GREETING_DIRECTIVE,
|
|
11
11
|
Hono,
|
|
12
|
+
INSTALL_OWNER_FILE,
|
|
12
13
|
LOG_DIR,
|
|
13
14
|
MAXY_DIR,
|
|
14
15
|
PLATFORM_ROOT,
|
|
@@ -83,7 +84,7 @@ import {
|
|
|
83
84
|
vncLog,
|
|
84
85
|
walkPremiumBundles,
|
|
85
86
|
writeAdminUserAndPerson
|
|
86
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-EXIPYITB.js";
|
|
87
88
|
import {
|
|
88
89
|
__commonJS,
|
|
89
90
|
__toESM
|
|
@@ -815,8 +816,8 @@ var serveStatic = (options = { root: "" }) => {
|
|
|
815
816
|
};
|
|
816
817
|
|
|
817
818
|
// server/index.ts
|
|
818
|
-
import { readFileSync as
|
|
819
|
-
import { resolve as resolve23, join as
|
|
819
|
+
import { readFileSync as readFileSync21, existsSync as existsSync21, watchFile } from "fs";
|
|
820
|
+
import { resolve as resolve23, join as join16, basename as basename6 } from "path";
|
|
820
821
|
import { homedir as homedir2 } from "os";
|
|
821
822
|
import { monitorEventLoopDelay } from "perf_hooks";
|
|
822
823
|
|
|
@@ -6325,19 +6326,32 @@ app4.post("/set-pin", async (c) => {
|
|
|
6325
6326
|
}
|
|
6326
6327
|
console.log(`[set-pin] wrote users.json + account.json admins: userId=${userId.slice(0, 8)}\u2026 role=owner`);
|
|
6327
6328
|
if (process.platform === "linux") {
|
|
6328
|
-
|
|
6329
|
-
|
|
6329
|
+
let installOwner = null;
|
|
6330
|
+
if (existsSync6(INSTALL_OWNER_FILE)) {
|
|
6331
|
+
try {
|
|
6332
|
+
const raw = readFileSync8(INSTALL_OWNER_FILE, "utf-8").trim();
|
|
6333
|
+
if (raw.length > 0) installOwner = raw;
|
|
6334
|
+
} catch (err) {
|
|
6335
|
+
console.error(`[set-pin] install-owner-read-failed path=${INSTALL_OWNER_FILE} error=${err instanceof Error ? err.message : String(err)}`);
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
if (!installOwner) {
|
|
6339
|
+
console.error(`[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing path=${INSTALL_OWNER_FILE}`);
|
|
6340
|
+
} else {
|
|
6341
|
+
const smbProc = spawnSync2("sudo", ["-n", "smbpasswd", "-a", "-s", installOwner], {
|
|
6342
|
+
input: `${body.pin}
|
|
6330
6343
|
${body.pin}
|
|
6331
6344
|
`,
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6345
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
6346
|
+
encoding: "utf-8",
|
|
6347
|
+
timeout: 1e4
|
|
6348
|
+
});
|
|
6349
|
+
if (smbProc.status === 0) {
|
|
6350
|
+
console.log(`[set-pin] smbpasswd sync ok owner=${installOwner} userId=${userId.slice(0, 8)}\u2026`);
|
|
6351
|
+
} else {
|
|
6352
|
+
const stderr = (smbProc.stderr ?? "").trim().slice(0, 200);
|
|
6353
|
+
console.error(`[set-pin] smbpasswd sync failed owner=${installOwner} rc=${smbProc.status} stderr=${JSON.stringify(stderr)}`);
|
|
6354
|
+
}
|
|
6341
6355
|
}
|
|
6342
6356
|
} else {
|
|
6343
6357
|
console.log(`[set-pin] smb-password-sync-skipped reason=non-linux platform=${process.platform}`);
|
|
@@ -11781,53 +11795,87 @@ function relPath(absPath, root) {
|
|
|
11781
11795
|
var sidebar_artefact_save_default = app23;
|
|
11782
11796
|
|
|
11783
11797
|
// server/routes/admin/sidebar-sessions.ts
|
|
11798
|
+
import { readdirSync as readdirSync8, readFileSync as readFileSync14, statSync as statSync7 } from "fs";
|
|
11799
|
+
import { join as join13 } from "path";
|
|
11800
|
+
var RC_URL_RE = /https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/;
|
|
11801
|
+
var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
|
|
11802
|
+
var TITLE_MAX = 80;
|
|
11784
11803
|
var app24 = new Hono();
|
|
11804
|
+
function projectsDirForAccount(accountId) {
|
|
11805
|
+
const configDir2 = process.env.CLAUDE_CONFIG_DIR;
|
|
11806
|
+
if (!configDir2) return null;
|
|
11807
|
+
const accountCwd = join13(DATA_ROOT, "accounts", accountId);
|
|
11808
|
+
const slug = accountCwd.replace(/\//g, "-");
|
|
11809
|
+
return join13(configDir2, "projects", slug);
|
|
11810
|
+
}
|
|
11811
|
+
function firstUserMessage(body) {
|
|
11812
|
+
for (const line of body.split("\n")) {
|
|
11813
|
+
if (!line || line[0] !== "{") continue;
|
|
11814
|
+
let parsed;
|
|
11815
|
+
try {
|
|
11816
|
+
parsed = JSON.parse(line);
|
|
11817
|
+
} catch {
|
|
11818
|
+
continue;
|
|
11819
|
+
}
|
|
11820
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
11821
|
+
const row = parsed;
|
|
11822
|
+
if (row.type !== "user" || !row.message || row.message.role !== "user") continue;
|
|
11823
|
+
const content = row.message.content;
|
|
11824
|
+
if (typeof content !== "string") continue;
|
|
11825
|
+
const trimmed = content.trim().replace(/\s+/g, " ");
|
|
11826
|
+
if (!trimmed) continue;
|
|
11827
|
+
return trimmed.length > TITLE_MAX ? trimmed.slice(0, TITLE_MAX - 1) + "\u2026" : trimmed;
|
|
11828
|
+
}
|
|
11829
|
+
return null;
|
|
11830
|
+
}
|
|
11785
11831
|
app24.get("/", requireAdminSession, async (c) => {
|
|
11786
11832
|
const cacheKey = c.var.cacheKey;
|
|
11787
11833
|
const accountId = getAccountIdForSession(cacheKey);
|
|
11788
11834
|
if (!accountId) {
|
|
11789
11835
|
return c.json({ error: "Account not found for session" }, 401);
|
|
11790
11836
|
}
|
|
11791
|
-
const
|
|
11792
|
-
|
|
11793
|
-
|
|
11794
|
-
|
|
11795
|
-
upstream = await fetch(url);
|
|
11796
|
-
} catch (err) {
|
|
11797
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
11798
|
-
console.error(`[admin-sessions-list] account=${accountId} fetch-failed error="${message}"`);
|
|
11799
|
-
return c.json({ error: "session-manager-unreachable" }, 503);
|
|
11837
|
+
const projectsDir = projectsDirForAccount(accountId);
|
|
11838
|
+
if (!projectsDir) {
|
|
11839
|
+
console.error(`[admin-sessions-list] account=${accountId} no-claude-config-dir`);
|
|
11840
|
+
return c.json({ sessions: [] });
|
|
11800
11841
|
}
|
|
11801
|
-
|
|
11802
|
-
console.error(`[admin-sessions-list] account=${accountId} upstream-status=${upstream.status}`);
|
|
11803
|
-
return c.json({ error: "session-manager-error", status: upstream.status }, 502);
|
|
11804
|
-
}
|
|
11805
|
-
let payloads;
|
|
11842
|
+
let entries;
|
|
11806
11843
|
try {
|
|
11807
|
-
|
|
11844
|
+
entries = readdirSync8(projectsDir);
|
|
11808
11845
|
} catch (err) {
|
|
11846
|
+
const code = err.code;
|
|
11847
|
+
if (code === "ENOENT") {
|
|
11848
|
+
console.log(`[admin-sessions-list] account=${accountId} rows=0 reason=no-projects-dir`);
|
|
11849
|
+
return c.json({ sessions: [] });
|
|
11850
|
+
}
|
|
11809
11851
|
const message = err instanceof Error ? err.message : String(err);
|
|
11810
|
-
console.error(`[admin-sessions-list] account=${accountId}
|
|
11811
|
-
return c.json({ error: "
|
|
11812
|
-
}
|
|
11813
|
-
if (!Array.isArray(payloads)) {
|
|
11814
|
-
console.error(`[admin-sessions-list] account=${accountId} bad-shape=not-array`);
|
|
11815
|
-
return c.json({ error: "session-manager-bad-body" }, 502);
|
|
11852
|
+
console.error(`[admin-sessions-list] account=${accountId} readdir-failed error="${message}"`);
|
|
11853
|
+
return c.json({ error: "projects-dir-unreadable" }, 500);
|
|
11816
11854
|
}
|
|
11817
11855
|
const rows = [];
|
|
11818
|
-
for (const
|
|
11819
|
-
|
|
11820
|
-
const
|
|
11821
|
-
const
|
|
11822
|
-
|
|
11823
|
-
|
|
11856
|
+
for (const name of entries) {
|
|
11857
|
+
if (!SESSION_ID_RE.test(name)) continue;
|
|
11858
|
+
const sessionId = name.slice(0, -".jsonl".length);
|
|
11859
|
+
const path2 = join13(projectsDir, name);
|
|
11860
|
+
let body;
|
|
11861
|
+
let mtimeMs;
|
|
11862
|
+
try {
|
|
11863
|
+
body = readFileSync14(path2, "utf8");
|
|
11864
|
+
mtimeMs = statSync7(path2).mtimeMs;
|
|
11865
|
+
} catch {
|
|
11866
|
+
continue;
|
|
11867
|
+
}
|
|
11868
|
+
const urlMatch = body.match(RC_URL_RE);
|
|
11869
|
+
if (!urlMatch) continue;
|
|
11870
|
+
const title = firstUserMessage(body) ?? sessionId.slice(0, 8);
|
|
11824
11871
|
rows.push({
|
|
11825
11872
|
sessionId,
|
|
11826
|
-
title
|
|
11827
|
-
url:
|
|
11828
|
-
capturedAt:
|
|
11873
|
+
title,
|
|
11874
|
+
url: urlMatch[0],
|
|
11875
|
+
capturedAt: new Date(mtimeMs).toISOString()
|
|
11829
11876
|
});
|
|
11830
11877
|
}
|
|
11878
|
+
rows.sort((a, b) => a.capturedAt < b.capturedAt ? 1 : -1);
|
|
11831
11879
|
console.log(`[admin-sessions-list] account=${accountId} rows=${rows.length}`);
|
|
11832
11880
|
return c.json({ sessions: rows });
|
|
11833
11881
|
});
|
|
@@ -11955,14 +12003,14 @@ app25.get("/", async (c) => {
|
|
|
11955
12003
|
var system_stats_default = app25;
|
|
11956
12004
|
|
|
11957
12005
|
// server/routes/admin/health.ts
|
|
11958
|
-
import { existsSync as existsSync17, readFileSync as
|
|
11959
|
-
import { resolve as resolve17, join as
|
|
12006
|
+
import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
|
|
12007
|
+
import { resolve as resolve17, join as join14 } from "path";
|
|
11960
12008
|
var PLATFORM_ROOT6 = process.env.MAXY_PLATFORM_ROOT ?? resolve17(process.cwd(), "..");
|
|
11961
12009
|
var brandHostname = "maxy";
|
|
11962
|
-
var brandJsonPath =
|
|
12010
|
+
var brandJsonPath = join14(PLATFORM_ROOT6, "config", "brand.json");
|
|
11963
12011
|
if (existsSync17(brandJsonPath)) {
|
|
11964
12012
|
try {
|
|
11965
|
-
const brand = JSON.parse(
|
|
12013
|
+
const brand = JSON.parse(readFileSync15(brandJsonPath, "utf-8"));
|
|
11966
12014
|
if (brand.hostname) brandHostname = brand.hostname;
|
|
11967
12015
|
} catch {
|
|
11968
12016
|
}
|
|
@@ -11972,7 +12020,7 @@ var PROCESS_STARTED_AT = (/* @__PURE__ */ new Date()).toISOString();
|
|
|
11972
12020
|
var PROBE_TIMEOUT_MS = 1e3;
|
|
11973
12021
|
function readVersion() {
|
|
11974
12022
|
if (!existsSync17(VERSION_FILE)) return "unknown";
|
|
11975
|
-
return
|
|
12023
|
+
return readFileSync15(VERSION_FILE, "utf-8").trim() || "unknown";
|
|
11976
12024
|
}
|
|
11977
12025
|
async function probeConversationDb() {
|
|
11978
12026
|
let session;
|
|
@@ -12481,7 +12529,7 @@ var admin_default = app32;
|
|
|
12481
12529
|
|
|
12482
12530
|
// app/lib/access-gate.ts
|
|
12483
12531
|
import neo4j4 from "neo4j-driver";
|
|
12484
|
-
import { readFileSync as
|
|
12532
|
+
import { readFileSync as readFileSync16 } from "fs";
|
|
12485
12533
|
import { resolve as resolve18 } from "path";
|
|
12486
12534
|
import { randomUUID as randomUUID8 } from "crypto";
|
|
12487
12535
|
var PLATFORM_ROOT7 = process.env.MAXY_PLATFORM_ROOT ?? resolve18(process.cwd(), "..");
|
|
@@ -12490,7 +12538,7 @@ function readPassword() {
|
|
|
12490
12538
|
if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
|
|
12491
12539
|
const passwordFile = resolve18(PLATFORM_ROOT7, "config/.neo4j-password");
|
|
12492
12540
|
try {
|
|
12493
|
-
return
|
|
12541
|
+
return readFileSync16(passwordFile, "utf-8").trim();
|
|
12494
12542
|
} catch {
|
|
12495
12543
|
throw new Error(
|
|
12496
12544
|
`Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`
|
|
@@ -12922,7 +12970,7 @@ app35.route("/request-magic-link", request_magic_link_default);
|
|
|
12922
12970
|
var access_default = app35;
|
|
12923
12971
|
|
|
12924
12972
|
// server/routes/sites.ts
|
|
12925
|
-
import { existsSync as existsSync18, readFileSync as
|
|
12973
|
+
import { existsSync as existsSync18, readFileSync as readFileSync17, realpathSync as realpathSync5, statSync as statSync8 } from "fs";
|
|
12926
12974
|
import { resolve as resolve20 } from "path";
|
|
12927
12975
|
var SAFE_SEG_RE = /^[a-z0-9_][a-z0-9_.-]{0,99}$/i;
|
|
12928
12976
|
var MIME = {
|
|
@@ -12988,7 +13036,7 @@ app36.get("/:rel{.*}", (c) => {
|
|
|
12988
13036
|
}
|
|
12989
13037
|
let stat8;
|
|
12990
13038
|
try {
|
|
12991
|
-
stat8 = existsSync18(filePath) ?
|
|
13039
|
+
stat8 = existsSync18(filePath) ? statSync8(filePath) : null;
|
|
12992
13040
|
} catch {
|
|
12993
13041
|
stat8 = null;
|
|
12994
13042
|
}
|
|
@@ -13026,7 +13074,7 @@ app36.get("/:rel{.*}", (c) => {
|
|
|
13026
13074
|
}
|
|
13027
13075
|
let body;
|
|
13028
13076
|
try {
|
|
13029
|
-
body =
|
|
13077
|
+
body = readFileSync17(realPath);
|
|
13030
13078
|
} catch (err) {
|
|
13031
13079
|
const code = err?.code;
|
|
13032
13080
|
if (code === "EISDIR") {
|
|
@@ -13062,7 +13110,7 @@ var sites_default = app36;
|
|
|
13062
13110
|
|
|
13063
13111
|
// app/lib/visitor-token.ts
|
|
13064
13112
|
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
13065
|
-
import { mkdirSync as mkdirSync4, readFileSync as
|
|
13113
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync18, writeFileSync as writeFileSync6 } from "fs";
|
|
13066
13114
|
import { dirname as dirname4 } from "path";
|
|
13067
13115
|
var TOKEN_PREFIX = "v1.";
|
|
13068
13116
|
var SECRET_BYTES = 32;
|
|
@@ -13071,7 +13119,7 @@ var cachedSecret = null;
|
|
|
13071
13119
|
function getSecret() {
|
|
13072
13120
|
if (cachedSecret) return cachedSecret;
|
|
13073
13121
|
try {
|
|
13074
|
-
const hex2 =
|
|
13122
|
+
const hex2 = readFileSync18(VISITOR_TOKEN_SECRET_FILE, "utf-8").trim();
|
|
13075
13123
|
if (hex2.length === SECRET_BYTES * 2) {
|
|
13076
13124
|
cachedSecret = Buffer.from(hex2, "hex");
|
|
13077
13125
|
return cachedSecret;
|
|
@@ -13085,7 +13133,7 @@ function getSecret() {
|
|
|
13085
13133
|
console.log(`[visitor-token] secret minted path=${VISITOR_TOKEN_SECRET_FILE}`);
|
|
13086
13134
|
} catch {
|
|
13087
13135
|
}
|
|
13088
|
-
const hex =
|
|
13136
|
+
const hex = readFileSync18(VISITOR_TOKEN_SECRET_FILE, "utf-8").trim();
|
|
13089
13137
|
cachedSecret = Buffer.from(hex, "hex");
|
|
13090
13138
|
return cachedSecret;
|
|
13091
13139
|
}
|
|
@@ -13138,8 +13186,8 @@ var VISITOR_COOKIE_NAME = "mxy_v";
|
|
|
13138
13186
|
var VISITOR_COOKIE_MAX_AGE_SECONDS = Math.floor(DEFAULT_TTL_MS / 1e3);
|
|
13139
13187
|
|
|
13140
13188
|
// app/lib/brand-config.ts
|
|
13141
|
-
import { existsSync as existsSync19, readFileSync as
|
|
13142
|
-
import { join as
|
|
13189
|
+
import { existsSync as existsSync19, readFileSync as readFileSync19 } from "fs";
|
|
13190
|
+
import { join as join15 } from "path";
|
|
13143
13191
|
var cached2 = null;
|
|
13144
13192
|
var cachedAttempted = false;
|
|
13145
13193
|
function readBrandConfig() {
|
|
@@ -13147,10 +13195,10 @@ function readBrandConfig() {
|
|
|
13147
13195
|
cachedAttempted = true;
|
|
13148
13196
|
const platformRoot = process.env.MAXY_PLATFORM_ROOT;
|
|
13149
13197
|
if (!platformRoot) return null;
|
|
13150
|
-
const brandPath =
|
|
13198
|
+
const brandPath = join15(platformRoot, "config", "brand.json");
|
|
13151
13199
|
if (!existsSync19(brandPath)) return null;
|
|
13152
13200
|
try {
|
|
13153
|
-
cached2 = JSON.parse(
|
|
13201
|
+
cached2 = JSON.parse(readFileSync19(brandPath, "utf-8"));
|
|
13154
13202
|
return cached2;
|
|
13155
13203
|
} catch {
|
|
13156
13204
|
return null;
|
|
@@ -13882,7 +13930,7 @@ function broadcastAdminShutdown(reason) {
|
|
|
13882
13930
|
|
|
13883
13931
|
// ../lib/entitlement/src/index.ts
|
|
13884
13932
|
import { createPublicKey, createHash as createHash4, verify as cryptoVerify } from "crypto";
|
|
13885
|
-
import { existsSync as existsSync20, readFileSync as
|
|
13933
|
+
import { existsSync as existsSync20, readFileSync as readFileSync20, statSync as statSync9 } from "fs";
|
|
13886
13934
|
import { resolve as resolve22 } from "path";
|
|
13887
13935
|
|
|
13888
13936
|
// ../lib/entitlement/src/canonicalize.ts
|
|
@@ -13934,7 +13982,7 @@ function resolveEntitlement(brand, account) {
|
|
|
13934
13982
|
if (!existsSync20(entitlementPath)) {
|
|
13935
13983
|
return logResolved(anonymousFallback("missing"), { reason: "missing" });
|
|
13936
13984
|
}
|
|
13937
|
-
const stat8 =
|
|
13985
|
+
const stat8 = statSync9(entitlementPath);
|
|
13938
13986
|
const key = memoKey(stat8.mtimeMs, account);
|
|
13939
13987
|
if (memo && memo.key === key) {
|
|
13940
13988
|
return memo.result;
|
|
@@ -13946,7 +13994,7 @@ function resolveEntitlement(brand, account) {
|
|
|
13946
13994
|
function verifyAndResolve(brand, entitlementPath, account) {
|
|
13947
13995
|
let pubkeyPem;
|
|
13948
13996
|
try {
|
|
13949
|
-
pubkeyPem =
|
|
13997
|
+
pubkeyPem = readFileSync20(pubkeyPath(brand), "utf-8");
|
|
13950
13998
|
} catch (err) {
|
|
13951
13999
|
return logResolved(anonymousFallback("pubkey-missing"), {
|
|
13952
14000
|
reason: "pubkey-missing"
|
|
@@ -13960,7 +14008,7 @@ function verifyAndResolve(brand, entitlementPath, account) {
|
|
|
13960
14008
|
}
|
|
13961
14009
|
let envelope;
|
|
13962
14010
|
try {
|
|
13963
|
-
envelope = JSON.parse(
|
|
14011
|
+
envelope = JSON.parse(readFileSync20(entitlementPath, "utf-8"));
|
|
13964
14012
|
} catch {
|
|
13965
14013
|
return logResolved(anonymousFallback("malformed"), { reason: "malformed" });
|
|
13966
14014
|
}
|
|
@@ -14121,14 +14169,14 @@ function clientFrom(c) {
|
|
|
14121
14169
|
);
|
|
14122
14170
|
}
|
|
14123
14171
|
var PLATFORM_ROOT9 = process.env.MAXY_PLATFORM_ROOT || "";
|
|
14124
|
-
var BRAND_JSON_PATH = PLATFORM_ROOT9 ?
|
|
14172
|
+
var BRAND_JSON_PATH = PLATFORM_ROOT9 ? join16(PLATFORM_ROOT9, "config", "brand.json") : "";
|
|
14125
14173
|
var BRAND = { productName: "Maxy", hostname: "maxy", configDir: ".maxy", marketingUrl: "https://getmaxy.com" };
|
|
14126
14174
|
if (BRAND_JSON_PATH && !existsSync21(BRAND_JSON_PATH)) {
|
|
14127
14175
|
console.error(`[brand] WARNING: brand.json not found at ${BRAND_JSON_PATH} \u2014 using Maxy defaults`);
|
|
14128
14176
|
}
|
|
14129
14177
|
if (BRAND_JSON_PATH && existsSync21(BRAND_JSON_PATH)) {
|
|
14130
14178
|
try {
|
|
14131
|
-
const parsed = JSON.parse(
|
|
14179
|
+
const parsed = JSON.parse(readFileSync21(BRAND_JSON_PATH, "utf-8"));
|
|
14132
14180
|
BRAND = { ...BRAND, ...parsed };
|
|
14133
14181
|
} catch (err) {
|
|
14134
14182
|
console.error(`[brand] Failed to parse brand.json: ${err.message}`);
|
|
@@ -14147,11 +14195,11 @@ var brandLoginOpts = {
|
|
|
14147
14195
|
bodyFont: BRAND.defaultFonts?.body,
|
|
14148
14196
|
logoContainsName: !!BRAND.logoContainsName
|
|
14149
14197
|
};
|
|
14150
|
-
var ALIAS_DOMAINS_PATH =
|
|
14198
|
+
var ALIAS_DOMAINS_PATH = join16(homedir2(), BRAND.configDir, "alias-domains.json");
|
|
14151
14199
|
function loadAliasDomains() {
|
|
14152
14200
|
try {
|
|
14153
14201
|
if (!existsSync21(ALIAS_DOMAINS_PATH)) return null;
|
|
14154
|
-
const parsed = JSON.parse(
|
|
14202
|
+
const parsed = JSON.parse(readFileSync21(ALIAS_DOMAINS_PATH, "utf-8"));
|
|
14155
14203
|
if (!Array.isArray(parsed)) {
|
|
14156
14204
|
console.error("[alias-domains] malformed alias-domains.json \u2014 expected array");
|
|
14157
14205
|
return null;
|
|
@@ -14528,7 +14576,7 @@ app40.get("/agent-assets/:slug/:filename", (c) => {
|
|
|
14528
14576
|
const ext = "." + filename.split(".").pop()?.toLowerCase();
|
|
14529
14577
|
const contentType = IMAGE_MIME[ext] || "application/octet-stream";
|
|
14530
14578
|
console.log(`[agent-assets] serve slug=${slug} file=${filename} status=200`);
|
|
14531
|
-
const body =
|
|
14579
|
+
const body = readFileSync21(filePath);
|
|
14532
14580
|
return c.body(body, 200, {
|
|
14533
14581
|
"Content-Type": contentType,
|
|
14534
14582
|
"Cache-Control": "public, max-age=3600"
|
|
@@ -14558,7 +14606,7 @@ app40.get("/generated/:filename", (c) => {
|
|
|
14558
14606
|
const ext = "." + filename.split(".").pop()?.toLowerCase();
|
|
14559
14607
|
const contentType = IMAGE_MIME[ext] || "application/octet-stream";
|
|
14560
14608
|
console.log(`[generated] serve file=${filename} status=200`);
|
|
14561
|
-
const body =
|
|
14609
|
+
const body = readFileSync21(filePath);
|
|
14562
14610
|
return c.body(body, 200, {
|
|
14563
14611
|
"Content-Type": contentType,
|
|
14564
14612
|
"Cache-Control": "public, max-age=86400"
|
|
@@ -14573,7 +14621,7 @@ var brandLogoPath = "/brand/maxy-monochrome.png";
|
|
|
14573
14621
|
var brandIconPath = "/brand/maxy-monochrome.png";
|
|
14574
14622
|
if (BRAND_JSON_PATH && existsSync21(BRAND_JSON_PATH)) {
|
|
14575
14623
|
try {
|
|
14576
|
-
const fullBrand = JSON.parse(
|
|
14624
|
+
const fullBrand = JSON.parse(readFileSync21(BRAND_JSON_PATH, "utf-8"));
|
|
14577
14625
|
if (fullBrand.assets?.logo) brandLogoPath = `/brand/${fullBrand.assets.logo}`;
|
|
14578
14626
|
brandIconPath = fullBrand.assets?.icon ? `/brand/${fullBrand.assets.icon}` : brandLogoPath;
|
|
14579
14627
|
} catch {
|
|
@@ -14590,9 +14638,9 @@ var brandScript = `<script>window.__BRAND__=${JSON.stringify({
|
|
|
14590
14638
|
function readInstalledVersion() {
|
|
14591
14639
|
try {
|
|
14592
14640
|
if (!PLATFORM_ROOT9) return "unknown";
|
|
14593
|
-
const versionFile =
|
|
14641
|
+
const versionFile = join16(PLATFORM_ROOT9, "config", `.${BRAND.hostname}-version`);
|
|
14594
14642
|
if (!existsSync21(versionFile)) return "unknown";
|
|
14595
|
-
const content =
|
|
14643
|
+
const content = readFileSync21(versionFile, "utf-8").trim();
|
|
14596
14644
|
return content || "unknown";
|
|
14597
14645
|
} catch {
|
|
14598
14646
|
return "unknown";
|
|
@@ -14633,7 +14681,7 @@ var clientErrorReporterScript = `<script>
|
|
|
14633
14681
|
function cachedHtml(file) {
|
|
14634
14682
|
let html = htmlCache.get(file);
|
|
14635
14683
|
if (!html) {
|
|
14636
|
-
html =
|
|
14684
|
+
html = readFileSync21(resolve23(process.cwd(), "public", file), "utf-8");
|
|
14637
14685
|
const productNameEsc = escapeHtml(BRAND.productName);
|
|
14638
14686
|
html = html.replace(/<title>([^<]*)<\/title>/, (_match, inner) => `<title>${escapeHtml(inner).replace(/Maxy/g, productNameEsc)}</title>`);
|
|
14639
14687
|
html = html.replace('href="/favicon.ico"', `href="${escapeHtml(brandFaviconPath)}"`);
|
|
@@ -14649,26 +14697,26 @@ ${clientErrorReporterScript}
|
|
|
14649
14697
|
}
|
|
14650
14698
|
var brandedHtmlCache = /* @__PURE__ */ new Map();
|
|
14651
14699
|
function loadBrandingCache(agentSlug) {
|
|
14652
|
-
const configDir2 =
|
|
14700
|
+
const configDir2 = join16(homedir2(), BRAND.configDir);
|
|
14653
14701
|
try {
|
|
14654
|
-
const accountJsonPath =
|
|
14702
|
+
const accountJsonPath = join16(configDir2, "account.json");
|
|
14655
14703
|
if (!existsSync21(accountJsonPath)) return null;
|
|
14656
|
-
const account = JSON.parse(
|
|
14704
|
+
const account = JSON.parse(readFileSync21(accountJsonPath, "utf-8"));
|
|
14657
14705
|
const accountId = account.accountId;
|
|
14658
14706
|
if (!accountId) return null;
|
|
14659
|
-
const cachePath =
|
|
14707
|
+
const cachePath = join16(configDir2, "branding-cache", accountId, `${agentSlug}.json`);
|
|
14660
14708
|
if (!existsSync21(cachePath)) return null;
|
|
14661
|
-
return JSON.parse(
|
|
14709
|
+
return JSON.parse(readFileSync21(cachePath, "utf-8"));
|
|
14662
14710
|
} catch {
|
|
14663
14711
|
return null;
|
|
14664
14712
|
}
|
|
14665
14713
|
}
|
|
14666
14714
|
function resolveDefaultSlug() {
|
|
14667
14715
|
try {
|
|
14668
|
-
const configDir2 =
|
|
14669
|
-
const accountJsonPath =
|
|
14716
|
+
const configDir2 = join16(homedir2(), BRAND.configDir);
|
|
14717
|
+
const accountJsonPath = join16(configDir2, "account.json");
|
|
14670
14718
|
if (!existsSync21(accountJsonPath)) return null;
|
|
14671
|
-
const account = JSON.parse(
|
|
14719
|
+
const account = JSON.parse(readFileSync21(accountJsonPath, "utf-8"));
|
|
14672
14720
|
return account.defaultAgent || null;
|
|
14673
14721
|
} catch {
|
|
14674
14722
|
return null;
|
|
@@ -14741,7 +14789,7 @@ app40.use("/vnc-popout.html", logViewerFetch);
|
|
|
14741
14789
|
app40.get("/vnc-popout.html", (c) => {
|
|
14742
14790
|
let html = htmlCache.get("vnc-popout.html");
|
|
14743
14791
|
if (!html) {
|
|
14744
|
-
html =
|
|
14792
|
+
html = readFileSync21(resolve23(process.cwd(), "public", "vnc-popout.html"), "utf-8");
|
|
14745
14793
|
const name = escapeHtml(BRAND.productName);
|
|
14746
14794
|
html = html.replace("<title>Browser \u2014 Maxy</title>", `<title>${name}</title>`);
|
|
14747
14795
|
html = html.replace("</head>", ` ${brandScript}
|
|
@@ -14867,7 +14915,7 @@ try {
|
|
|
14867
14915
|
(async () => {
|
|
14868
14916
|
try {
|
|
14869
14917
|
if (!existsSync21(USERS_FILE)) return;
|
|
14870
|
-
const usersRaw =
|
|
14918
|
+
const usersRaw = readFileSync21(USERS_FILE, "utf-8").trim();
|
|
14871
14919
|
if (!usersRaw) return;
|
|
14872
14920
|
const users = JSON.parse(usersRaw);
|
|
14873
14921
|
const userId = users[0]?.userId;
|
|
@@ -14962,7 +15010,7 @@ if (bootAccountConfig?.whatsapp) {
|
|
|
14962
15010
|
}
|
|
14963
15011
|
init({
|
|
14964
15012
|
configDir: configDirForWhatsApp,
|
|
14965
|
-
platformRoot: resolve23(process.env.MAXY_PLATFORM_ROOT ??
|
|
15013
|
+
platformRoot: resolve23(process.env.MAXY_PLATFORM_ROOT ?? join16(__dirname, "..")),
|
|
14966
15014
|
accountConfig: bootAccountConfig,
|
|
14967
15015
|
onMessage: async (msg) => {
|
|
14968
15016
|
if (process.env.WHATSAPP_PTY_BRIDGE_ENABLED === "true" && msg.text && !msg.isOwnerMirror) {
|