@rubytech/create-maxy 1.0.745 → 1.0.747

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ // Task 800 — acceptance grid for findPeerBrandOnDefaultNeo4jPort.
2
+ //
3
+ // Each test exercises one branch of the matching rule. The wrapper in
4
+ // index.ts owns fs reads + warning emission; this suite does not touch fs.
5
+ //
6
+ // Runs via Node's built-in test runner (matches apt-resolve.test.ts +
7
+ // port-canonicalisation.test.ts). The brief asked for vitest, but the
8
+ // package has no vitest dependency — node:test is the codebase convention
9
+ // and ships with the toolchain.
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { findPeerBrandOnDefaultNeo4jPort } from "../peer-brand-detect.js";
13
+ const DEFAULT_PORT = 7687;
14
+ const CURRENT = "realagent"; // installer is realagent; peer would be maxy
15
+ test("no peer hostnames → null", () => {
16
+ const result = findPeerBrandOnDefaultNeo4jPort({
17
+ currentBrandHostname: CURRENT,
18
+ defaultNeo4jPort: DEFAULT_PORT,
19
+ peerEnvContents: [],
20
+ });
21
+ assert.equal(result, null);
22
+ });
23
+ test("peer with content=null (no .env on disk) → null", () => {
24
+ const result = findPeerBrandOnDefaultNeo4jPort({
25
+ currentBrandHostname: CURRENT,
26
+ defaultNeo4jPort: DEFAULT_PORT,
27
+ peerEnvContents: [["maxy", null]],
28
+ });
29
+ assert.equal(result, null);
30
+ });
31
+ test("peer pins NEO4J_URI=bolt://localhost:7687 → returns peer hostname", () => {
32
+ const env = `EMBED_MODEL=nomic-embed-text\nNEO4J_URI=bolt://localhost:7687\nACCOUNT_ID=abc\n`;
33
+ const result = findPeerBrandOnDefaultNeo4jPort({
34
+ currentBrandHostname: CURRENT,
35
+ defaultNeo4jPort: DEFAULT_PORT,
36
+ peerEnvContents: [["maxy", env]],
37
+ });
38
+ assert.equal(result, "maxy");
39
+ });
40
+ test("peer pins dedicated port 7688 (not the system unit) → null", () => {
41
+ const env = `NEO4J_URI=bolt://localhost:7688\n`;
42
+ const result = findPeerBrandOnDefaultNeo4jPort({
43
+ currentBrandHostname: CURRENT,
44
+ defaultNeo4jPort: DEFAULT_PORT,
45
+ peerEnvContents: [["maxy", env]],
46
+ });
47
+ assert.equal(result, null);
48
+ });
49
+ test("peer .env has no NEO4J_URI line → null", () => {
50
+ const env = `EMBED_MODEL=nomic-embed-text\nDISPLAY_MODE=hd\n`;
51
+ const result = findPeerBrandOnDefaultNeo4jPort({
52
+ currentBrandHostname: CURRENT,
53
+ defaultNeo4jPort: DEFAULT_PORT,
54
+ peerEnvContents: [["maxy", env]],
55
+ });
56
+ assert.equal(result, null);
57
+ });
58
+ test("peer .env is empty → null", () => {
59
+ const result = findPeerBrandOnDefaultNeo4jPort({
60
+ currentBrandHostname: CURRENT,
61
+ defaultNeo4jPort: DEFAULT_PORT,
62
+ peerEnvContents: [["maxy", ""]],
63
+ });
64
+ assert.equal(result, null);
65
+ });
66
+ test("multiple peers, second one matches → returns the matching hostname", () => {
67
+ const result = findPeerBrandOnDefaultNeo4jPort({
68
+ currentBrandHostname: CURRENT,
69
+ defaultNeo4jPort: DEFAULT_PORT,
70
+ peerEnvContents: [
71
+ ["future-brand", `NEO4J_URI=bolt://localhost:7689\n`],
72
+ ["maxy", `NEO4J_URI=bolt://localhost:7687\n`],
73
+ ],
74
+ });
75
+ assert.equal(result, "maxy");
76
+ });
77
+ test("only the current brand in iterable → filtered, returns null", () => {
78
+ // Caller may pass the full known-brand list; the helper filters self.
79
+ const result = findPeerBrandOnDefaultNeo4jPort({
80
+ currentBrandHostname: CURRENT,
81
+ defaultNeo4jPort: DEFAULT_PORT,
82
+ peerEnvContents: [[CURRENT, `NEO4J_URI=bolt://localhost:7687\n`]],
83
+ });
84
+ assert.equal(result, null);
85
+ });
86
+ test("URI with non-bolt scheme is not a match", () => {
87
+ const env = `NEO4J_URI=neo4j://localhost:7687\n`;
88
+ const result = findPeerBrandOnDefaultNeo4jPort({
89
+ currentBrandHostname: CURRENT,
90
+ defaultNeo4jPort: DEFAULT_PORT,
91
+ peerEnvContents: [["maxy", env]],
92
+ });
93
+ assert.equal(result, null);
94
+ });
95
+ test("URI with non-localhost host is not a match", () => {
96
+ const env = `NEO4J_URI=bolt://neo4j.example.com:7687\n`;
97
+ const result = findPeerBrandOnDefaultNeo4jPort({
98
+ currentBrandHostname: CURRENT,
99
+ defaultNeo4jPort: DEFAULT_PORT,
100
+ peerEnvContents: [["maxy", env]],
101
+ });
102
+ assert.equal(result, null);
103
+ });
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { resolve, join, dirname } from "node:path";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { resolveInstallPortFromFs, buildMaxyUnitFile } from "./port-resolution.js";
7
7
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
8
+ import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
8
9
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
9
10
  // Brand manifest — read from payload to derive all brand-specific installation values.
