@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 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. Checking other brands...");
729
+ console.log(" Stored password doesn't match Neo4j. Resetting auth.");
730
730
  }
731
731
  }
732
- // 2. Cross-brand check: only for shared instances. Dedicated instances have
733
- // independent auth another brand's password is irrelevant.
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
- // Migrate secrets from ~/.maxy/ to brand-specific config dir.
1179
- // Pre-fix code (before Task 262) hardcoded ~/.maxy/ for all brands.
1180
- // On branded builds (configDir != .maxy), secrets may exist at ~/.maxy/ but not
1181
- // at the brand-specific location. Copy them over so they survive the path fix.
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 → different path inside INSTALL_DIR replace (idempotent upgrade)
1561
- // - symlink → dangling → repair + log
1562
- // - symlink → live target outside INSTALL_DIR → exit 1 (cross-brand collision)
1563
- // - regular file → exit 1
1564
- // - unreadable symlink → exit 1
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
- let resolvedTarget;
1593
- try {
1594
- const raw = readlinkSync(linkPath);
1595
- resolvedTarget = resolve(dirname(linkPath), raw);
1596
- }
1597
- catch (err) {
1598
- console.error(`Setup failed: ${linkPath} symlink unreadable: ${err.message}`);
1599
- console.error(`[create-maxy:collision] ${linkPath} symlink unreadable: ${err.message}`);
1600
- console.error(` Remove the file and re-run: npx -y @rubytech/create-maxy`);
1601
- process.exit(1);
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
- const insideInstallDir = resolvedTarget === INSTALL_DIR || resolvedTarget.startsWith(INSTALL_DIR + "/");
1609
- let targetExists = false;
1610
- try {
1611
- lstatSync(resolvedTarget);
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 — install-time flag for multi-brand data isolation.
2270
+ // Neo4j port — multi-brand data isolation.
2415
2271
  //
2416
- // Default: 7687 (shared system Neo4j instance). When a different port is
2417
- // specified, the installer creates a dedicated Neo4j instance for this brand
2418
- // with its own config, data directory, systemd service, and password.
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 detection (upgrade) > default 7687.
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", "neo4j"], { stdio: "pipe", timeout: 15_000 });
107
- console.log(" Stopped Neo4j");
132
+ spawnSync("sudo", ["systemctl", "stop", neo4jService], { stdio: "pipe", timeout: 15_000 });
133
+ console.log(` Stopped ${neo4jService}`);
108
134
  }
109
135
  catch {
110
- console.log(" Neo4j not running");
136
+ console.log(` ${neo4jService} not running`);
111
137
  }
112
- // Kill cloudflared (read PID from tunnel.state — check brand path first, legacy fallback)
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
- const legacyStateFile = resolve(HOME, ".cloudflared/tunnel.state");
115
- const stateFile = existsSync(brandStateFile) ? brandStateFile : legacyStateFile;
116
- if (existsSync(stateFile)) {
142
+ if (existsSync(brandStateFile)) {
117
143
  try {
118
- const state = JSON.parse(readFileSync(stateFile, "utf-8"));
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
- // Check brand path first, legacy fallback post-Task-441 state lives under ~/{configDir}/cloudflared/
157
- const brandState = join(CONFIG_DIR, "cloudflared/tunnel.state");
158
- const legacyState = resolve(HOME, ".cloudflared/tunnel.state");
159
- const stateFile = existsSync(brandState) ? brandState : legacyState;
160
- const brandCert = join(CONFIG_DIR, "cloudflared/cert.pem");
161
- const legacyCert = resolve(HOME, ".cloudflared/cert.pem");
162
- const certFile = existsSync(brandCert) ? brandCert : legacyCert;
163
- // Read tunnel info for actionable error messages
190
+ // Read only this brand's state/cert (Task 659no 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
- // List tunnels to find the one to delete
183
- const listResult = spawnSync("cloudflared", ["tunnel", "list", "--output", "json"], {
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 (listResult.status !== 0) {
189
- console.log(" Could not list tunnels network may be unreachable.");
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
- let tunnels = [];
197
- try {
198
- tunnels = JSON.parse(listResult.stdout || "[]");
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
- { path: resolve(HOME, ".cloudflare"), label: "~/.cloudflare" },
338
- { path: resolve(HOME, ".cloudflared"), label: "~/.cloudflared" },
339
- { path: resolve(HOME, ".claude"), label: "~/.claude" },
340
- { path: resolve(HOME, ".ollama"), label: "~/.ollama" },
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
- const paths = [
363
- "/var/lib/neo4j/data",
364
- "/var/lib/neo4j/logs",
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
- { path: "/etc/apt/sources.list.d/neo4j.list", label: "Neo4j apt repository" },
433
- { path: "/usr/share/keyrings/neo4j.gpg", label: "Neo4j GPG key" },
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}/, ~/.claude/, ~/.cloudflare/, ~/.cloudflared/, ~/.ollama/`);
573
- console.log(" Database: Neo4j data and transaction logs");
574
- console.log(" Packages: neo4j, openjdk (17 or 21), tigervnc, websockify, novnc, cloudflared");
575
- console.log(" Config: avahi service, WiFi power save, apt repos, GPG keys");
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(" Cloudflare: tunnel and DNS records deleted from Cloudflare's edge");
673
+ console.log(` Cloudflare: ${BRAND.productName}'s tunnel and DNS records deleted from Cloudflare's edge`);
580
674
  console.log("");
581
- console.log(" Node.js and shared system utilities (curl, git, etc.) are NOT removed.");
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
  // ---------------------------------------------------------------------------