@rubytech/create-realagent 1.0.627 → 1.0.630

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.
Files changed (27) hide show
  1. package/dist/index.js +34 -0
  2. package/package.json +1 -1
  3. package/payload/platform/lib/graph-mcp/dist/index.d.ts +26 -0
  4. package/payload/platform/lib/graph-mcp/dist/index.d.ts.map +1 -0
  5. package/payload/platform/lib/graph-mcp/dist/index.js +193 -0
  6. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -0
  7. package/payload/platform/lib/graph-mcp/src/index.ts +225 -0
  8. package/payload/platform/lib/graph-mcp/tsconfig.json +8 -0
  9. package/payload/platform/package.json +2 -2
  10. package/payload/platform/plugins/cloudflare/scripts/_stream-log.sh +124 -0
  11. package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +45 -3
  12. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +151 -10
  13. package/payload/platform/plugins/docs/references/memory-guide.md +8 -0
  14. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  15. package/payload/platform/plugins/memory/mcp/scripts/graph/accept.sh +129 -0
  16. package/payload/platform/plugins/memory/mcp/scripts/graph/fixture.cypher +59 -0
  17. package/payload/platform/plugins/memory/references/graph-primitives.md +195 -0
  18. package/payload/server/public/assets/admin-DirN63aF.js +352 -0
  19. package/payload/server/public/assets/public-Cizdj15i.js +5 -0
  20. package/payload/server/public/assets/useVoiceRecorder-DIV9KAk_.css +1 -0
  21. package/payload/server/public/assets/{useVoiceRecorder-CiYPZu3g.js → useVoiceRecorder-tbj4tUsl.js} +1 -1
  22. package/payload/server/public/index.html +3 -3
  23. package/payload/server/public/public.html +3 -3
  24. package/payload/server/server.js +481 -102
  25. package/payload/server/public/assets/admin-BxVuKRJZ.js +0 -352
  26. package/payload/server/public/assets/public-Bgm9WQFZ.js +0 -5
  27. package/payload/server/public/assets/useVoiceRecorder-BORuG_su.css +0 -1
package/dist/index.js CHANGED
@@ -813,6 +813,39 @@ function installOllama(embedModel) {
813
813
  logFile(` Pulling embedding model: ${embedModel}`);
814
814
  shellRetry("ollama", ["pull", embedModel], { timeout: 600_000 }, 3, 15);
815
815
  }
