nano-brain 2026.7.1102 → 2026.7.1104

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/README.md CHANGED
@@ -8,6 +8,7 @@ Agent-oriented memory and code intelligence over MCP. AI agents don't read docs
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
  [![GitHub](https://img.shields.io/badge/GitHub-nano--step%2Fnano--brain-181717?logo=github)](https://github.com/nano-step/nano-brain)
10
10
  [![npm](https://img.shields.io/badge/npm-@nano--step%2Fnano--brain-CC3533?logo=npm)](https://www.npmjs.com/package/@nano-step/nano-brain)
11
+ [![npm downloads](https://img.shields.io/npm/dw/@nano-step/nano-brain?logo=npm&label=downloads)](https://www.npmjs.com/package/@nano-step/nano-brain)
11
12
  [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/nano-step/nano-brain)
12
13
  [![Discord](https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/nano-brain)
13
14
 
@@ -6,7 +6,7 @@ const fs = require("fs");
6
6
  const path = require("path");
7
7
  const os = require("os");
8
8
  const crypto = require("crypto");
9
- const { execSync } = require("child_process");
9
+ const { execFileSync } = require("child_process");
10
10
 
11
11
  const VERSION = require("../package.json").version;
12
12
  const REPO = "nano-step/nano-brain";
@@ -24,34 +24,69 @@ function getPlatformKey() {
24
24
  const platform = PLATFORM_MAP[os.platform()];
25
25
  const arch = ARCH_MAP[os.arch()];
26
26
  if (!platform || !arch) {
27
- console.error(`Unsupported platform: ${os.platform()}-${os.arch()}`);
28
- console.error("Build from source: CGO_ENABLED=0 go build -o nano-brain ./cmd/nano-brain");
29
- process.exit(0);
27
+ // Throw (do not process.exit) so ensureBinary's "throws, never exits"
28
+ // contract holds: main() turns this into a non-fatal install exit(0),
29
+ // run.js reports it and exits 1 (#594).
30
+ throw new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`);
30
31
  }
31
32
  return `${platform}-${arch}`;
32
33
  }
33
34
 
35
+ // Remove a partial download without throwing. A socket error can fire before
36
+ // createWriteStream has opened the file, or after a redirect branch already
37
+ // removed it — an unguarded fs.unlinkSync then throws ENOENT *inside* the
38
+ // event callback, which is uncaught and crashes the whole postinstall (#592).
39
+ function safeUnlink(p) {
40
+ // No existsSync pre-check: the try/catch already swallows ENOENT, and a
41
+ // check-then-unlink is a TOCTOU race. Best-effort cleanup.
42
+ try {
43
+ fs.unlinkSync(p);
44
+ } catch (_) {
45
+ /* best-effort cleanup */
46
+ }
47
+ }
48
+
49
+ // file.close() is asynchronous; the fd is only released when its callback
50
+ // fires. Every unlink / recursive-redirect / reject is therefore deferred into
51
+ // file.close(() => …) so the partial file is gone (and not re-opened by a
52
+ // racing redirect) before we act — otherwise Windows unlink fails with EPERM
53
+ // while the fd is open, and a raw request error would leak the write stream.
34
54
  function download(url, dest) {
35
55
  return new Promise((resolve, reject) => {
36
56
  const file = fs.createWriteStream(dest);
37
57
  https.get(url, (res) => {
38
58
  if (res.statusCode === 301 || res.statusCode === 302) {
39
- file.close();
40
- fs.unlinkSync(dest);
41
- return download(res.headers.location, dest).then(resolve).catch(reject);
59
+ file.close(() => {
60
+ safeUnlink(dest);
61
+ download(res.headers.location, dest).then(resolve).catch(reject);
62
+ });
63
+ return;
42
64
  }
43
65
  if (res.statusCode !== 200) {
44
- file.close();
45
- fs.unlinkSync(dest);
46
- return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
66
+ file.close(() => {
67
+ safeUnlink(dest);
68
+ reject(new Error(`Download failed: HTTP ${res.statusCode}`));
69
+ });
70
+ return;
47
71
  }
48
72
  res.pipe(file);
73
+ // pipe() does NOT forward source 'error' events; without this a socket
74
+ // reset mid-transfer emits an unhandled 'error' on res and crashes the
75
+ // postinstall (#592) — mirror downloadWithHash's handler.
76
+ res.on("error", (err) => {
77
+ file.close(() => {
78
+ safeUnlink(dest);
79
+ reject(err);
80
+ });
81
+ });
49
82
  file.on("finish", () => {
50
83
  file.close(resolve);
51
84
  });
52
85
  }).on("error", (err) => {
53
- fs.unlinkSync(dest);
54
- reject(err);
86
+ file.close(() => {
87
+ safeUnlink(dest);
88
+ reject(err);
89
+ });
55
90
  });
56
91
  });
57
92
  }
@@ -62,14 +97,18 @@ function downloadWithHash(url, dest) {
62
97
  const hash = crypto.createHash("sha256");
63
98
  https.get(url, (res) => {
64
99
  if (res.statusCode === 301 || res.statusCode === 302) {
65
- file.close();
66
- fs.unlinkSync(dest);
67
- return downloadWithHash(res.headers.location, dest).then(resolve).catch(reject);
100
+ file.close(() => {
101
+ safeUnlink(dest);
102
+ downloadWithHash(res.headers.location, dest).then(resolve).catch(reject);
103
+ });
104
+ return;
68
105
  }
69
106
  if (res.statusCode !== 200) {
70
- file.close();
71
- fs.unlinkSync(dest);
72
- return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
107
+ file.close(() => {
108
+ safeUnlink(dest);
109
+ reject(new Error(`Download failed: HTTP ${res.statusCode}`));
110
+ });
111
+ return;
73
112
  }
74
113
  res.on("data", (chunk) => hash.update(chunk));
75
114
  res.pipe(file);
@@ -77,13 +116,16 @@ function downloadWithHash(url, dest) {
77
116
  file.close(() => resolve(hash.digest("hex")));
78
117
  });
79
118
  res.on("error", (err) => {
80
- file.close();
81
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
82
- reject(err);
119
+ file.close(() => {
120
+ safeUnlink(dest);
121
+ reject(err);
122
+ });
83
123
  });
84
124
  }).on("error", (err) => {
85
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
86
- reject(err);
125
+ file.close(() => {
126
+ safeUnlink(dest);
127
+ reject(err);
128
+ });
87
129
  });
88
130
  });
89
131
  }
@@ -110,9 +152,7 @@ async function verifySHA256(tag, assetName, binPath, computedHex) {
110
152
  console.warn(`⚠ SHA256SUMS not available for ${tag} (${err.message}); skipping integrity verification.`);
111
153
  return;
112
154
  } finally {
113
- if (fs.existsSync(sumsPath)) {
114
- try { fs.unlinkSync(sumsPath); } catch (_) {}
115
- }
155
+ safeUnlink(sumsPath);
116
156
  }
117
157
 
118
158
  const expectedHex = parseSHA256Line(sumsContent, assetName);
@@ -122,7 +162,10 @@ async function verifySHA256(tag, assetName, binPath, computedHex) {
122
162
  }
123
163
 
124
164
  if (expectedHex.toLowerCase() !== computedHex.toLowerCase()) {
125
- if (fs.existsSync(binPath)) fs.unlinkSync(binPath);
165
+ // safeUnlink so an unrelated fs error (e.g. EACCES) can't turn a SHA
166
+ // mismatch into a non-"SECURITY:" rejection that main() would treat as a
167
+ // retryable download failure, bypassing the hard-fail fail-safe.
168
+ safeUnlink(binPath);
126
169
  throw new Error(
127
170
  `SECURITY: SHA-256 mismatch for ${assetName}\n` +
128
171
  ` expected: ${expectedHex}\n` +
@@ -225,84 +268,106 @@ function tryAutoLink(srcBinPath, platform) {
225
268
  }
226
269
  }
227
270
 
228
- async function main() {
229
- const platformKey = getPlatformKey();
271
+ function binaryPath() {
230
272
  const binName = os.platform() === "win32" ? "nano-brain.exe" : "nano-brain";
231
- const binPath = path.join(__dirname, binName);
273
+ return path.join(__dirname, binName);
274
+ }
275
+
276
+ // Download-and-verify the platform binary to binaryPath() and return that path.
277
+ // THROWS on failure (never calls process.exit) so both the install-time caller
278
+ // (main) and the run-time caller (run.js) can choose their own exit behavior.
279
+ // A "SECURITY:"-prefixed error signals an integrity failure that must hard-fail.
280
+ // Progress goes to stderr so it never pollutes a command's stdout when run.js
281
+ // downloads lazily on first invocation (#594).
282
+ async function ensureBinary() {
283
+ const platformKey = getPlatformKey();
284
+ const binPath = binaryPath();
232
285
 
233
286
  const skipVerify = !!process.env.NANO_BRAIN_SKIP_SHA_VERIFY;
234
287
  if (skipVerify) {
235
- console.warn("⚠ NANO_BRAIN_SKIP_SHA_VERIFY is set; binary integrity check will be skipped.");
288
+ console.error("⚠ NANO_BRAIN_SKIP_SHA_VERIFY is set; binary integrity check will be skipped.");
236
289
  }
237
290
 
238
291
  if (fs.existsSync(binPath)) {
239
292
  try {
240
- const output = execSync(`"${binPath}" version --json`, { timeout: 5000 }).toString();
293
+ const output = execFileSync(binPath, ["version", "--json"], { timeout: 5000 }).toString();
241
294
  if (output.includes(VERSION)) {
242
- console.log(`nano-brain v${VERSION} already installed.`);
243
- return;
295
+ return binPath;
244
296
  }
245
- } catch {
246
- // Wrong version or can't run — redownload
297
+ console.error("Existing nano-brain binary is a different version; re-downloading.");
298
+ } catch (err) {
299
+ console.error(`Existing nano-brain binary failed verification (${err.message}); re-downloading.`);
247
300
  }
301
+ // Reached only when the existing binary is stale/broken. Unlink before
302
+ // overwriting: a running process keeps its handle to the old inode, so
303
+ // this avoids ETXTBSY on re-download (Linux/macOS).
304
+ safeUnlink(binPath);
248
305
  }
249
306
 
250
- console.log(`Downloading nano-brain v${VERSION} for ${platformKey}...`);
307
+ console.error(`Downloading nano-brain v${VERSION} for ${platformKey}...`);
251
308
 
252
309
  const assetName = `nano-brain-${platformKey}`;
253
310
  let lastErr;
254
- for (const tag of candidateTagsForVersion(VERSION)) {
311
+
312
+ const attempt = async (tag, suffix) => {
255
313
  const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
314
+ if (skipVerify) {
315
+ await download(url, binPath);
316
+ } else {
317
+ const computedHex = await downloadWithHash(url, binPath);
318
+ await verifySHA256(tag, assetName, binPath, computedHex);
319
+ }
320
+ fs.chmodSync(binPath, 0o755);
321
+ console.error(`nano-brain v${VERSION} installed successfully from ${tag}${suffix || ""}.`);
322
+ return binPath;
323
+ };
324
+
325
+ for (const tag of candidateTagsForVersion(VERSION)) {
256
326
  try {
257
- if (skipVerify) {
258
- await download(url, binPath);
259
- } else {
260
- const computedHex = await downloadWithHash(url, binPath);
261
- await verifySHA256(tag, assetName, binPath, computedHex);
262
- }
263
- fs.chmodSync(binPath, 0o755);
264
- console.log(`nano-brain v${VERSION} installed successfully from ${tag}.`);
265
- tryAutoLink(binPath);
266
- return;
327
+ return await attempt(tag);
267
328
  } catch (err) {
268
- if (err && typeof err.message === "string" && err.message.startsWith("SECURITY:")) {
269
- console.error(err.message);
270
- process.exit(1);
271
- }
329
+ if (err && typeof err.message === "string" && err.message.startsWith("SECURITY:")) throw err;
272
330
  lastErr = err;
273
331
  }
274
332
  }
275
333
 
334
+ let apiTag;
276
335
  try {
277
- const tag = await resolveTagFromAPI(VERSION, assetName);
278
- if (tag) {
279
- const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`;
280
- if (skipVerify) {
281
- await download(url, binPath);
282
- } else {
283
- const computedHex = await downloadWithHash(url, binPath);
284
- await verifySHA256(tag, assetName, binPath, computedHex);
285
- }
286
- fs.chmodSync(binPath, 0o755);
287
- console.log(`nano-brain v${VERSION} installed successfully from ${tag} (API fallback).`);
288
- tryAutoLink(binPath);
289
- return;
290
- }
336
+ apiTag = await resolveTagFromAPI(VERSION, assetName);
291
337
  } catch (err) {
292
- if (err && typeof err.message === "string" && err.message.startsWith("SECURITY:")) {
293
- console.error(err.message);
294
- process.exit(1);
295
- }
296
338
  lastErr = err;
297
339
  }
340
+ if (apiTag) {
341
+ // Not wrapped: a SECURITY error from the API-fallback attempt must propagate.
342
+ return await attempt(apiTag, " (API fallback)");
343
+ }
298
344
 
299
- console.error(`Failed to download binary: ${lastErr && lastErr.message}`);
300
- console.error("Build from source: CGO_ENABLED=0 go build -o npm/nano-brain ./cmd/nano-brain");
301
- process.exit(0);
345
+ throw new Error(`Failed to download binary: ${lastErr && lastErr.message}`);
346
+ }
347
+
348
+ async function main() {
349
+ try {
350
+ const bin = await ensureBinary();
351
+ // Auto-link only at install time — never from run.js's lazy path, so its
352
+ // console.log chatter can't pollute a command's stdout (#594).
353
+ tryAutoLink(bin);
354
+ } catch (err) {
355
+ const msg = err && err.message ? err.message : String(err);
356
+ if (msg.startsWith("SECURITY:")) {
357
+ console.error(msg);
358
+ process.exit(1); // integrity failure — never leave a bad binary
359
+ }
360
+ // Non-fatal at install time: don't fail `npm install`. run.js retries the
361
+ // download on first invocation, which also covers toolchains that don't
362
+ // persist postinstall-created files (#594).
363
+ console.error(msg);
364
+ console.error("Build from source: CGO_ENABLED=0 go build -o npm/nano-brain ./cmd/nano-brain");
365
+ process.exit(0);
366
+ }
302
367
  }
303
368
 
304
369
  if (require.main === module) {
305
370
  main();
306
371
  }
307
372
 
308
- module.exports = { parseSHA256Line, downloadWithHash, verifySHA256, tryAutoLink };
373
+ module.exports = { parseSHA256Line, download, downloadWithHash, verifySHA256, tryAutoLink, safeUnlink, ensureBinary, binaryPath };
@@ -6,7 +6,87 @@ const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
  const os = require("node:os");
8
8
  const crypto = require("node:crypto");
9
- const { parseSHA256Line, tryAutoLink } = require("./postinstall");
9
+ const { spawnSync } = require("node:child_process");
10
+ const { parseSHA256Line, tryAutoLink, safeUnlink, download, downloadWithHash, ensureBinary, binaryPath } = require("./postinstall");
11
+ const VERSION = require("../package.json").version;
12
+ const isWin = os.platform() === "win32";
13
+
14
+ // #594: when a valid binary is already at binaryPath(), ensureBinary() must
15
+ // return it WITHOUT any network download. Stub the binary with a tiny script
16
+ // that echoes the expected `version --json` payload. Any real binary already
17
+ // there is moved aside and restored so the assertion always runs (not skipped).
18
+ test("ensureBinary: returns existing binary without downloading", { skip: isWin }, async () => {
19
+ const bin = binaryPath();
20
+ const backup = `${bin}.itest.bak`;
21
+ const hadReal = fs.existsSync(bin);
22
+ if (hadReal) fs.renameSync(bin, backup);
23
+ fs.writeFileSync(bin, `#!/bin/sh\necho '{"version":"${VERSION}"}'\n`);
24
+ fs.chmodSync(bin, 0o755);
25
+ try {
26
+ const resolved = await ensureBinary();
27
+ assert.strictEqual(resolved, bin);
28
+ } finally {
29
+ safeUnlink(bin);
30
+ if (hadReal) fs.renameSync(backup, bin);
31
+ }
32
+ });
33
+
34
+ // #594 BLOCKER regression: an unsupported platform must REJECT (not process.exit),
35
+ // so run.js can report it and exit 1. getPlatformKey throws; ensureBinary propagates.
36
+ test("ensureBinary: rejects (does not exit) on unsupported platform", async () => {
37
+ const realPlatform = os.platform;
38
+ os.platform = () => "freebsd";
39
+ try {
40
+ await assert.rejects(() => ensureBinary(), /Unsupported platform/);
41
+ } finally {
42
+ os.platform = realPlatform;
43
+ }
44
+ });
45
+
46
+ // #594: run.js executes the binary NANO_BRAIN_BIN points to (override path),
47
+ // end-to-end via a subprocess — proves the wrapper's exec path works.
48
+ test("run.js: runs NANO_BRAIN_BIN target and exits 0", { skip: isWin }, () => {
49
+ const stub = path.join(os.tmpdir(), `nb-runjs-stub-${process.pid}-${Date.now()}`);
50
+ fs.writeFileSync(stub, `#!/bin/sh\necho NB_STUB_OK\n`);
51
+ fs.chmodSync(stub, 0o755);
52
+ try {
53
+ const r = spawnSync(process.execPath, [path.join(__dirname, "run.js"), "anyarg"], {
54
+ env: { ...process.env, NANO_BRAIN_BIN: stub },
55
+ encoding: "utf8",
56
+ });
57
+ assert.strictEqual(r.status, 0);
58
+ assert.match(r.stdout, /NB_STUB_OK/);
59
+ } finally {
60
+ safeUnlink(stub);
61
+ }
62
+ });
63
+
64
+ // #592 regression: a request/socket error must REJECT the promise (so main()'s
65
+ // per-tag retry loop continues), never throw synchronously or crash the
66
+ // process via an unguarded unlink. Point at a closed port → ECONNREFUSED.
67
+ test("download: rejects (not throws) on connection error, no lingering file", async () => {
68
+ const dest = path.join(os.tmpdir(), `nb-dl-refused-${process.pid}-${Date.now()}`);
69
+ await assert.rejects(() => download("https://127.0.0.1:1/x", dest));
70
+ assert.strictEqual(fs.existsSync(dest), false);
71
+ });
72
+
73
+ test("downloadWithHash: rejects (not throws) on connection error, no lingering file", async () => {
74
+ const dest = path.join(os.tmpdir(), `nb-dlh-refused-${process.pid}-${Date.now()}`);
75
+ await assert.rejects(() => downloadWithHash("https://127.0.0.1:1/x", dest));
76
+ assert.strictEqual(fs.existsSync(dest), false);
77
+ });
78
+
79
+ test("safeUnlink: does not throw on a nonexistent path (#592 regression)", () => {
80
+ const missing = path.join(os.tmpdir(), `nb-safeunlink-missing-${process.pid}-${Date.now()}`);
81
+ assert.doesNotThrow(() => safeUnlink(missing));
82
+ });
83
+
84
+ test("safeUnlink: removes an existing file", () => {
85
+ const p = path.join(os.tmpdir(), `nb-safeunlink-${process.pid}-${Date.now()}`);
86
+ fs.writeFileSync(p, "x");
87
+ safeUnlink(p);
88
+ assert.strictEqual(fs.existsSync(p), false);
89
+ });
10
90
 
11
91
  test("parseSHA256Line: returns hash for matching filename in single-line content", () => {
12
92
  const content = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 nano-brain-linux-amd64\n";
package/npm/run.js CHANGED
@@ -9,6 +9,23 @@ const os = require("os");
9
9
  const binName = os.platform() === "win32" ? "nano-brain.exe" : "nano-brain";
10
10
  const binPath = path.join(__dirname, binName);
11
11
 
12
+ function runBinary(bin) {
13
+ try {
14
+ execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
15
+ } catch (e) {
16
+ // A numeric status means the binary ran and chose that exit code (it has
17
+ // already produced its own output). A non-numeric status means we could
18
+ // not spawn it at all (arch mismatch, missing libc, ETXTBSY, perms) —
19
+ // surface that, since stdio:"inherit" shows nothing for a spawn failure.
20
+ if (typeof e.status !== "number") {
21
+ process.stderr.write(`Error: failed to execute binary at ${bin}: ${e.message}\n`);
22
+ }
23
+ process.exit(e.status || 1);
24
+ }
25
+ process.exit(0);
26
+ }
27
+
28
+ // Explicit override wins and skips any download.
12
29
  const envBin = process.env.NANO_BRAIN_BIN;
13
30
  if (envBin && envBin.trim() !== "") {
14
31
  const trimmed = envBin.trim();
@@ -16,26 +33,30 @@ if (envBin && envBin.trim() !== "") {
16
33
  process.stderr.write(`Error: NANO_BRAIN_BIN points to ${trimmed} which does not exist. Unset the variable or correct the path.\n`);
17
34
  process.exit(1);
18
35
  }
19
- const mode = fs.statSync(trimmed).mode;
20
- if ((mode & 0o111) === 0) {
36
+ if ((fs.statSync(trimmed).mode & 0o111) === 0) {
21
37
  process.stderr.write(`Error: NANO_BRAIN_BIN points to ${trimmed} which is not executable. Run: chmod +x ${trimmed}\n`);
22
38
  process.exit(1);
23
39
  }
24
- try { execFileSync(trimmed, process.argv.slice(2), { stdio: "inherit" }); }
25
- catch (e) { process.exit(e.status || 1); }
26
- process.exit(0);
40
+ runBinary(trimmed);
41
+ return; // runBinary exits; explicit return makes the branches structurally exclusive
27
42
  }
28
43
 
29
- if (!fs.existsSync(binPath)) {
30
- console.error("nano-brain binary not found at " + binPath + ".");
31
- console.error("The postinstall script may have failed during npm install.");
32
- console.error("Try: npm install -g @nano-step/nano-brain --foreground-scripts");
33
- console.error("Or build from source: CGO_ENABLED=0 go build -o npm/nano-brain ./cmd/nano-brain");
34
- process.exit(1);
44
+ if (fs.existsSync(binPath)) {
45
+ runBinary(binPath);
46
+ return;
35
47
  }
36
48
 
37
- try {
38
- execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" });
39
- } catch (e) {
40
- process.exit(e.status || 1);
41
- }
49
+ // The binary is missing — the postinstall download either failed (offline /
50
+ // proxy) or was not persisted by the package manager (some npm/node versions
51
+ // discard postinstall-created files, #594). Download it now, at run time, where
52
+ // the write always persists, then run. Progress goes to stderr.
53
+ process.stderr.write("nano-brain: binary not present; downloading on first run...\n");
54
+ require("./postinstall")
55
+ .ensureBinary()
56
+ .then((bin) => runBinary(bin))
57
+ .catch((err) => {
58
+ const msg = err && err.message ? err.message : String(err);
59
+ process.stderr.write(`nano-brain: could not obtain the binary: ${msg}\n`);
60
+ process.stderr.write("Fix: set NANO_BRAIN_BIN=/path/to/nano-brain, or build from source: CGO_ENABLED=0 go build -o npm/nano-brain ./cmd/nano-brain\n");
61
+ process.exit(1);
62
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.7.1102",
3
+ "version": "2026.7.1104",
4
4
  "description": "Persistent memory and code intelligence for AI coding agents",
5
5
  "bin": {
6
6
  "nano-brain": "npm/run.js"