10
11
  // The bundler stamps brand.json into the payload at build time.
@@ -841,6 +842,48 @@ function installNeo4j() {
841
842
  shell("systemctl", ["start", "neo4j"], { sudo: true });
842
843
  console.log(" Neo4j started. Password stored securely.");
843
844
  }
845
+ /**
846
+ * Task 800 — does any peer brand on this host pin `NEO4J_URI=bolt://localhost:<default>`
847
+ * in its `.env`? If yes, the apt-installed `neo4j.service` is its database and the
848
+ * dedicated-unit installer must NOT stop+disable it. Returns the first matching
849
+ * peer's hostname, or `null` when no peer pins the default port.
850
+ *
851
+ * Wraps the pure decision in `peer-brand-detect.ts` with the fs reads of
852
+ * `~/.<peer>/.env`. Bias on read errors: if `.env` exists but is unreadable
853
+ * (permissions, transient I/O), the wrapper treats that peer as a *potential*
854
+ * dependency and short-circuits to the kept-active path. Disabling the system
855
+ * unit on faulty evidence would silently kill the peer's database (the exact
856
+ * failure Task 800 prevents); a conservatively-skipped disable is recoverable
857
+ * because the dedicated-unit bind check at the end of `setupDedicatedNeo4j`
858
+ * fails loud if the system unit is actually free.
859
+ */
860
+ function peerBrandUsingSystemUnit() {
861
+ const home = process.env.HOME ?? "/root";
862
+ const peerEnvContents = [];
863
+ for (const hostname of KNOWN_BRAND_HOSTNAMES) {
864
+ if (hostname === BRAND.hostname) {
865
+ peerEnvContents.push([hostname, null]);
866
+ continue;
867
+ }
868
+ const envPath = resolve(home, `.${hostname}`, ".env");
869
+ if (!existsSync(envPath)) {
870
+ peerEnvContents.push([hostname, null]);
871
+ continue;
872
+ }
873
+ try {
874
+ peerEnvContents.push([hostname, readFileSync(envPath, "utf-8")]);
875
+ }
876
+ catch (err) {
877
+ console.error(` WARNING: unable to read peer brand .env at ${envPath} — treating as potential dependency to avoid data loss (Task 800): ${err instanceof Error ? err.message : String(err)}`);
878
+ return hostname;
879
+ }
880
+ }
881
+ return findPeerBrandOnDefaultNeo4jPort({
882
+ currentBrandHostname: BRAND.hostname,
883
+ defaultNeo4jPort: DEFAULT_NEO4J_PORT,
884
+ peerEnvContents,
885
+ });
886
+ }
844
887
  /**
845
888
  * Create a dedicated Neo4j instance for this brand when NEO4J_DEDICATED is true.
846
889
  * Produces: separate config dir, data dir, log dir, systemd service, and password.
@@ -849,6 +892,10 @@ function installNeo4j() {
849
892
  * dedicated, start, verify) so a half-installed Pi recovers in-place without
850
893
  * manual systemctl. ensureNeo4jPassword() handles password verification on the
851
894
  * recovery path.
895
+ *
896
+ * Task 800: on multi-brand hosts where a peer brand still depends on the apt
897
+ * `neo4j.service` (port 7687), the stop+disable step is skipped — disabling
898
+ * the system unit would kill the peer's database.
852
899
  */