816
+ // Task 557 — uv/uvx bootstrap. Required by the `graph` MCP server which
817
+ // spawns `uvx mcp-neo4j-cypher@0.6.0 --transport stdio`. Idempotent: when
818
+ // uvx is already on PATH (or under $HOME/.local/bin) we skip. Non-fatal on
819
+ // failure — the installer continues; the graph server will loudly fail
820
+ // at session start with a clear "uvx not found" error, which is retriable
821
+ // via a second installer run with network access.
822
+ function installUv() {
823
+ if (commandExists("uvx")) {
824
+ logFile(" uv: already installed");
825
+ console.log(" uv/uvx already installed.");
826
+ return;
827
+ }
828
+ console.log(" Installing uv (Python tool runner — required by Neo4j MCP server)...");
829
+ logFile(" uv: installing via astral.sh installer");
830
+ const result = spawnSync("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y"], { stdio: "inherit" });
831
+ if (result.status !== 0) {
832
+ console.error(` WARNING: uv install exited ${result.status} — graph MCP server will fail at session start until this is retried`);
833
+ logFile(` WARNING: uv install failed with status ${result.status}`);
834
+ return;
835
+ }
836
+ // The installer writes uvx to $HOME/.local/bin — add it to PATH for the
837
+ // remainder of this install so commandExists("uvx") works downstream.
838
+ const localBin = `${process.env.HOME}/.local/bin`;
839
+ if (process.env.PATH && !process.env.PATH.includes(localBin)) {
840
+ process.env.PATH = `${localBin}:${process.env.PATH}`;
841
+ }
842
+ if (commandExists("uvx")) {
843
+ logFile(" uv: install succeeded; uvx on PATH");
844
+ }
845
+ else {
846
+ console.error(" WARNING: uv installed but uvx not on PATH — check $HOME/.local/bin");
847
+ }
848
+ }
816
849
  function installCloudflared() {
817
850
  if (commandExists("cloudflared")) {
818
851
  log("6", TOTAL, "Cloudflared already installed.");
@@ -1937,6 +1970,7 @@ try {
1937
1970
  installNeo4j();
1938
1971
  setupDedicatedNeo4j();
1939
1972
  installOllama(EMBED_MODEL);
1973
+ installUv();
1940
1974
  installCloudflared();
1941
1975
  installWhisperCpp();
1942
1976
  deployPayload(); // Must happen before ensureNeo4jPassword — restores config backup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.627",
3
+ "version": "1.0.630",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
4
+ *
5
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
6
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
7
+ * stdio stream byte-for-byte. While proxying, it:
8
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
9
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
10
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
11
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
12
+ * per-conversation stream log alongside every other plugin.
13
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
14
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
15
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
16
+ * start time per `id`, and on the response path to emit one
17
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
18
+ * line per tool call. Forwarding is byte-accurate — the parser
19
+ * observes, never mutates.
20
+ *
21
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
22
+ * each brand already owns its own Neo4j instance on its own port with its
23
+ * own password. This shim trusts that — no query-layer tenant filtering.
24
+ */
25
+ export {};
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;GAsBG"}
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
5
+ *
6
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
7
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
8
+ * stdio stream byte-for-byte. While proxying, it:
9
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
10
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
11
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
12
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
13
+ * per-conversation stream log alongside every other plugin.
14
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
15
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
16
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
17
+ * start time per `id`, and on the response path to emit one
18
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
19
+ * line per tool call. Forwarding is byte-accurate — the parser
20
+ * observes, never mutates.
21
+ *
22
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
23
+ * each brand already owns its own Neo4j instance on its own port with its
24
+ * own password. This shim trusts that — no query-layer tenant filtering.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ const node_child_process_1 = require("node:child_process");
28
+ const node_fs_1 = require("node:fs");
29
+ const node_path_1 = require("node:path");
30
+ const node_string_decoder_1 = require("node:string_decoder");
31
+ const index_js_1 = require("../../mcp-stderr-tee/dist/index.js");
32
+ const SERVER_NAME = "graph";
33
+ const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
34
+ (0, index_js_1.initStderrTee)(SERVER_NAME);
35
+ function resolvePassword() {
36
+ if (process.env.NEO4J_PASSWORD)
37
+ return process.env.NEO4J_PASSWORD;
38
+ // __dirname points at `lib/graph-mcp/dist` after compilation; the platform
39
+ // root is three levels up (lib/graph-mcp/dist -> lib/graph-mcp -> lib -> platform).
40
+ const root = process.env.PLATFORM_ROOT ?? (0, node_path_1.resolve)(__dirname, "../../..");
41
+ const file = (0, node_path_1.resolve)(root, "config/.neo4j-password");
42
+ try {
43
+ return (0, node_fs_1.readFileSync)(file, "utf-8").trim();
44
+ }
45
+ catch {
46
+ console.error(`[graph-mcp] password unavailable — file=${file} env=NEO4J_PASSWORD (neither set)`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ const brand = process.env.BRAND ?? "maxy";
51
+ const neo4jUri = process.env.NEO4J_URI ?? "bolt://localhost:7687";
52
+ const neo4jUser = process.env.NEO4J_USERNAME ?? process.env.NEO4J_USER ?? "neo4j";
53
+ const neo4jPassword = resolvePassword();
54
+ const portMatch = /:(\d+)$/.exec(neo4jUri);
55
+ const neo4jPort = portMatch ? portMatch[1] : "?";
56
+ const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
57
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
58
+ const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
59
+ const childEnv = {
60
+ ...process.env,
61
+ NEO4J_URI: neo4jUri,
62
+ NEO4J_URL: neo4jUri,
63
+ NEO4J_USERNAME: neo4jUser,
64
+ NEO4J_PASSWORD: neo4jPassword,
65
+ NEO4J_READ_ONLY: readOnly,
66
+ NEO4J_NAMESPACE: namespace,
67
+ NEO4J_RESPONSE_TOKEN_LIMIT: responseTokenLimit,
68
+ };
69
+ console.error(`[graph-mcp] boot brand=${brand} uri=${neo4jUri} user=${neo4jUser} ` +
70
+ `namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`);
71
+ const child = (0, node_child_process_1.spawn)("uvx", [UPSTREAM_PACKAGE, "--transport", "stdio"], {
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ env: childEnv,
74
+ });
75
+ child.on("spawn", () => {
76
+ console.error(`[graph-mcp] spawned ${UPSTREAM_PACKAGE} pid=${child.pid}`);
77
+ });
78
+ child.on("error", (err) => {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ console.error(`[graph-mcp] spawn error: ${msg}`);
81
+ if (err.code === "ENOENT") {
82
+ console.error("[graph-mcp] uvx not found on PATH — install uv: " +
83
+ "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y");
84
+ }
85
+ process.exit(127);
86
+ });
87
+ child.on("exit", (code, signal) => {
88
+ console.error(`[graph-mcp] uvx exited code=${code ?? "null"} signal=${signal ?? "null"}`);
89
+ process.exit(code ?? (signal ? 1 : 0));
90
+ });
91
+ // Forward child stderr to our own stderr so the stderr tee picks it up.
92
+ child.stderr.on("data", (chunk) => {
93
+ process.stderr.write(chunk);
94
+ });
95
+ const pending = new Map();
96
+ function truncate(s, n) {
97
+ return s.length <= n ? s : `${s.slice(0, n)}...`;
98
+ }
99
+ function stripNamespace(toolName) {
100
+ if (!toolName)
101
+ return "(unknown)";
102
+ const prefix = `${namespace}_`;
103
+ return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
104
+ }
105
+ function extractCypher(args) {
106
+ if (!args)
107
+ return null;
108
+ const q = args["query"] ?? args["cypher"];
109
+ return typeof q === "string" ? truncate(q.replace(/\s+/g, " ").trim(), 80) : null;
110
+ }
111
+ function countRows(result) {
112
+ if (!result?.content || !Array.isArray(result.content))
113
+ return "?";
114
+ const text = result.content[0]?.text ?? "";
115
+ const rowsMatch = /(\d+)\s+(?:rows?|records?)/i.exec(text);
116
+ if (rowsMatch)
117
+ return rowsMatch[1];
118
+ return String(result.content.length);
119
+ }
120
+ function onRequestLine(line) {
121
+ try {
122
+ const msg = JSON.parse(line);
123
+ if (msg.method === "tools/call" && msg.id !== undefined) {
124
+ pending.set(msg.id, {
125
+ method: stripNamespace(msg.params?.name),
126
+ cypherPrefix: extractCypher(msg.params?.arguments),
127
+ startMs: Date.now(),
128
+ });
129
+ }
130
+ }
131
+ catch {
132
+ // Non-JSON / partial line — forward untouched, don't log.
133
+ }
134
+ }
135
+ function onResponseLine(line) {
136
+ try {
137
+ const msg = JSON.parse(line);
138
+ if (msg.id === undefined || !pending.has(msg.id))
139
+ return;
140
+ const p = pending.get(msg.id);
141
+ pending.delete(msg.id);
142
+ const elapsed = Date.now() - p.startMs;
143
+ const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
144
+ if (msg.error) {
145
+ const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
146
+ console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`);
147
+ }
148
+ else {
149
+ const rows = countRows(msg.result);
150
+ console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`);
151
+ }
152
+ }
153
+ catch {
154
+ // Non-JSON — forward untouched.
155
+ }
156
+ }
157
+ function makeLineSplitter(onLine) {
158
+ const decoder = new node_string_decoder_1.StringDecoder("utf8");
159
+ let buf = "";
160
+ return (chunk) => {
161
+ buf += decoder.write(chunk);
162
+ let idx;
163
+ while ((idx = buf.indexOf("\n")) !== -1) {
164
+ const line = buf.slice(0, idx);
165
+ buf = buf.slice(idx + 1);
166
+ if (line.length > 0)
167
+ onLine(line);
168
+ }
169
+ };
170
+ }
171
+ const splitRequest = makeLineSplitter(onRequestLine);
172
+ process.stdin.on("data", (chunk) => {
173
+ splitRequest(chunk);
174
+ child.stdin.write(chunk);
175
+ });
176
+ process.stdin.on("end", () => {
177
+ child.stdin.end();
178
+ });
179
+ const splitResponse = makeLineSplitter(onResponseLine);
180
+ child.stdout.on("data", (chunk) => {
181
+ splitResponse(chunk);
182
+ process.stdout.write(chunk);
183
+ });
184
+ child.stdout.on("end", () => {
185
+ process.stdout.end();
186
+ });
187
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
188
+ process.on(sig, () => {
189
+ console.error(`[graph-mcp] ${sig} received — forwarding to child`);
190
+ child.kill(sig);
191
+ });
192
+ }
193
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AACA;;;;;;;;;;;;;;;;;;;;;;GAsBG;;AAEH,2DAA2C;AAC3C,qCAAuC;AACvC,yCAAoC;AACpC,6DAAoD;AACpD,iEAAmE;AAEnE,MAAM,WAAW,GAAG,OAAO,CAAC;AAC5B,MAAM,gBAAgB,GAAG,wBAAwB,CAAC;AAElD,IAAA,wBAAa,EAAC,WAAW,CAAC,CAAC;AAE3B,SAAS,eAAe;IACtB,IAAI,OAAO,CAAC,GAAG,CAAC,cAAc;QAAE,OAAO,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;IAClE,2EAA2E;IAC3E,oFAAoF;IACpF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAA,mBAAO,EAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACzE,MAAM,IAAI,GAAG,IAAA,mBAAO,EAAC,IAAI,EAAE,wBAAwB,CAAC,CAAC;IACrD,IAAI,CAAC;QACH,OAAO,IAAA,sBAAY,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,KAAK,CAAC,2CAA2C,IAAI,mCAAmC,CAAC,CAAC;QAClG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,MAAM,CAAC;AAC1C,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,uBAAuB,CAAC;AAClE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,OAAO,CAAC;AAClF,MAAM,aAAa,GAAG,eAAe,EAAE,CAAC;AACxC,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AAC3C,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;AACjD,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,YAAY,CAAC;AAC9D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,MAAM,CAAC;AACvD,MAAM,kBAAkB,GAAG,OAAO,CAAC,GAAG,CAAC,0BAA0B,IAAI,OAAO,CAAC;AAE7E,MAAM,QAAQ,GAAsB;IAClC,GAAG,OAAO,CAAC,GAAG;IACd,SAAS,EAAE,QAAQ;IACnB,SAAS,EAAE,QAAQ;IACnB,cAAc,EAAE,SAAS;IACzB,cAAc,EAAE,aAAa;IAC7B,eAAe,EAAE,QAAQ;IACzB,eAAe,EAAE,SAAS;IAC1B,0BAA0B,EAAE,kBAAkB;CAC/C,CAAC;AAEF,OAAO,CAAC,KAAK,CACX,0BAA0B,KAAK,QAAQ,QAAQ,SAAS,SAAS,GAAG;IAClE,aAAa,SAAS,aAAa,QAAQ,eAAe,kBAAkB,EAAE,CACjF,CAAC;AAEF,MAAM,KAAK,GAAG,IAAA,0BAAK,EAAC,KAAK,EAAE,CAAC,gBAAgB,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE;IACrE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;IAC/B,GAAG,EAAE,QAAQ;CACd,CAAC,CAAC;AAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;IACrB,OAAO,CAAC,KAAK,CAAC,uBAAuB,gBAAgB,QAAQ,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;AAC5E,CAAC,CAAC,CAAC;AAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;IACxB,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,OAAO,CAAC,KAAK,CAAC,4BAA4B,GAAG,EAAE,CAAC,CAAC;IACjD,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QACrD,OAAO,CAAC,KAAK,CACX,kDAAkD;YAChD,0DAA0D,CAC7D,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACpB,CAAC,CAAC,CAAC;AAEH,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;IAChC,OAAO,CAAC,KAAK,CAAC,+BAA+B,IAAI,IAAI,MAAM,WAAW,MAAM,IAAI,MAAM,EAAE,CAAC,CAAC;IAC1F,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACzC,CAAC,CAAC,CAAC;AAEH,wEAAwE;AACxE,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;IACxC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC9B,CAAC,CAAC,CAAC;AASH,MAAM,OAAO,GAAG,IAAI,GAAG,EAAgC,CAAC;AAExD,SAAS,QAAQ,CAAC,CAAS,EAAE,CAAS;IACpC,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC;AACnD,CAAC;AAED,SAAS,cAAc,CAAC,QAA4B;IAClD,IAAI,CAAC,QAAQ;QAAE,OAAO,WAAW,CAAC;IAClC,MAAM,MAAM,GAAG,GAAG,SAAS,GAAG,CAAC;IAC/B,OAAO,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AAChF,CAAC;AAUD,SAAS,aAAa,CAAC,IAAyC;IAC9D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1C,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACpF,CAAC;AAED,SAAS,SAAS,CAAC,MAAgC;IACjD,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC;QAAE,OAAO,GAAG,CAAC;IACnE,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,EAAE,CAAC;IAC3C,MAAM,SAAS,GAAG,6BAA6B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC3D,IAAI,SAAS;QAAE,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;AACvC,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IACjC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAmB,CAAC;QAC/C,IAAI,GAAG,CAAC,MAAM,KAAK,YAAY,IAAI,GAAG,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE;gBAClB,MAAM,EAAE,cAAc,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC;gBACxC,YAAY,EAAE,aAAa,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC;gBAClD,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE;aACpB,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,0DAA0D;IAC5D,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,IAAY;IAClC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAmB,CAAC;QAC/C,IAAI,GAAG,CAAC,EAAE,KAAK,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAAE,OAAO;QACzD,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAE,CAAC;QAC/B,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACvB,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC;QACvC,MAAM,WAAW,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1F,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;YACpF,OAAO,CAAC,KAAK,CACX,oBAAoB,CAAC,CAAC,MAAM,UAAU,KAAK,SAAS,SAAS,IAAI,WAAW,WAAW,OAAO,QAAQ,OAAO,EAAE,CAChH,CAAC;QACJ,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACnC,OAAO,CAAC,KAAK,CACX,oBAAoB,CAAC,CAAC,MAAM,UAAU,KAAK,SAAS,SAAS,IAAI,WAAW,SAAS,IAAI,OAAO,OAAO,EAAE,CAC1G,CAAC;QACJ,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,gCAAgC;IAClC,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,MAA8B;IACtD,MAAM,OAAO,GAAG,IAAI,mCAAa,CAAC,MAAM,CAAC,CAAC;IAC1C,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,OAAO,CAAC,KAAa,EAAE,EAAE;QACvB,GAAG,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC5B,IAAI,GAAW,CAAC;QAChB,OAAO,CAAC,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YACxC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;YAC/B,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;YACzB,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,CAAC;QACpC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,YAAY,GAAG,gBAAgB,CAAC,aAAa,CAAC,CAAC;AACrD,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;IACzC,YAAY,CAAC,KAAK,CAAC,CAAC;IACpB,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC,CAAC,CAAC;AACH,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;IAC3B,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;AACvD,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;IACxC,aAAa,CAAC,KAAK,CAAC,CAAC;IACrB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC9B,CAAC,CAAC,CAAC;AACH,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;IAC1B,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC;AACvB,CAAC,CAAC,CAAC;AAEH,KAAK,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAU,EAAE,CAAC;IAC3D,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE;QACnB,OAAO,CAAC,KAAK,CAAC,eAAe,GAAG,iCAAiC,CAAC,CAAC;QACnE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
4
+ *
5
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
6
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
7
+ * stdio stream byte-for-byte. While proxying, it:
8
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
9
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
10
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
11
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
12
+ * per-conversation stream log alongside every other plugin.
13
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
14
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
15
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
16
+ * start time per `id`, and on the response path to emit one
17
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
18
+ * line per tool call. Forwarding is byte-accurate — the parser
19
+ * observes, never mutates.
20
+ *
21
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
22
+ * each brand already owns its own Neo4j instance on its own port with its
23
+ * own password. This shim trusts that — no query-layer tenant filtering.
24
+ */
25
+
26
+ import { spawn } from "node:child_process";
27
+ import { readFileSync } from "node:fs";
28
+ import { resolve } from "node:path";
29
+ import { StringDecoder } from "node:string_decoder";
30
+ import { initStderrTee } from "../../mcp-stderr-tee/dist/index.js";
31
+
32
+ const SERVER_NAME = "graph";
33
+ const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
34
+
35
+ initStderrTee(SERVER_NAME);
36
+
37
+ function resolvePassword(): string {
38
+ if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
39
+ // __dirname points at `lib/graph-mcp/dist` after compilation; the platform
40
+ // root is three levels up (lib/graph-mcp/dist -> lib/graph-mcp -> lib -> platform).
41
+ const root = process.env.PLATFORM_ROOT ?? resolve(__dirname, "../../..");
42
+ const file = resolve(root, "config/.neo4j-password");
43
+ try {
44
+ return readFileSync(file, "utf-8").trim();
45
+ } catch {
46
+ console.error(`[graph-mcp] password unavailable — file=${file} env=NEO4J_PASSWORD (neither set)`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ const brand = process.env.BRAND ?? "maxy";
52
+ const neo4jUri = process.env.NEO4J_URI ?? "bolt://localhost:7687";
53
+ const neo4jUser = process.env.NEO4J_USERNAME ?? process.env.NEO4J_USER ?? "neo4j";
54
+ const neo4jPassword = resolvePassword();
55
+ const portMatch = /:(\d+)$/.exec(neo4jUri);
56
+ const neo4jPort = portMatch ? portMatch[1] : "?";
57
+ const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
58
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
59
+ const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
60
+
61
+ const childEnv: NodeJS.ProcessEnv = {
62
+ ...process.env,
63
+ NEO4J_URI: neo4jUri,
64
+ NEO4J_URL: neo4jUri,
65
+ NEO4J_USERNAME: neo4jUser,
66
+ NEO4J_PASSWORD: neo4jPassword,
67
+ NEO4J_READ_ONLY: readOnly,
68
+ NEO4J_NAMESPACE: namespace,
69
+ NEO4J_RESPONSE_TOKEN_LIMIT: responseTokenLimit,
70
+ };
71
+
72
+ console.error(
73
+ `[graph-mcp] boot brand=${brand} uri=${neo4jUri} user=${neo4jUser} ` +
74
+ `namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`,
75
+ );
76
+
77
+ const child = spawn("uvx", [UPSTREAM_PACKAGE, "--transport", "stdio"], {
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ env: childEnv,
80
+ });
81
+
82
+ child.on("spawn", () => {
83
+ console.error(`[graph-mcp] spawned ${UPSTREAM_PACKAGE} pid=${child.pid}`);
84
+ });
85
+
86
+ child.on("error", (err) => {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ console.error(`[graph-mcp] spawn error: ${msg}`);
89
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
90
+ console.error(
91
+ "[graph-mcp] uvx not found on PATH — install uv: " +
92
+ "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y",
93
+ );
94
+ }
95
+ process.exit(127);
96
+ });
97
+
98
+ child.on("exit", (code, signal) => {
99
+ console.error(`[graph-mcp] uvx exited code=${code ?? "null"} signal=${signal ?? "null"}`);
100
+ process.exit(code ?? (signal ? 1 : 0));
101
+ });
102
+
103
+ // Forward child stderr to our own stderr so the stderr tee picks it up.
104
+ child.stderr.on("data", (chunk: Buffer) => {
105
+ process.stderr.write(chunk);
106
+ });
107
+
108
+ // --- JSON-RPC call correlation ---
109
+ // tools/call is the only method we time; initialize and tools/list are noise.
110
+ interface PendingCall {
111
+ method: string;
112
+ cypherPrefix: string | null;
113
+ startMs: number;
114
+ }
115
+ const pending = new Map<string | number, PendingCall>();
116
+
117
+ function truncate(s: string, n: number): string {
118
+ return s.length <= n ? s : `${s.slice(0, n)}...`;
119
+ }
120
+
121
+ function stripNamespace(toolName: string | undefined): string {
122
+ if (!toolName) return "(unknown)";
123
+ const prefix = `${namespace}_`;
124
+ return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
125
+ }
126
+
127
+ type JsonRpcMessage = {
128
+ id?: string | number;
129
+ method?: string;
130
+ params?: { name?: string; arguments?: Record<string, unknown> };
131
+ result?: { content?: Array<{ text?: string; type?: string }> };
132
+ error?: { message?: string; code?: number };
133
+ };
134
+
135
+ function extractCypher(args: Record<string, unknown> | undefined): string | null {
136
+ if (!args) return null;
137
+ const q = args["query"] ?? args["cypher"];
138
+ return typeof q === "string" ? truncate(q.replace(/\s+/g, " ").trim(), 80) : null;
139
+ }
140
+
141
+ function countRows(result: JsonRpcMessage["result"]): string {
142
+ if (!result?.content || !Array.isArray(result.content)) return "?";
143
+ const text = result.content[0]?.text ?? "";
144
+ const rowsMatch = /(\d+)\s+(?:rows?|records?)/i.exec(text);
145
+ if (rowsMatch) return rowsMatch[1];
146
+ return String(result.content.length);
147
+ }
148
+
149
+ function onRequestLine(line: string): void {
150
+ try {
151
+ const msg = JSON.parse(line) as JsonRpcMessage;
152
+ if (msg.method === "tools/call" && msg.id !== undefined) {
153
+ pending.set(msg.id, {
154
+ method: stripNamespace(msg.params?.name),
155
+ cypherPrefix: extractCypher(msg.params?.arguments),
156
+ startMs: Date.now(),
157
+ });
158
+ }
159
+ } catch {
160
+ // Non-JSON / partial line — forward untouched, don't log.
161
+ }
162
+ }
163
+
164
+ function onResponseLine(line: string): void {
165
+ try {
166
+ const msg = JSON.parse(line) as JsonRpcMessage;
167
+ if (msg.id === undefined || !pending.has(msg.id)) return;
168
+ const p = pending.get(msg.id)!;
169
+ pending.delete(msg.id);
170
+ const elapsed = Date.now() - p.startMs;
171
+ const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
172
+ if (msg.error) {
173
+ const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
174
+ console.error(
175
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`,
176
+ );
177
+ } else {
178
+ const rows = countRows(msg.result);
179
+ console.error(
180
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`,
181
+ );
182
+ }
183
+ } catch {
184
+ // Non-JSON — forward untouched.
185
+ }
186
+ }
187
+
188
+ function makeLineSplitter(onLine: (line: string) => void): (chunk: Buffer) => void {
189
+ const decoder = new StringDecoder("utf8");
190
+ let buf = "";
191
+ return (chunk: Buffer) => {
192
+ buf += decoder.write(chunk);
193
+ let idx: number;
194
+ while ((idx = buf.indexOf("\n")) !== -1) {
195
+ const line = buf.slice(0, idx);
196
+ buf = buf.slice(idx + 1);
197
+ if (line.length > 0) onLine(line);
198
+ }
199
+ };
200
+ }
201
+
202
+ const splitRequest = makeLineSplitter(onRequestLine);
203
+ process.stdin.on("data", (chunk: Buffer) => {
204
+ splitRequest(chunk);
205
+ child.stdin.write(chunk);
206
+ });
207
+ process.stdin.on("end", () => {
208
+ child.stdin.end();
209
+ });
210
+
211
+ const splitResponse = makeLineSplitter(onResponseLine);
212
+ child.stdout.on("data", (chunk: Buffer) => {
213
+ splitResponse(chunk);
214
+ process.stdout.write(chunk);
215
+ });
216
+ child.stdout.on("end", () => {
217
+ process.stdout.end();
218
+ });
219
+
220
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
221
+ process.on(sig, () => {
222
+ console.error(`[graph-mcp] ${sig} received — forwarding to child`);
223
+ child.kill(sig);
224
+ });
225
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -6,8 +6,8 @@
6
6
  "plugins/*/mcp"
7
7
  ],
8
8
  "scripts": {
9
- "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
- "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
9
+ "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
+ "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
11
11
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
12
12
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
13
13
  "build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # Shared stream-log helpers sourced by setup-tunnel.sh and reset-tunnel.sh.
3
+ #
4
+ # The platform exposes STREAM_LOG_PATH in the `claude` spawn env (Task 556);
5
+ # the Bash-tool subprocess inherits it, and opt-in scripts call these
6
+ # helpers to emit phase lines and tee subprocess output into the same
7
+ # per-conversation file the chat UI's server-side tailer reads.
8
+ #
9
+ # Contract (read by platform/ui/app/api/admin/chat/route.ts tailer and
10
+ # .docs/platform.md):
11
+ # [<ISO-ts>] [<scope>] <kv …>
12
+ # [<ISO-ts>] [<scope>:<subprocess-tag>] <raw line>
13
+ # where <scope> ∈ {setup-tunnel, reset-tunnel}. The tailer regex is
14
+ # ^\[[^]]+\] \[(setup-tunnel|reset-tunnel)(:[^]]+)?\]
15
+ # so any prefix change must be made on both sides atomically.
16
+
17
+ # Exit 1 loudly with the variable name and the invoking scope so direct-SSH
18
+ # invocations fail fast and the operator reads exactly what to set. No
19
+ # silent fallback — criterion 4 of Task 556.
20
+ require_stream_log_path() {
21
+ local scope="$1"
22
+ if [ -z "${STREAM_LOG_PATH:-}" ]; then
23
+ echo "ERROR [${scope}]: STREAM_LOG_PATH environment variable is unset." >&2
24
+ echo " This script tees subprocess output into a per-conversation" >&2
25
+ echo " stream log; it is meaningless without a target path." >&2
26
+ echo " The platform sets STREAM_LOG_PATH automatically for every" >&2
27
+ echo " \`claude\` spawn (Task 556). If you are invoking this" >&2
28
+ echo " script by hand, export it yourself, for example:" >&2
29
+ echo " export STREAM_LOG_PATH=\"\${HOME}/.maxy/logs/manual-invocation.log\"" >&2
30
+ exit 1
31
+ fi
32
+ mkdir -p "$(dirname "${STREAM_LOG_PATH}")"
33
+ }
34
+
35
+ # ISO-8601 UTC timestamp with millisecond precision.
36
+ stream_log_ts() {
37
+ date -u +"%Y-%m-%dT%H:%M:%S.%3NZ"
38
+ }
39
+
40
+ # Append one phase line to STREAM_LOG_PATH AND echo to stderr so the Bash
41
+ # tool's stderr capture carries it. Both paths matter: the stream log is
42
+ # the live tailer surface; stderr is the exit-time Bash-tool surface.
43
+ #
44
+ # Usage: phase_line <scope> <key=value …>
45
+ phase_line() {
46
+ local scope="$1"; shift
47
+ local ts
48
+ ts="$(stream_log_ts)"
49
+ printf '[%s] [%s] %s\n' "${ts}" "${scope}" "$*" >> "${STREAM_LOG_PATH}"
50
+ printf '[%s] %s\n' "${scope}" "$*" >&2
51
+ }
52
+
53
+ # Tee a subprocess's combined stdout+stderr into STREAM_LOG_PATH line-by-line
54
+ # with the given tag, mirroring each line to stderr for the Bash tool.
55
+ # Returns the subprocess's exit code.
56
+ #
57
+ # Use for fire-and-forget subprocesses whose stdout does NOT need to be
58
+ # captured by the caller (the caller only cares about the exit code).
59
+ # For subprocesses whose stdout the caller must parse (e.g. JSON output
60
+ # feeding `jq`), use `tee_subprocess_capture` instead.
61
+ #
62
+ # Usage: tee_subprocess <tag> -- <cmd> <args …>
63
+ # Example: tee_subprocess reset-tunnel:cloudflared -- cloudflared --origincert … tunnel delete my-tunnel
64
+ #
65
+ # stdbuf forces line buffering; without it cloudflared holds its stdout in
66
+ # libc's 8 KB buffer until exit and the ≤ 1 s latency criterion fails.
67
+ tee_subprocess() {
68
+ local tag="$1"; shift
69
+ if [ "${1:-}" != "--" ]; then
70
+ echo "tee_subprocess: expected -- before command" >&2
71
+ return 2
72
+ fi
73
+ shift
74
+ # Prefer `stdbuf -oL -eL` to force line buffering on the subprocess; fall
75
+ # back to the bare command when stdbuf is unavailable (macOS dev). On the
76
+ # Pi (Linux), stdbuf is present via coreutils and cloudflared's output
77
+ # reaches the tee as soon as each line is written.
78
+ local -a buf_prefix=()
79
+ if command -v stdbuf >/dev/null 2>&1; then
80
+ buf_prefix=(stdbuf -oL -eL)
81
+ fi
82
+ "${buf_prefix[@]}" "$@" 2>&1 | while IFS= read -r line; do
83
+ local ts
84
+ ts="$(stream_log_ts)"
85
+ printf '[%s] [%s] %s\n' "${ts}" "${tag}" "${line}" >> "${STREAM_LOG_PATH}"
86
+ printf '%s\n' "${line}" >&2
87
+ done
88
+ return "${PIPESTATUS[0]}"
89
+ }
90
+
91
+ # Tee a subprocess's combined stdout+stderr into STREAM_LOG_PATH line-by-line
92
+ # with the given tag, passing each line through on stdout so the caller can
93
+ # capture it with `> file` or `$( … )`. Stderr mirroring is dropped so the
94
+ # caller's stdout is exactly the subprocess's output — `jq` and friends
95
+ # remain parseable.
96
+ #
97
+ # Usage: tee_subprocess_capture <tag> -- <cmd> <args …> > captured.file
98
+ # Example:
99
+ # tee_subprocess_capture reset-tunnel:cloudflared -- \
100
+ # cloudflared --origincert "${CERT}" tunnel list --output json \
101
+ # > "${NAMES_TMP}"
102
+ tee_subprocess_capture() {
103
+ local tag="$1"; shift
104
+ if [ "${1:-}" != "--" ]; then
105
+ echo "tee_subprocess_capture: expected -- before command" >&2
106
+ return 2
107
+ fi
108
+ shift
109
+ # Prefer `stdbuf -oL -eL` to force line buffering on the subprocess; fall
110
+ # back to the bare command when stdbuf is unavailable (macOS dev). On the
111
+ # Pi (Linux), stdbuf is present via coreutils and cloudflared's output
112
+ # reaches the tee as soon as each line is written.
113
+ local -a buf_prefix=()
114
+ if command -v stdbuf >/dev/null 2>&1; then
115
+ buf_prefix=(stdbuf -oL -eL)
116
+ fi
117
+ "${buf_prefix[@]}" "$@" 2>&1 | while IFS= read -r line; do
118
+ local ts
119
+ ts="$(stream_log_ts)"
120
+ printf '[%s] [%s] %s\n' "${ts}" "${tag}" "${line}" >> "${STREAM_LOG_PATH}"
121
+ printf '%s\n' "${line}"
122
+ done
123
+ return "${PIPESTATUS[0]}"
124
+ }