@rubytech/create-realagent 1.0.682 → 1.0.684
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +41 -184
- package/dist/uninstall.js +172 -72
- package/package.json +1 -1
- package/payload/platform/config/brand.json +1 -0
- package/payload/platform/plugins/docs/references/deployment.md +16 -0
- package/payload/platform/plugins/docs/references/troubleshooting.md +23 -0
- package/payload/server/public/assets/{admin-Bu8EzQH7.js → admin-WQxJgaus.js} +3 -3
- package/payload/server/public/index.html +1 -1
package/dist/index.js
CHANGED
|
@@ -726,21 +726,11 @@ function ensureNeo4jPassword() {
|
|
|
726
726
|
console.log(" Stored password doesn't match dedicated Neo4j instance.");
|
|
727
727
|
}
|
|
728
728
|
else {
|
|
729
|
-
console.log(" Stored password doesn't match Neo4j.
|
|
729
|
+
console.log(" Stored password doesn't match Neo4j. Resetting auth.");
|
|
730
730
|
}
|
|
731
731
|
}
|
|
732
|
-
// 2.
|
|
733
|
-
//
|
|
734
|
-
if (!NEO4J_DEDICATED) {
|
|
735
|
-
const crossBrandPassword = findWorkingNeo4jPassword();
|
|
736
|
-
if (crossBrandPassword) {
|
|
737
|
-
writeFileSync(passwordFile, crossBrandPassword.password, { mode: 0o600 });
|
|
738
|
-
mkdirSync(persistDir, { recursive: true });
|
|
739
|
-
writeFileSync(persistentPasswordFile, crossBrandPassword.password, { mode: 0o600 });
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
// 3. Fresh install: no working password found. Generate and set a new one.
|
|
732
|
+
// 2. Fresh install or recovery: no working same-brand password. Generate and set a new one.
|
|
733
|
+
// Brand isolation (Task 659): never read another brand's .neo4j-password.
|
|
744
734
|
if (!existsSync(passwordFile)) {
|
|
745
735
|
console.log(" No Neo4j password file found. Setting initial password...");
|
|
746
736
|
}
|
|
@@ -766,42 +756,6 @@ function neo4jPasswordWorks(password, port = DEFAULT_NEO4J_PORT) {
|
|
|
766
756
|
], { stdio: "pipe", timeout: 10000 });
|
|
767
757
|
return check.status === 0;
|
|
768
758
|
}
|
|
769
|
-
/** Scan $HOME dotdirs for a .neo4j-password file with a working password.
|
|
770
|
-
* Returns the password and source dir name, or null if none found. */
|
|
771
|
-
function findWorkingNeo4jPassword() {
|
|
772
|
-
const home = process.env.HOME ?? "/root";
|
|
773
|
-
let entries;
|
|
774
|
-
try {
|
|
775
|
-
entries = readdirSync(home);
|
|
776
|
-
}
|
|
777
|
-
catch {
|
|
778
|
-
logFile(" Neo4j cross-brand scan: could not read $HOME");
|
|
779
|
-
return null;
|
|
780
|
-
}
|
|
781
|
-
for (const entry of entries) {
|
|
782
|
-
// Only check dotdirs that aren't our own configDir
|
|
783
|
-
if (!entry.startsWith(".") || entry === BRAND.configDir)
|
|
784
|
-
continue;
|
|
785
|
-
const candidate = join(home, entry, ".neo4j-password");
|
|
786
|
-
try {
|
|
787
|
-
if (!existsSync(candidate))
|
|
788
|
-
continue;
|
|
789
|
-
const password = readFileSync(candidate, "utf-8").trim();
|
|
790
|
-
if (!password)
|
|
791
|
-
continue;
|
|
792
|
-
if (neo4jPasswordWorks(password)) {
|
|
793
|
-
console.log(` Neo4j password: reused from ~/${entry}`);
|
|
794
|
-
logFile(` Neo4j password: reused from ~/${entry}`);
|
|
795
|
-
return { password, source: entry };
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
catch {
|
|
799
|
-
// Unreadable file or other FS error — skip this candidate
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
return null;
|
|
804
|
-
}
|
|
805
759
|
function installNeo4j() {
|
|
806
760
|
if (commandExists("neo4j")) {
|
|
807
761
|
log("4", TOTAL, "Neo4j already installed.");
|
|
@@ -1175,90 +1129,10 @@ function deployPayload() {
|
|
|
1175
1129
|
cpSync(oldUsersFile, persistentUsersFile);
|
|
1176
1130
|
console.log(" Migrated users.json to persistent storage.");
|
|
1177
1131
|
}
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
if (BRAND.configDir !== ".maxy") {
|
|
1183
|
-
const legacyDir = resolve(process.env.HOME ?? "/root", ".maxy");
|
|
1184
|
-
const MIGRATABLE_SECRETS = [".anthropic-api-key", ".admin-pin", ".remote-password"];
|
|
1185
|
-
for (const secret of MIGRATABLE_SECRETS) {
|
|
1186
|
-
const legacyFile = join(legacyDir, secret);
|
|
1187
|
-
const brandFile = join(persistentDir, secret);
|
|
1188
|
-
if (existsSync(legacyFile) && !existsSync(brandFile)) {
|
|
1189
|
-
mkdirSync(persistentDir, { recursive: true });
|
|
1190
|
-
cpSync(legacyFile, brandFile);
|
|
1191
|
-
console.log(` Migrated ${secret} from ~/.maxy/ to ~/${BRAND.configDir}/`);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
// Migrate cloudflared state from ~/.cloudflared/ to ~/{configDir}/cloudflared/.
|
|
1196
|
-
// Pre-Task-441 code hardcoded ~/.cloudflared/ for all brands. On upgrade, existing
|
|
1197
|
-
// tunnel.state and cert.pem may exist at the legacy path. Runs for ALL brands
|
|
1198
|
-
// (including default .maxy) since the legacy path was ~/.cloudflared/ regardless of brand.
|
|
1199
|
-
//
|
|
1200
|
-
// tunnel.state: COPY (not move). cloudflared's `tunnel run` reads state from
|
|
1201
|
-
// ~/.cloudflared/ when no --config is passed — emptying that path risks tunnel-startup regression.
|
|
1202
|
-
//
|
|
1203
|
-
// cert.pem: MOVE (copy + remove). Maxy's code always passes --origincert pointing
|
|
1204
|
-
// at the brand path, so the legacy copy is unreferenced post-migration. Leaving it
|
|
1205
|
-
// in place lets `tunnel-login force=true` look like a reset but silently re-import
|
|
1206
|
-
// the stale cert on the next read (Task 531). Removing the legacy file makes the
|
|
1207
|
-
// recovery path Task 529's pre-flight error recommends actually work.
|
|
1208
|
-
const legacyCloudflaredDir = resolve(process.env.HOME ?? "/root", ".cloudflared");
|
|
1209
|
-
const brandCloudflaredDir = join(persistentDir, "cloudflared");
|
|
1210
|
-
const CLOUDFLARED_MIGRATIONS = [
|
|
1211
|
-
{ file: "tunnel.state", removeLegacy: false },
|
|
1212
|
-
{ file: "cert.pem", removeLegacy: true },
|
|
1213
|
-
];
|
|
1214
|
-
for (const { file, removeLegacy } of CLOUDFLARED_MIGRATIONS) {
|
|
1215
|
-
const legacyFile = join(legacyCloudflaredDir, file);
|
|
1216
|
-
const brandFile = join(brandCloudflaredDir, file);
|
|
1217
|
-
if (existsSync(legacyFile) && !existsSync(brandFile)) {
|
|
1218
|
-
mkdirSync(brandCloudflaredDir, { recursive: true });
|
|
1219
|
-
cpSync(legacyFile, brandFile);
|
|
1220
|
-
if (removeLegacy) {
|
|
1221
|
-
// Explicit try/catch instead of rmSync({force:true}): force swallows
|
|
1222
|
-
// EACCES/EBUSY as well as ENOENT, which would silently leave a
|
|
1223
|
-
// root-owned legacy cert.pem in place while logging success — the
|
|
1224
|
-
// exact silent-failure class Task 531 exists to close. Surface the
|
|
1225
|
-
// error so the operator knows the migration is incomplete.
|
|
1226
|
-
try {
|
|
1227
|
-
rmSync(legacyFile);
|
|
1228
|
-
console.log(` Migrated ${file} from ~/.cloudflared/ to ~/${BRAND.configDir}/cloudflared/ (and removed legacy copy)`);
|
|
1229
|
-
}
|
|
1230
|
-
catch (err) {
|
|
1231
|
-
const code = err.code;
|
|
1232
|
-
if (code === "ENOENT") {
|
|
1233
|
-
// Legacy file vanished between existsSync and rmSync — benign
|
|
1234
|
-
console.log(` Migrated ${file} from ~/.cloudflared/ to ~/${BRAND.configDir}/cloudflared/ (legacy copy already gone)`);
|
|
1235
|
-
}
|
|
1236
|
-
else {
|
|
1237
|
-
console.log(` Migrated ${file} from ~/.cloudflared/ to ~/${BRAND.configDir}/cloudflared/ — WARNING: could not remove legacy ${legacyFile}: ${err}`);
|
|
1238
|
-
console.log(` Remove manually with: sudo rm ${legacyFile}`);
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
else {
|
|
1243
|
-
console.log(` Migrated ${file} from ~/.cloudflared/ to ~/${BRAND.configDir}/cloudflared/`);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
// Migrate Cloudflare API token from ~/.cloudflare/ to ~/{configDir}/cloudflare/.
|
|
1248
|
-
// Pre-Task-441 code stored the API token at ~/.cloudflare/api-token (hardcoded).
|
|
1249
|
-
// Post-Task-441 code reads from ~/{configDir}/cloudflare/api-token (brand-aware).
|
|
1250
|
-
// Without this migration, tunnel-status reports hasToken=false on upgraded Pis
|
|
1251
|
-
// even though the token file exists at the legacy path.
|
|
1252
|
-
const legacyCloudflareDir = resolve(process.env.HOME ?? "/root", ".cloudflare");
|
|
1253
|
-
const brandCloudflareDir = join(persistentDir, "cloudflare");
|
|
1254
|
-
const legacyTokenFile = join(legacyCloudflareDir, "api-token");
|
|
1255
|
-
const brandTokenFile = join(brandCloudflareDir, "api-token");
|
|
1256
|
-
if (existsSync(legacyTokenFile) && !existsSync(brandTokenFile)) {
|
|
1257
|
-
mkdirSync(brandCloudflareDir, { recursive: true });
|
|
1258
|
-
cpSync(legacyTokenFile, brandTokenFile);
|
|
1259
|
-
chmodSync(brandTokenFile, 0o600);
|
|
1260
|
-
console.log(` Migrated api-token from ~/.cloudflare/ to ~/${BRAND.configDir}/cloudflare/`);
|
|
1261
|
-
}
|
|
1132
|
+
// Brand isolation: installer does not read ~/.maxy/, ~/.cloudflared/, or
|
|
1133
|
+
// ~/.cloudflare/ on non-default brands. These are peer-brand or shared-singleton
|
|
1134
|
+
// paths (Task 659). Pre-Task-659 installs that need to recover legacy state
|
|
1135
|
+
// follow the manual recovery paragraph in .docs/deployment.md.
|
|
1262
1136
|
// Stop the running service before wiping directories (upgrade path).
|
|
1263
1137
|
// The server holds open files in platform/ — rmSync fails with ENOTEMPTY if it's running.
|
|
1264
1138
|
// systemctl stop returns when the main process exits, but ExecStopPost (e.g. VNC cleanup)
|
|
@@ -1554,14 +1428,17 @@ function setupAccount() {
|
|
|
1554
1428
|
// the symlink the agent's first SKILL-compliant invocation fails with exit
|
|
1555
1429
|
// 127 — the discipline-violation loop Task 555 exists to close.
|
|
1556
1430
|
//
|
|
1557
|
-
// Collision discipline:
|
|
1431
|
+
// Collision discipline (Task 659 — last-writer-wins across brands):
|
|
1558
1432
|
// - absent path → create
|
|
1433
|
+
// - regular file → exit 1 (operator-owned file, do not clobber)
|
|
1559
1434
|
// - symlink → same target → no-op
|
|
1560
|
-
// - symlink →
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1435
|
+
// - symlink → any other target → overwrite (unlink + symlink)
|
|
1436
|
+
// covers stale-same-brand, dangling,
|
|
1437
|
+
// unreadable, and peer-brand cases.
|
|
1438
|
+
// setup-tunnel.sh takes <brand> as argv
|
|
1439
|
+
// so the script still operates on the
|
|
1440
|
+
// correct brand regardless of who owns
|
|
1441
|
+
// the shortcut symlink.
|
|
1565
1442
|
// ---------------------------------------------------------------------------
|
|
1566
1443
|
function createTunnelSymlink(linkPath, target) {
|
|
1567
1444
|
const targetAbs = resolve(target);
|
|
@@ -1589,48 +1466,27 @@ function createTunnelSymlink(linkPath, target) {
|
|
|
1589
1466
|
console.error(` Remove the file and re-run: npx -y @rubytech/create-maxy`);
|
|
1590
1467
|
process.exit(1);
|
|
1591
1468
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1469
|
+
// Brand isolation (Task 659): a symlink at this path is either stale-same-brand,
|
|
1470
|
+
// dangling, unreadable, or pointing at another brand. All four cases are resolved
|
|
1471
|
+
// by last-writer-wins overwrite — setup-tunnel.sh takes <brand> as argv, so
|
|
1472
|
+
// whichever brand's installer ran last owns the shortcut.
|
|
1473
|
+
const resolvedTarget = (() => {
|
|
1474
|
+
try {
|
|
1475
|
+
return resolve(dirname(linkPath), readlinkSync(linkPath));
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
return "<unreadable>";
|
|
1479
|
+
}
|
|
1480
|
+
})();
|
|
1603
1481
|
if (resolvedTarget === targetAbs) {
|
|
1604
1482
|
console.log(` [create-maxy] symlink ${linkPath} already points to ${targetAbs}`);
|
|
1605
1483
|
logFile(` symlink unchanged: ${linkPath} → ${targetAbs}`);
|
|
1606
1484
|
return;
|
|
1607
1485
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
targetExists = true;
|
|
1613
|
-
}
|
|
1614
|
-
catch { /* resolvedTarget absent → dangling */ }
|
|
1615
|
-
if (insideInstallDir) {
|
|
1616
|
-
unlinkSync(linkPath);
|
|
1617
|
-
symlinkSync(targetAbs, linkPath);
|
|
1618
|
-
console.log(` [create-maxy] symlink replaced: ${linkPath} → ${targetAbs}`);
|
|
1619
|
-
logFile(` symlink replaced (stale same-brand): ${linkPath} was ${resolvedTarget}, now ${targetAbs}`);
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1622
|
-
if (!targetExists) {
|
|
1623
|
-
unlinkSync(linkPath);
|
|
1624
|
-
symlinkSync(targetAbs, linkPath);
|
|
1625
|
-
console.log(` [create-maxy] symlink repair: dangling ${linkPath} replaced (→ ${targetAbs})`);
|
|
1626
|
-
logFile(` symlink repaired (dangling): ${linkPath} was ${resolvedTarget}, now ${targetAbs}`);
|
|
1627
|
-
return;
|
|
1628
|
-
}
|
|
1629
|
-
console.error(`Setup failed: ${linkPath} collision (symlink to ${resolvedTarget})`);
|
|
1630
|
-
console.error(`[create-maxy:collision] ${linkPath} already exists target=${resolvedTarget}`);
|
|
1631
|
-
console.error(` This symlink was created by a different install (not within ${INSTALL_DIR}).`);
|
|
1632
|
-
console.error(` Remove or relocate it, then re-run: npx -y @rubytech/create-maxy`);
|
|
1633
|
-
process.exit(1);
|
|
1486
|
+
unlinkSync(linkPath);
|
|
1487
|
+
symlinkSync(targetAbs, linkPath);
|
|
1488
|
+
console.log(` [create-maxy] symlink replaced: ${linkPath} → ${targetAbs}`);
|
|
1489
|
+
logFile(` symlink replaced: ${linkPath} was ${resolvedTarget}, now ${targetAbs}`);
|
|
1634
1490
|
}
|
|
1635
1491
|
function installTunnelScripts() {
|
|
1636
1492
|
const setupSrc = join(INSTALL_DIR, "platform/plugins/cloudflare/scripts/setup-tunnel.sh");
|
|
@@ -2411,17 +2267,17 @@ else {
|
|
|
2411
2267
|
catch { /* non-critical */ }
|
|
2412
2268
|
}
|
|
2413
2269
|
// ---------------------------------------------------------------------------
|
|
2414
|
-
// Neo4j port —
|
|
2270
|
+
// Neo4j port — multi-brand data isolation.
|
|
2415
2271
|
//
|
|
2416
|
-
// Default: 7687 (shared system Neo4j instance).
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2272
|
+
// Default Maxy brand: 7687 (shared system Neo4j instance). Branded builds ship
|
|
2273
|
+
// a dedicated port in brand.json (e.g. Real Agent = 7688) so installing a
|
|
2274
|
+
// second brand on the same device gets its own database by default — Task 659.
|
|
2419
2275
|
//
|
|
2420
|
-
// Priority: --neo4j-port flag > .env NEO4J_URI
|
|
2276
|
+
// Priority: --neo4j-port flag > .env NEO4J_URI (upgrade preserve) > BRAND.neo4jPort > 7687.
|
|
2421
2277
|
// ---------------------------------------------------------------------------
|
|
2422
2278
|
const DEFAULT_NEO4J_PORT = 7687;
|
|
2423
|
-
let NEO4J_PORT = DEFAULT_NEO4J_PORT;
|
|
2424
|
-
let NEO4J_PORT_SOURCE = "default";
|
|
2279
|
+
let NEO4J_PORT = BRAND.neo4jPort ?? DEFAULT_NEO4J_PORT;
|
|
2280
|
+
let NEO4J_PORT_SOURCE = BRAND.neo4jPort ? "brand.json" : "default";
|
|
2425
2281
|
const neo4jPortIdx = _args.indexOf("--neo4j-port");
|
|
2426
2282
|
if (neo4jPortIdx !== -1) {
|
|
2427
2283
|
const raw = _args[neo4jPortIdx + 1];
|
|
@@ -2435,7 +2291,8 @@ if (neo4jPortIdx !== -1) {
|
|
|
2435
2291
|
NEO4J_PORT_SOURCE = "--neo4j-port flag";
|
|
2436
2292
|
}
|
|
2437
2293
|
else {
|
|
2438
|
-
// Upgrade detection: check .env for NEO4J_URI=bolt://localhost:{port}
|
|
2294
|
+
// Upgrade detection: check .env for NEO4J_URI=bolt://localhost:{port}.
|
|
2295
|
+
// Preserves an existing install's port even if brand.json now says different.
|
|
2439
2296
|
const persistDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
|
|
2440
2297
|
const envPath = join(persistDir, ".env");
|
|
2441
2298
|
try {
|
package/dist/uninstall.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync, execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync, cpSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, appendFileSync, cpSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { resolve, join, dirname } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
@@ -79,6 +79,25 @@ function commandExists(cmd) {
|
|
|
79
79
|
export function isMaxyInstalled() {
|
|
80
80
|
return existsSync(INSTALL_DIR);
|
|
81
81
|
}
|
|
82
|
+
/** Detect whether another brand is installed on this device.
|
|
83
|
+
* Task 659: device-wide steps (apt package purge, Ollama binary removal, apt
|
|
84
|
+
* repo cleanup, ~/.claude / ~/.ollama wipes) must skip when a peer brand is
|
|
85
|
+
* present — its runtime still depends on those singletons.
|
|
86
|
+
*
|
|
87
|
+
* Detection: any `.service` file in `~/.config/systemd/user/` whose name is
|
|
88
|
+
* not this brand's `BRAND.serviceName`. This mirrors the install-time check
|
|
89
|
+
* at index.ts:489 which uses the same signal for apt/hostname behavior. */
|
|
90
|
+
function peerBrandPresent() {
|
|
91
|
+
const systemdUserDir = resolve(HOME, ".config/systemd/user");
|
|
92
|
+
if (!existsSync(systemdUserDir))
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
return readdirSync(systemdUserDir).some((f) => f.endsWith(".service") && f !== BRAND.serviceName && !f.startsWith("maxy-edge") && !f.startsWith("maxy-ttyd"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
82
101
|
// ---------------------------------------------------------------------------
|
|
83
102
|
// Step 1: Stop services
|
|
84
103
|
// ---------------------------------------------------------------------------
|
|
@@ -101,21 +120,28 @@ function stopServices() {
|
|
|
101
120
|
catch {
|
|
102
121
|
console.log(" maxy-edge not running");
|
|
103
122
|
}
|
|
104
|
-
// Stop Neo4j
|
|
123
|
+
// Stop Neo4j — dedicated branded instance if this brand uses one, else shared.
|
|
124
|
+
// Brand isolation (Task 659): never stop `neo4j.service` when this brand runs
|
|
125
|
+
// a dedicated `neo4j-<hostname>.service` — stopping the shared instance would
|
|
126
|
+
// break every other brand on the device.
|
|
127
|
+
const neo4jPortForStop = readNeo4jPortFromEnv();
|
|
128
|
+
const neo4jService = neo4jPortForStop !== undefined && neo4jPortForStop !== 7687
|
|
129
|
+
? `neo4j-${BRAND.hostname}`
|
|
130
|
+
: "neo4j";
|
|
105
131
|
try {
|
|
106
|
-
spawnSync("sudo", ["systemctl", "stop",
|
|
107
|
-
console.log(
|
|
132
|
+
spawnSync("sudo", ["systemctl", "stop", neo4jService], { stdio: "pipe", timeout: 15_000 });
|
|
133
|
+
console.log(` Stopped ${neo4jService}`);
|
|
108
134
|
}
|
|
109
135
|
catch {
|
|
110
|
-
console.log(
|
|
136
|
+
console.log(` ${neo4jService} not running`);
|
|
111
137
|
}
|
|
112
|
-
// Kill cloudflared (read PID from tunnel.state —
|
|
138
|
+
// Kill cloudflared (read PID from this brand's tunnel.state only — Task 659).
|
|
139
|
+
// Never read ~/.cloudflared/ — that path is shared with every brand installed
|
|
140
|
+
// on this device plus cloudflared's own runtime-generated files.
|
|
113
141
|
const brandStateFile = join(CONFIG_DIR, "cloudflared/tunnel.state");
|
|
114
|
-
|
|
115
|
-
const stateFile = existsSync(brandStateFile) ? brandStateFile : legacyStateFile;
|
|
116
|
-
if (existsSync(stateFile)) {
|
|
142
|
+
if (existsSync(brandStateFile)) {
|
|
117
143
|
try {
|
|
118
|
-
const state = JSON.parse(readFileSync(
|
|
144
|
+
const state = JSON.parse(readFileSync(brandStateFile, "utf-8"));
|
|
119
145
|
if (state.pid) {
|
|
120
146
|
spawnSync("kill", [String(state.pid)], { stdio: "pipe" });
|
|
121
147
|
console.log(` Killed cloudflared (PID ${state.pid})`);
|
|
@@ -125,6 +151,14 @@ function stopServices() {
|
|
|
125
151
|
console.log(" cloudflared not running");
|
|
126
152
|
}
|
|
127
153
|
}
|
|
154
|
+
// Brand isolation (Task 659): the VNC stack and Ollama daemon are
|
|
155
|
+
// device-wide singletons. Killing them during uninstall would interrupt
|
|
156
|
+
// any peer brand still serving requests through them. Skip when a peer
|
|
157
|
+
// is present.
|
|
158
|
+
if (peerBrandPresent()) {
|
|
159
|
+
console.log(" Peer brand present — leaving VNC/Chromium and Ollama running.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
128
162
|
// Kill VNC processes (Xtigervnc, websockify, Chromium)
|
|
129
163
|
for (const proc of ["Xtigervnc", "websockify", "chromium"]) {
|
|
130
164
|
try {
|
|
@@ -153,21 +187,21 @@ function deleteCloudflareTunnel() {
|
|
|
153
187
|
console.log(" cloudflared not installed — skipping tunnel deletion.");
|
|
154
188
|
return;
|
|
155
189
|
}
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
190
|
+
// Read only this brand's state/cert (Task 659 — no cross-brand reads).
|
|
191
|
+
const stateFile = join(CONFIG_DIR, "cloudflared/tunnel.state");
|
|
192
|
+
const certFile = join(CONFIG_DIR, "cloudflared/cert.pem");
|
|
193
|
+
// Brand isolation (Task 659): read the tunnel id + domain from THIS brand's
|
|
194
|
+
// state file and scope `cloudflared` invocations to THIS brand's cert via
|
|
195
|
+
// --origincert. Without --origincert, cloudflared reads the device-wide
|
|
196
|
+
// ~/.cloudflared/cert.pem and would list (and delete) every tunnel in
|
|
197
|
+
// whichever Cloudflare account last logged in on this device.
|
|
198
|
+
let tunnelId;
|
|
164
199
|
let tunnelDomain;
|
|
165
|
-
let tunnelConfigPath;
|
|
166
200
|
if (existsSync(stateFile)) {
|
|
167
201
|
try {
|
|
168
202
|
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
203
|
+
tunnelId = state.tunnelId ?? state.id;
|
|
169
204
|
tunnelDomain = state.domain;
|
|
170
|
-
tunnelConfigPath = state.configPath;
|
|
171
205
|
}
|
|
172
206
|
catch { /* corrupt state file */ }
|
|
173
207
|
}
|
|
@@ -179,45 +213,24 @@ function deleteCloudflareTunnel() {
|
|
|
179
213
|
}
|
|
180
214
|
return;
|
|
181
215
|
}
|
|
182
|
-
|
|
183
|
-
|
|
216
|
+
if (!tunnelId) {
|
|
217
|
+
console.log(" tunnel.state has no tunnel id — skipping delete.");
|
|
218
|
+
console.log(" If a tunnel exists for this brand on Cloudflare's edge, delete it manually.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.log(` Deleting tunnel id ${tunnelId} (${tunnelDomain ?? "no domain"})...`);
|
|
222
|
+
const deleteResult = spawnSync("cloudflared", ["--origincert", certFile, "tunnel", "delete", "-f", tunnelId], {
|
|
184
223
|
stdio: "pipe",
|
|
185
224
|
encoding: "utf-8",
|
|
186
225
|
timeout: 30_000,
|
|
187
226
|
});
|
|
188
|
-
if (
|
|
189
|
-
console.log(
|
|
190
|
-
if (tunnelDomain) {
|
|
191
|
-
console.log(` The tunnel for ${tunnelDomain} may still exist.`);
|
|
192
|
-
console.log(" Delete it manually at https://one.dash.cloudflare.com → Networks → Tunnels");
|
|
193
|
-
}
|
|
194
|
-
return;
|
|
227
|
+
if (deleteResult.status === 0) {
|
|
228
|
+
console.log(` Deleted tunnel ${tunnelId} and its DNS records.`);
|
|
195
229
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
catch { /* malformed response */ }
|
|
201
|
-
if (tunnels.length === 0) {
|
|
202
|
-
console.log(" No tunnels found — nothing to delete.");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
// Delete each tunnel (typically just one)
|
|
206
|
-
for (const tunnel of tunnels) {
|
|
207
|
-
console.log(` Deleting tunnel: ${tunnel.name} (${tunnel.id})...`);
|
|
208
|
-
const deleteResult = spawnSync("cloudflared", ["tunnel", "delete", "-f", tunnel.id], {
|
|
209
|
-
stdio: "pipe",
|
|
210
|
-
encoding: "utf-8",
|
|
211
|
-
timeout: 30_000,
|
|
212
|
-
});
|
|
213
|
-
if (deleteResult.status === 0) {
|
|
214
|
-
console.log(` Deleted tunnel ${tunnel.name} and its DNS records.`);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
console.log(` Failed to delete tunnel ${tunnel.name}: ${(deleteResult.stderr || "").trim()}`);
|
|
218
|
-
if (tunnelDomain) {
|
|
219
|
-
console.log(` Delete it manually at https://one.dash.cloudflare.com → Networks → Tunnels`);
|
|
220
|
-
}
|
|
230
|
+
else {
|
|
231
|
+
console.log(` Failed to delete tunnel ${tunnelId}: ${(deleteResult.stderr || "").trim()}`);
|
|
232
|
+
if (tunnelDomain) {
|
|
233
|
+
console.log(` Delete it manually at https://one.dash.cloudflare.com → Networks → Tunnels`);
|
|
221
234
|
}
|
|
222
235
|
}
|
|
223
236
|
}
|
|
@@ -331,14 +344,24 @@ function removeTunnelSymlinks() {
|
|
|
331
344
|
// ---------------------------------------------------------------------------
|
|
332
345
|
function removeAppDirs() {
|
|
333
346
|
log("4", "Removing application directories...");
|
|
347
|
+
// Brand isolation (Task 659): ~/.cloudflare and ~/.cloudflared are
|
|
348
|
+
// device-wide singletons (shared across every brand and used by
|
|
349
|
+
// cloudflared's own runtime); per-brand tunnel state lives at
|
|
350
|
+
// ~/{configDir}/cloudflared/ and is removed via CONFIG_DIR. ~/.claude
|
|
351
|
+
// (Claude CLI OAuth state) and ~/.ollama (shared model cache) are also
|
|
352
|
+
// device-wide — skip both when a peer brand is still installed.
|
|
353
|
+
const peer = peerBrandPresent();
|
|
334
354
|
const dirs = [
|
|
335
355
|
{ path: INSTALL_DIR, label: `~/${BRAND.installDir}` },
|
|
336
356
|
{ path: CONFIG_DIR, label: `~/${BRAND.configDir}` },
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
357
|
+
...(peer ? [] : [
|
|
358
|
+
{ path: resolve(HOME, ".claude"), label: "~/.claude" },
|
|
359
|
+
{ path: resolve(HOME, ".ollama"), label: "~/.ollama" },
|
|
360
|
+
]),
|
|
341
361
|
];
|
|
362
|
+
if (peer) {
|
|
363
|
+
console.log(" Peer brand still installed — leaving ~/.claude and ~/.ollama in place.");
|
|
364
|
+
}
|
|
342
365
|
for (const { path, label } of dirs) {
|
|
343
366
|
if (existsSync(path)) {
|
|
344
367
|
try {
|
|
@@ -359,10 +382,25 @@ function removeAppDirs() {
|
|
|
359
382
|
// ---------------------------------------------------------------------------
|
|
360
383
|
function removeNeo4jData() {
|
|
361
384
|
log("5", "Removing Neo4j data...");
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
385
|
+
// Brand isolation (Task 659): remove only THIS brand's Neo4j data. Shared
|
|
386
|
+
// 7687 data is skipped entirely when a peer brand is present. Dedicated
|
|
387
|
+
// branded instances live at /var/lib/neo4j-<hostname>/ and are always
|
|
388
|
+
// this-brand-owned by construction.
|
|
389
|
+
const envPort = readNeo4jPortFromEnv();
|
|
390
|
+
const isDedicated = envPort !== undefined && envPort !== 7687;
|
|
391
|
+
const dataDir = isDedicated ? `/var/lib/neo4j-${BRAND.hostname}` : "/var/lib/neo4j";
|
|
392
|
+
if (!isDedicated && peerBrandPresent()) {
|
|
393
|
+
console.log(` Shared Neo4j instance on 7687 — peer brand present, skipping data wipe.`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const paths = [`${dataDir}/data`, `${dataDir}/logs`];
|
|
397
|
+
if (isDedicated) {
|
|
398
|
+
// Also clean up the dedicated config dir and systemd unit — install-time
|
|
399
|
+
// code creates both at /etc/neo4j-<hostname>/ and /etc/systemd/system/
|
|
400
|
+
// neo4j-<hostname>.service. Shared instance owns neither.
|
|
401
|
+
paths.push(`/etc/neo4j-${BRAND.hostname}`);
|
|
402
|
+
paths.push(`/etc/systemd/system/neo4j-${BRAND.hostname}.service`);
|
|
403
|
+
}
|
|
366
404
|
for (const p of paths) {
|
|
367
405
|
if (existsSync(p)) {
|
|
368
406
|
try {
|
|
@@ -374,6 +412,29 @@ function removeNeo4jData() {
|
|
|
374
412
|
}
|
|
375
413
|
}
|
|
376
414
|
}
|
|
415
|
+
if (isDedicated) {
|
|
416
|
+
try {
|
|
417
|
+
spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "pipe" });
|
|
418
|
+
}
|
|
419
|
+
catch { /* ignore */ }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Read NEO4J_URI port from this brand's .env. Returns undefined when the
|
|
423
|
+
* file is missing or malformed — caller treats that as "assume shared 7687". */
|
|
424
|
+
function readNeo4jPortFromEnv() {
|
|
425
|
+
const envPath = join(CONFIG_DIR, ".env");
|
|
426
|
+
if (!existsSync(envPath))
|
|
427
|
+
return undefined;
|
|
428
|
+
try {
|
|
429
|
+
const match = readFileSync(envPath, "utf-8").match(/^NEO4J_URI=bolt:\/\/localhost:(\d+)$/m);
|
|
430
|
+
if (!match)
|
|
431
|
+
return undefined;
|
|
432
|
+
const port = parseInt(match[1], 10);
|
|
433
|
+
return isNaN(port) ? undefined : port;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
377
438
|
}
|
|
378
439
|
// ---------------------------------------------------------------------------
|
|
379
440
|
// Step 6: Purge system packages
|
|
@@ -384,6 +445,15 @@ function purgeSystemPackages() {
|
|
|
384
445
|
console.log(" Not Linux — skipping package removal.");
|
|
385
446
|
return;
|
|
386
447
|
}
|
|
448
|
+
// Brand isolation (Task 659): every package in the list below is shared
|
|
449
|
+
// device-wide (neo4j, openjdk, tigervnc, websockify, novnc, cloudflared) —
|
|
450
|
+
// purging them when a peer brand is still installed breaks the peer's
|
|
451
|
+
// runtime. Leave packages in place; the operator can purge manually after
|
|
452
|
+
// uninstalling the last brand on the device.
|
|
453
|
+
if (peerBrandPresent()) {
|
|
454
|
+
console.log(" Peer brand still installed — skipping shared package purge.");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
387
457
|
const packages = [
|
|
388
458
|
"neo4j",
|
|
389
459
|
"openjdk-17-jre-headless",
|
|
@@ -426,12 +496,22 @@ function removeSystemConfig() {
|
|
|
426
496
|
console.log(" Not Linux — skipping.");
|
|
427
497
|
return;
|
|
428
498
|
}
|
|
499
|
+
// Brand isolation (Task 659): the Neo4j apt repo and GPG key are device-wide
|
|
500
|
+
// prerequisites for installing/updating Neo4j packages — leaving them when a
|
|
501
|
+
// peer brand is still installed is the correct posture. Avahi service and
|
|
502
|
+
// NetworkManager config are brand-specific (filenames keyed on BRAND.hostname).
|
|
503
|
+
const peerPresent = peerBrandPresent();
|
|
429
504
|
const files = [
|
|
430
505
|
{ path: `/etc/avahi/services/${BRAND.hostname}.service`, label: "Avahi mDNS service" },
|
|
431
506
|
{ path: `/etc/NetworkManager/conf.d/${BRAND.hostname}-no-powersave.conf`, label: "WiFi power save config" },
|
|
432
|
-
|
|
433
|
-
|
|
507
|
+
...(peerPresent ? [] : [
|
|
508
|
+
{ path: "/etc/apt/sources.list.d/neo4j.list", label: "Neo4j apt repository" },
|
|
509
|
+
{ path: "/usr/share/keyrings/neo4j.gpg", label: "Neo4j GPG key" },
|
|
510
|
+
]),
|
|
434
511
|
];
|
|
512
|
+
if (peerPresent) {
|
|
513
|
+
console.log(" Peer brand still installed — leaving Neo4j apt repo + GPG key in place.");
|
|
514
|
+
}
|
|
435
515
|
for (const { path, label } of files) {
|
|
436
516
|
if (existsSync(path)) {
|
|
437
517
|
try {
|
|
@@ -504,6 +584,14 @@ function removeSystemdService() {
|
|
|
504
584
|
// ---------------------------------------------------------------------------
|
|
505
585
|
function removeOllama() {
|
|
506
586
|
log("9", "Removing Ollama...");
|
|
587
|
+
// Brand isolation (Task 659): the Ollama binary and systemd service are
|
|
588
|
+
// device-wide singletons — every brand on this device shares one Ollama
|
|
589
|
+
// process. Leave both in place when a peer brand is still installed;
|
|
590
|
+
// uninstalling Ollama would break the peer brand's embedding pipeline.
|
|
591
|
+
if (peerBrandPresent()) {
|
|
592
|
+
console.log(" Peer brand still installed — leaving Ollama binary and service in place.");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
507
595
|
// Stop and disable the systemd service if it exists
|
|
508
596
|
try {
|
|
509
597
|
spawnSync("sudo", ["systemctl", "stop", "ollama"], { stdio: "pipe", timeout: 10_000 });
|
|
@@ -565,20 +653,32 @@ function restoreHostname() {
|
|
|
565
653
|
// Summary display
|
|
566
654
|
// ---------------------------------------------------------------------------
|
|
567
655
|
function showRemovalSummary() {
|
|
656
|
+
const peer = peerBrandPresent();
|
|
568
657
|
console.log("");
|
|
569
658
|
console.log("The following will be removed:");
|
|
570
659
|
console.log("");
|
|
571
|
-
console.log(` Services: ${BRAND.serviceName}, Neo4j, cloudflared, VNC, Ollama`);
|
|
572
|
-
console.log(` App dirs: ~/${BRAND.installDir}/, ~/${BRAND.configDir}
|
|
573
|
-
console.log(
|
|
574
|
-
|
|
575
|
-
|
|
660
|
+
console.log(` Services: ${BRAND.serviceName}${peer ? "" : ", Neo4j, cloudflared, VNC, Ollama"}`);
|
|
661
|
+
console.log(` App dirs: ~/${BRAND.installDir}/, ~/${BRAND.configDir}/${peer ? "" : ", ~/.claude/, ~/.ollama/"}`);
|
|
662
|
+
console.log(` Database: Neo4j data + logs for ${BRAND.productName}'s instance`);
|
|
663
|
+
if (!peer) {
|
|
664
|
+
console.log(" Packages: neo4j, openjdk (17 or 21), tigervnc, websockify, novnc, cloudflared");
|
|
665
|
+
console.log(" Config: avahi service, WiFi power save, apt repos, GPG keys");
|
|
666
|
+
console.log(" Ollama: binary and models");
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
console.log(" Config: avahi service, WiFi power save (apt repo + GPG key left in place)");
|
|
670
|
+
}
|
|
576
671
|
console.log(` Systemd: ${BRAND.serviceName} unit file, user lingering`);
|
|
577
|
-
console.log(" Ollama: binary and models");
|
|
578
672
|
console.log(` Hostname: restored from '${BRAND.hostname}' to 'raspberrypi'`);
|
|
579
|
-
console.log(
|
|
673
|
+
console.log(` Cloudflare: ${BRAND.productName}'s tunnel and DNS records deleted from Cloudflare's edge`);
|
|
580
674
|
console.log("");
|
|
581
|
-
|
|
675
|
+
if (peer) {
|
|
676
|
+
console.log(" Peer brand still installed — device-wide singletons (Ollama, cloudflared");
|
|
677
|
+
console.log(" binary, apt packages, ~/.claude, ~/.ollama) are left in place.");
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
console.log(" Node.js and shared system utilities (curl, git, etc.) are NOT removed.");
|
|
681
|
+
}
|
|
582
682
|
console.log("");
|
|
583
683
|
}
|
|
584
684
|
// ---------------------------------------------------------------------------
|