853
900
  function setupDedicatedNeo4j() {
854
901
  if (!NEO4J_DEDICATED)
@@ -952,10 +999,22 @@ WantedBy=multi-user.target
952
999
  spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
953
1000
  console.log(" [privileged] systemctl enable");
954
1001
  shell("systemctl", ["enable", serviceName], { sudo: true });
955
- console.log(` [neo4j] disabling system unit (brand-dedicated active on port ${NEO4J_PORT})`);
956
- logFile(` [neo4j] disabling system unit (brand-dedicated active on port ${NEO4J_PORT})`);
957
- shell("systemctl", ["stop", "neo4j"], { sudo: true, bestEffort: true });
958
- shell("systemctl", ["disable", "neo4j"], { sudo: true, bestEffort: true });
1002
+ // Task 800: skip stop+disable when a peer brand on this host still depends
1003
+ // on the apt `neo4j.service` (port 7687). Disabling it would kill the peer's
1004
+ // database Task 797 reproducer on Neo's laptop. The kept-active path is
1005
+ // mutually exclusive with the disable path: exactly one log line per install.
1006
+ const peerOnSystemUnit = peerBrandUsingSystemUnit();
1007
+ if (peerOnSystemUnit !== null) {
1008
+ const keptActiveMsg = ` [neo4j] system unit kept active — peer brand ${peerOnSystemUnit} depends on port ${DEFAULT_NEO4J_PORT} (Task 800)`;
1009
+ console.log(keptActiveMsg);
1010
+ logFile(keptActiveMsg);
1011
+ }
1012
+ else {
1013
+ console.log(` [neo4j] disabling system unit (brand-dedicated active on port ${NEO4J_PORT})`);
1014
+ logFile(` [neo4j] disabling system unit (brand-dedicated active on port ${NEO4J_PORT})`);
1015
+ shell("systemctl", ["stop", "neo4j"], { sudo: true });
1016
+ shell("systemctl", ["disable", "neo4j"], { sudo: true });
1017
+ }
959
1018
  console.log(` [neo4j] reset-failed ${serviceName} before start`);
960
1019
  logFile(` [neo4j] reset-failed ${serviceName} before start`);
961
1020
  shell("systemctl", ["reset-failed", serviceName], { sudo: true, bestEffort: true });
@@ -0,0 +1,39 @@
1
+ // Task 800 — pure peer-brand detection. Extracted from index.ts so the
2
+ // "should we leave the system neo4j.service alone?" decision can be unit-
3
+ // tested with concrete `.env` fixtures, no fs reads. Mirrors the apt-resolve
4
+ // / port-resolution pattern: inputs in, decision out, no I/O.
5
+ //
6
+ // The installer's `setupDedicatedNeo4j()` wraps this with the fs reads of
7
+ // `~/.<peer>/.env` and the `console.error` warning on read failure. This
8
+ // module owns only the parsing + matching rule.
9
+ /**
10
+ * Decide whether any peer brand on the same host depends on the apt-installed
11
+ * `neo4j.service` (port `defaultNeo4jPort`, conventionally 7687). Returns the
12
+ * first matching peer brand's hostname, or `null` if no peer pins that port.
13
+ *
14
+ * `peerEnvContents` is an iterable of `[hostname, envContent | null]` pairs.
15
+ * `null` content means the peer's `.env` is absent or unreadable — treated
16
+ * the same as "no evidence of dependency". The wrapper logs the read failure;
17
+ * this module stays decision-only.
18
+ *
19
+ * Matching rule: a peer pins the default port iff its `.env` contains a line
20
+ * matching `NEO4J_URI=bolt://localhost:<defaultNeo4jPort>` exactly. Any other
21
+ * scheme, host, or port → not a match. Self-references (peer hostname equal
22
+ * to the current install's hostname) are silently skipped so the caller can
23
+ * pass the full known-brand list without filtering first.
24
+ */
25
+ export function findPeerBrandOnDefaultNeo4jPort(args) {
26
+ const { currentBrandHostname, defaultNeo4jPort, peerEnvContents } = args;
27
+ const expected = `bolt://localhost:${defaultNeo4jPort}`;
28
+ for (const [hostname, content] of peerEnvContents) {
29
+ if (hostname === currentBrandHostname)
30
+ continue;
31
+ if (content === null)
32
+ continue;
33
+ const match = content.match(/^NEO4J_URI=(.+)$/m);
34
+ if (match && match[1].trim() === expected) {
35
+ return hostname;
36
+ }
37
+ }
38
+ return null;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.745",
3
+ "version": "1.0.747",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -848,3 +848,43 @@ FOR (c:Credential) REQUIRE (c.accountId, c.name, c.authority) IS UNIQUE;
848
848
 
849
849
  CREATE INDEX credential_account IF NOT EXISTS
850
850
  FOR (c:Credential) ON (c.accountId);
851
+
852
+ // ----------------------------------------------------------
853
+ // Provenance indexes (Task 800) — back the shim's per-write
854
+ // orphan check. Task 797's `executeWrite` runs an unanchored
855
+ // `MATCH (n) WHERE n.createdBySession = $autoSession AND
856
+ // n.createdAt >= $autoStartTimestamp AND NOT (n)--()` after
857
+ // every operator write. Without an index this is `AllNodesScan`
858
+ // — sub-millisecond on the current ~10K-node graph, but a
859
+ // per-write tax that grows with graph size.
860
+ //
861
+ // Neo4j 5 property indexes require a label clause; there is no
862
+ // label-free range/text/point index. The orphan check is
863
+ // unanchored, so the planner relies on `UnionNodeByLabelsScan`
864
+ // (Neo4j 5.6+) to combine per-label seeks. If a future EXPLAIN
865
+ // shows `AllNodesScan` despite these indexes, the orphan-check
866
+ // Cypher in `cypher-shim-write.ts` should be anchored with an
867
+ // explicit label union — that's the structural fallback.
868
+ //
869
+ // Coverage: the five labels written most often through the raw
870
+ // Cypher write path (Person, Organization, KnowledgeDocument,
871
+ // Section, Task). Other written labels (Project, Position,
872
+ // Credential, ToolCall, Conversation, etc.) still hit
873
+ // `AllNodesScan` for the orphan check; extending coverage is a
874
+ // separate decision, not a default.
875
+ // ----------------------------------------------------------
876
+
877
+ CREATE INDEX person_created_by_session IF NOT EXISTS
878
+ FOR (n:Person) ON (n.createdBySession);
879
+
880
+ CREATE INDEX organization_created_by_session IF NOT EXISTS
881
+ FOR (n:Organization) ON (n.createdBySession);
882
+
883
+ CREATE INDEX knowledge_doc_created_by_session IF NOT EXISTS
884
+ FOR (n:KnowledgeDocument) ON (n.createdBySession);
885
+
886
+ CREATE INDEX section_created_by_session IF NOT EXISTS
887
+ FOR (n:Section) ON (n.createdBySession);
888
+
889
+ CREATE INDEX task_created_by_session IF NOT EXISTS
890
+ FOR (n:Task) ON (n.createdBySession);
@@ -101,7 +101,7 @@ A single Pi or laptop can host more than one brand (for example Maxy and Real Ag
101
101
 
102
102
  - **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Maxy on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
103
103
  - **Brand-isolated Neo4j (Task 787):** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
104
- - **Multi-brand-host caveat (Task 800):** on a host where the default brand keeps using the shared system `neo4j.service` per Task 659 V3 (e.g. a laptop running Maxy on default port 7687 alongside a branded build on a dedicated port), the Task 787 disable above kills the default brand's database when a branded installer runs. Symptom: default-brand admin server keeps running but bolt port 7687 has no listener; the branded install log contains `[neo4j] disabling system unit (brand-dedicated active on port <branded-port>)` immediately followed by `Stopping neo4j.service` in the systemd journal. Data on disk is intact (`disable` only removes the WantedBy symlink); recovery is `sudo systemctl enable neo4j && sudo systemctl start neo4j`. Task 800 adds a peer-brand guard so the disable is skipped when any `~/.<brand>/.env` pins `NEO4J_URI=bolt://localhost:7687`. Until Task 800 lands, repeat the recovery after each branded `create-{brand}` upgrade on a multi-brand host.
104
+ - **Peer-aware system-unit guard (Task 800):** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active peer brand <name> depends on port 7687 (Task 800)` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the Task 797 reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged from Task 787.
105
105
  - **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
106
106
 
107
107
  To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation: