@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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
@@ -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
|
-
- **
|
|
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:
|