@niroai/niro 0.17.0 → 1.0.0

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 (177) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +6 -2
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Puts the local engine on the machine, once, and finds it every time after that.
3
+ *
4
+ * The engine is a compiled binary of its own, not part of this npm package. It is about 60 MB
5
+ * per platform and there are four of them, so shipping all four to every install would be
6
+ * 240 MB on a machine that needs one, and publishing it at all would hand the whole engine to
7
+ * anybody who runs `npm pack` (Niraj, 2026-10-14: the engine is "downloaded on first use from
8
+ * an authenticated endpoint, not published to npm").
9
+ *
10
+ * **The fast path first.** Every run after the first costs one `existsSync`. Nothing here
11
+ * touches the network when the engine is already in the cache, and nothing here is called
12
+ * when the CLI only needs to ASK the engine something: a download must be something a person
13
+ * started, not a side effect of the watcher checking whether a folder is indexed.
14
+ *
15
+ * **Where it comes from, and why the digest does not come with it.** The CLI asks a Niro
16
+ * service for a manifest naming the version, the address of the tarball for this platform and
17
+ * its SHA-256. The digest comes from the service, which is authenticated, and never from the
18
+ * host serving the download: a digest published beside the file it describes proves only that
19
+ * the two agree, so whoever controls the download would control both. With the digest from
20
+ * somewhere else, a tarball that has been replaced is refused before anything is unpacked.
21
+ *
22
+ * **One download at a time.** Two commands can start within a second of each other: the
23
+ * wizard and a watcher, or two terminals. So the download takes a lock, streams to a `.part`
24
+ * file, and the finished folder is put in place with a rename, which is atomic. A reader
25
+ * either sees the whole folder or does not see it.
26
+ */
27
+ const crypto = require("crypto");
28
+ const fs = require("fs");
29
+ const os = require("os");
30
+ const path = require("path");
31
+ const { spawnSync } = require("child_process");
32
+
33
+ const api = require("./api");
34
+ const color = require("./color");
35
+
36
+ /**
37
+ * Where the cache lives, beside the credentials and config the CLI already keeps.
38
+ *
39
+ * Worked out when it is used rather than when this file loads, which is how `receiptStore.js`
40
+ * does it too: a value fixed at load time cannot be repointed, and both a test and a machine
41
+ * with a small home partition need to repoint it.
42
+ */
43
+ function home() {
44
+ return process.env.NIRO_ENGINE_HOME || path.join(os.homedir(), ".niro", "bin");
45
+ }
46
+
47
+ /** The manifest route, on the service that knows what this account may run. */
48
+ const MANIFEST_PATH = "/api/local-engine/engine";
49
+
50
+ /** The name of the executable inside the tarball's folder. */
51
+ const EXECUTABLE = process.platform === "win32" ? "niro-local.exe" : "niro-local";
52
+
53
+ /**
54
+ * How long to wait for another process's download, and how long a lock may sit untouched
55
+ * before it is treated as left behind by a process that died.
56
+ *
57
+ * Read when they are used, not when this file loads. A value fixed at load time ignores an
58
+ * override set by whatever started the process, and a test that sets one is then testing the
59
+ * default while appearing to test the override. Not hypothetical: the first run of this
60
+ * file's own tests sat for five minutes on a 300-second default it had asked to be 300
61
+ * milliseconds.
62
+ */
63
+ function lockWaitMs() {
64
+ return Number(process.env.NIRO_ENGINE_LOCK_WAIT_MS) || 300_000;
65
+ }
66
+
67
+ function lockStaleMs() {
68
+ return Number(process.env.NIRO_ENGINE_LOCK_STALE_MS) || 600_000;
69
+ }
70
+
71
+ /**
72
+ * This machine, as the release names it.
73
+ *
74
+ * Five labels exist because the engine is compiled, and a compiled binary is per platform and
75
+ * per processor. Anything else is told plainly that there is no engine for it rather than
76
+ * being given one that cannot run: "no build for your platform" is a far better message than
77
+ * an exec failure. Windows is built for Intel only (2026-11-10).
78
+ */
79
+ function label(platform = process.platform, architecture = process.arch) {
80
+ const architectures = { x64: "x86_64", arm64: "arm64" };
81
+ const found = architectures[architecture];
82
+ if (platform === "darwin" && found) return `macos-${found}`;
83
+ if (platform === "linux" && found) return `linux-${found}`;
84
+ if (platform === "win32" && architecture === "x64") return "windows-x86_64";
85
+ const problem = new Error(
86
+ `There is no local engine for ${platform} ${architecture}. ` +
87
+ "It is built for macOS and Linux on Intel and ARM, and for Windows on Intel."
88
+ );
89
+ problem.code = "ENOENGINEFORPLATFORM";
90
+ throw problem;
91
+ }
92
+
93
+ /** Which service to ask. The lease comes from the same place, so one variable moves both. */
94
+ function manifestUrl() {
95
+ const base = process.env.NIRO_BILLING_URL || require("./lease").billingUrl();
96
+ return `${String(base).replace(/\/+$/, "")}${MANIFEST_PATH}`;
97
+ }
98
+
99
+ /** Where a given version's engine lives once it is unpacked. */
100
+ function versionDir(version, which = label()) {
101
+ return path.join(home(), String(version), `niro-local-${which}`);
102
+ }
103
+
104
+ /**
105
+ * The engine on this machine, or null.
106
+ *
107
+ * `NIRO_LOCAL_BIN` wins, because a developer working on the engine points it at their own
108
+ * build and nothing should quietly download over that. Then the cache for the version asked
109
+ * for, if one was named; then the newest version in the cache, so a CLI that cannot reach us
110
+ * still runs the engine it already has.
111
+ */
112
+ function installed(version = null) {
113
+ if (process.env.NIRO_LOCAL_BIN) return process.env.NIRO_LOCAL_BIN;
114
+ let which;
115
+ try {
116
+ which = label();
117
+ } catch {
118
+ return null;
119
+ }
120
+ if (version) {
121
+ const exact = path.join(versionDir(version, which), EXECUTABLE);
122
+ return fs.existsSync(exact) ? exact : null;
123
+ }
124
+ let versions = [];
125
+ try {
126
+ versions = fs.readdirSync(home());
127
+ } catch {
128
+ return null;
129
+ }
130
+ const usable = versions
131
+ .filter((name) => fs.existsSync(path.join(versionDir(name, which), EXECUTABLE)))
132
+ .sort(compareVersions);
133
+ return usable.length ? path.join(versionDir(usable[usable.length - 1], which), EXECUTABLE) : null;
134
+ }
135
+
136
+ /**
137
+ * A machine holds one version (Niraj, 2026-11-05: "we need to focus on that there is only one version on the
138
+ * system"). Once the published one is in place, every other version folder in the cache goes. A running engine keeps
139
+ * its file open, so one still answering a session finishes normally. A folder that will not go is left and
140
+ * never fails the install: the newest is the one started anyway. Dot files and downloads in progress are not
141
+ * version folders.
142
+ */
143
+ function removeOtherVersions(keep) {
144
+ let names = [];
145
+ try {
146
+ names = fs.readdirSync(home());
147
+ } catch {
148
+ return;
149
+ }
150
+ for (const name of names) {
151
+ if (name === String(keep) || name.startsWith(".") || name.includes(".part-")) continue;
152
+ // Only older ones, and only when no engine runs from them: a running engine reads files from its folder after it
153
+ // starts, and only the executable stays open (independent check: a read failed once 2.0.0 was deleted under it).
154
+ // A folder in use goes at a later start (`tidy`).
155
+ if (compareVersions(name, keep) >= 0 || inUse(name)) continue;
156
+ try {
157
+ if (fs.statSync(path.join(home(), name)).isDirectory()) {
158
+ fs.rmSync(path.join(home(), name), { recursive: true, force: true });
159
+ }
160
+ } catch {
161
+ // Left for the next install.
162
+ }
163
+ }
164
+ }
165
+
166
+ /** The newest version in the cache with an engine in it, by number, or null. */
167
+ function newestVersion(which = label()) {
168
+ let names = [];
169
+ try {
170
+ names = fs.readdirSync(home());
171
+ } catch {
172
+ return null;
173
+ }
174
+ const usable = names
175
+ .filter((name) => fs.existsSync(path.join(versionDir(name, which), EXECUTABLE)))
176
+ .sort(compareVersions);
177
+ return usable.length ? usable[usable.length - 1] : null;
178
+ }
179
+
180
+ /** The mark an engine started from this version's folder leaves while it runs. */
181
+ function marksIn(version) {
182
+ const dir = versionDir(version);
183
+ try {
184
+ return fs.readdirSync(dir).filter((name) => name.startsWith(".in-use-")).map((name) => path.join(dir, name));
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ function alive(pid) {
191
+ try {
192
+ process.kill(pid, 0);
193
+ return true;
194
+ } catch (err) {
195
+ return err.code === "EPERM";
196
+ }
197
+ }
198
+
199
+ /** Whether an engine runs from this version's folder. A mark whose process is gone is removed. */
200
+ function inUse(version) {
201
+ let running = false;
202
+ for (const mark of marksIn(version)) {
203
+ const pid = Number(path.basename(mark).slice(".in-use-".length));
204
+ if (pid && alive(pid)) running = true;
205
+ else fs.rmSync(mark, { force: true });
206
+ }
207
+ return running;
208
+ }
209
+
210
+ /** At every start: the older versions no engine runs from any more go, so one is on the machine. */
211
+ function tidy() {
212
+ if (process.env.NIRO_LOCAL_BIN) return;
213
+ try {
214
+ const newest = newestVersion();
215
+ if (newest) removeOtherVersions(newest);
216
+ } catch {
217
+ // Never in the way of starting an engine.
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Start an engine with `start()` (which returns the child process), marking its version's folder as in use for as
223
+ * long as the child runs. The starter's own mark covers the moment before the child has a pid. An engine from
224
+ * outside the cache (NIRO_LOCAL_BIN, the PATH) is started as it is.
225
+ */
226
+ function startHeld(executable, start) {
227
+ tidy();
228
+ const relative = path.relative(home(), executable);
229
+ const version = relative.startsWith("..") || path.isAbsolute(relative) ? null : relative.split(path.sep)[0];
230
+ if (!version || !fs.existsSync(executable)) return start();
231
+ const mark = (pid) => {
232
+ const file = path.join(path.dirname(executable), `.in-use-${pid}`);
233
+ try {
234
+ fs.writeFileSync(file, "");
235
+ } catch {
236
+ return () => {};
237
+ }
238
+ return () => fs.rmSync(file, { force: true });
239
+ };
240
+ const releaseOwn = mark(process.pid);
241
+ let child;
242
+ try {
243
+ child = start();
244
+ } catch (err) {
245
+ releaseOwn();
246
+ throw err;
247
+ }
248
+ if (child && child.pid) {
249
+ const release = mark(child.pid);
250
+ child.once("close", release);
251
+ child.once("error", release);
252
+ }
253
+ releaseOwn();
254
+ return child;
255
+ }
256
+
257
+ /**
258
+ * Bring the cached engine to the published version, for `niro upgrade`: the engine moves with the CLI, so an old
259
+ * one is not left running. Null, with nothing fetched, when there is none in the cache (a first download is a
260
+ * person's choice) or NIRO_LOCAL_BIN names somebody's own build.
261
+ */
262
+ async function upgradeInstalled({ log = console.log } = {}) {
263
+ if (process.env.NIRO_LOCAL_BIN || !installed()) return null;
264
+ return ensure({ log, latest: true });
265
+ }
266
+
267
+ /**
268
+ * Oldest first, by number: 0.10.0 is newer than 0.9.0, which sorting the names as text gets backwards. That
269
+ * would have run the OLDER engine for good after an upgrade, on a home the newer one had already opened, and an
270
+ * older engine picks the first of a folder's rows and writes one branch's files into another's index
271
+ * (independent check of niro-local, 5 of 5 homes). A name with no numbers sorts below every version.
272
+ */
273
+ function compareVersions(one, two) {
274
+ const parts = (name) => String(name).split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : -1));
275
+ const [a, b] = [parts(one), parts(two)];
276
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
277
+ const difference = (a[i] ?? 0) - (b[i] ?? 0);
278
+ if (difference) return difference;
279
+ }
280
+ return String(one).localeCompare(String(two));
281
+ }
282
+
283
+ /**
284
+ * The engine this machine would RUN, and where it came from, or null when there is none.
285
+ *
286
+ * **Why this sits beside `installed()`, which looks like the same question.** `installed()`
287
+ * answers "is there one in the download cache", which is what `niro engine install` needs: a
288
+ * developer whose package manager already put the engine on their PATH still wants `install`
289
+ * to fetch one into the cache. Everything else wants the engine that will actually be
290
+ * started, and that includes the plain name on the PATH, which is how the engine runs for
291
+ * anybody working on it. The two answers disagreeing is what made `niro engine status` say
292
+ * "not installed" on a machine where the engine runs perfectly well, reported 2026-10-16:
293
+ * the status line read the cache while `localEngine.run` used the PATH.
294
+ *
295
+ * `source` is `"env"` for `NIRO_LOCAL_BIN`, `"cache"` for the download cache, `"path"` for a
296
+ * name found on the PATH.
297
+ */
298
+ function resolve() {
299
+ const named = process.env.NIRO_LOCAL_BIN;
300
+ if (named) return { path: named, source: "env" };
301
+ const cached = installed();
302
+ if (cached) return { path: cached, source: "cache" };
303
+ // The platform question comes before the PATH, and takes no VERSION at all. Both are
304
+ // answers to the same trap: this says "there is an engine", and the PATH cannot support a
305
+ // narrower claim than that. A version was asked for and silently ignored once (`installed`
306
+ // takes one), so the parameter is gone rather than documented. And on a platform we publish
307
+ // no engine for, something called niro-local on the PATH is somebody else's program:
308
+ // reporting it would turn an honest "not installed" into a confident wrong answer. A
309
+ // developer's own build on such a machine is still named by NIRO_LOCAL_BIN above, which is
310
+ // exactly what that variable is for.
311
+ try {
312
+ label();
313
+ } catch {
314
+ return null;
315
+ }
316
+ const onPath = onThePath(EXECUTABLE);
317
+ return onPath ? { path: onPath, source: "path" } : null;
318
+ }
319
+
320
+ /** Why tar failed: its own words, else why it never started (a missing tar.exe), else its exit code. */
321
+ function unpackProblem(result) {
322
+ const said = (result.stderr || "").trim();
323
+ if (said) return said;
324
+ if (result.error) return result.error.message;
325
+ return `tar exited with ${result.status}`;
326
+ }
327
+
328
+ /**
329
+ * Where a bare command name resolves on this machine's PATH, or null.
330
+ *
331
+ * Not needed to RUN the engine: `spawn` resolves a bare name itself. It is needed to SAY
332
+ * where the engine is, and to answer "is there one at all" before trying to run it.
333
+ *
334
+ * There is no extension list and no PATHEXT branch: the one name asked for is `EXECUTABLE`,
335
+ * which already carries `.exe` on Windows. On Windows `fs.accessSync(..., X_OK)` does not
336
+ * test an execute bit (Node documents it as behaving like `F_OK`), so there any file of that
337
+ * name counts, which is what Windows itself does with a `.exe`.
338
+ *
339
+ * Three details that each stop a wrong answer. The `isFile` check, because every directory
340
+ * passes an executable test, so a FOLDER called niro-local would otherwise be reported as the
341
+ * engine. `path.resolve`, so a relative PATH entry (`.`) gives an absolute answer rather than
342
+ * the bare name, which names no file once printed. And empty PATH entries are dropped rather
343
+ * than read as the current directory, which POSIX allows and nobody wants here: picking up a
344
+ * binary from whatever folder the developer happens to be in is not a feature.
345
+ *
346
+ * First match wins, which is what the shell does and what `spawn` did before anything called
347
+ * this. Two engines on one PATH is a real situation, an old one in /usr/local/bin and a new
348
+ * one in ~/.local/bin, and the one that would run is the one to report.
349
+ */
350
+ function onThePath(name) {
351
+ const entries = String(process.env.PATH || "")
352
+ .split(path.delimiter)
353
+ .filter(Boolean);
354
+ for (const entry of entries) {
355
+ const candidate = path.resolve(entry, name);
356
+ try {
357
+ fs.accessSync(candidate, fs.constants.X_OK);
358
+ if (fs.statSync(candidate).isFile()) return candidate;
359
+ } catch {
360
+ // Not there, or not runnable by us. Try the next one.
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /** What the service says this account should run. */
367
+ /**
368
+ * The statuses that are the service ANSWERING rather than breaking.
369
+ *
370
+ * Every one of them is a sentence a developer can act on, written by the service: this plan
371
+ * does not include the local engine (403), this account is stopped (403), no engine is
372
+ * published for your platform yet (503), this deployment has no signing key (503), we do not
373
+ * build for your platform (400), we do not know who you are (401). None of them is a fault to
374
+ * investigate, so none of them should arrive looking like one. Anything else, 500 included,
375
+ * keeps the route and the status code, because then somebody has to debug it.
376
+ */
377
+ const ANSWERED_STATUSES = new Set([400, 401, 403, 503]);
378
+
379
+ /** The sentence the service sent, or null when it sent none. */
380
+ function serviceSaid(err) {
381
+ const body = err && err.body;
382
+ if (!body) return null;
383
+ if (typeof body === "string") return body.trim() || null;
384
+ const said = body.error || body.message;
385
+ return typeof said === "string" && said.trim() ? said.trim() : null;
386
+ }
387
+
388
+ /**
389
+ * Turn a refusal from the manifest endpoint into words, or return null to leave it alone.
390
+ *
391
+ * Why this exists: the HTTP layer names the route and the status in every failure, which is
392
+ * right for a fault and wrong for an answer. Asking for an engine before one is published
393
+ * printed `Error: GET /api/local-engine/engine failed: No local engine is published for
394
+ * linux-x86_64 yet` and then `(HTTP 503)`, so a correct, expected answer read as a crash in
395
+ * our code and handed the reader a route they can do nothing with (reported 2026-10-16).
396
+ */
397
+ function manifestRefusal(err) {
398
+ if (!err || !ANSWERED_STATUSES.has(err.status)) return null;
399
+ const said = serviceSaid(err);
400
+ if (!said) return null;
401
+ const refusal = new Error(/[.!?]$/.test(said) ? said : `${said}.`);
402
+ // The code is how the command tells an answer from a fault. The status is deliberately NOT
403
+ // carried over: the printer appends "(HTTP n)" to anything that has one.
404
+ refusal.code = "EENGINEREFUSED";
405
+ return refusal;
406
+ }
407
+
408
+ async function manifest({ which = label() } = {}) {
409
+ let data;
410
+ try {
411
+ data = await api.request("GET", MANIFEST_PATH, {
412
+ apiUrl: process.env.NIRO_BILLING_URL || require("./lease").billingUrl(),
413
+ query: { label: which },
414
+ });
415
+ } catch (err) {
416
+ throw manifestRefusal(err) || err;
417
+ }
418
+ const version = data && (data.version || data.engine_version);
419
+ const url = data && data.url;
420
+ const digest = data && (data.sha256 || data.digest);
421
+ if (!version || !url || !digest) {
422
+ throw new Error("The engine manifest came back incomplete.");
423
+ }
424
+ return { version: String(version), url: String(url), sha256: String(digest).toLowerCase() };
425
+ }
426
+
427
+ /**
428
+ * Take the download lock, or wait for whoever has it.
429
+ *
430
+ * A directory is the lock, because `mkdir` either creates it or fails, in one step, on every
431
+ * filesystem this runs on. A lock whose directory has not been touched for a while is treated
432
+ * as left behind by a process that died, which is the case this has to survive: a download
433
+ * interrupted by Ctrl-C must not stop the next one for ever.
434
+ */
435
+ async function withLock(version, run) {
436
+ const lock = path.join(home(), `.lock-${version}`);
437
+ fs.mkdirSync(home(), { recursive: true });
438
+ const until = Date.now() + lockWaitMs();
439
+ for (;;) {
440
+ try {
441
+ fs.mkdirSync(lock);
442
+ break;
443
+ } catch (err) {
444
+ if (err.code !== "EEXIST") throw err;
445
+ let age = Infinity;
446
+ try {
447
+ age = Date.now() - fs.statSync(lock).mtimeMs;
448
+ } catch {
449
+ continue; // it went away between the failure and the question
450
+ }
451
+ if (age > lockStaleMs()) {
452
+ fs.rmSync(lock, { recursive: true, force: true });
453
+ continue;
454
+ }
455
+ if (Date.now() > until) {
456
+ throw new Error(
457
+ "Another process has been downloading the local engine for too long. " +
458
+ `If nothing is, remove ${lock} and try again.`
459
+ );
460
+ }
461
+ await new Promise((resolve) => setTimeout(resolve, 250));
462
+ }
463
+ }
464
+ try {
465
+ return await run();
466
+ } finally {
467
+ fs.rmSync(lock, { recursive: true, force: true });
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Download to `into`, hashing the bytes as they arrive. Returns the digest.
473
+ *
474
+ * No credential is sent. The address came from the manifest and may be storage belonging to
475
+ * somebody else, so putting this machine's login token in that request would hand it over to
476
+ * whoever serves the file. Whatever authorises the download has to be in the address itself,
477
+ * which is what a signed URL is for.
478
+ */
479
+ async function downloadTo(url, into) {
480
+ const answer = await fetch(url);
481
+ if (!answer.ok || !answer.body) {
482
+ throw new Error(`Downloading the local engine failed: HTTP ${answer.status}`);
483
+ }
484
+ const hash = crypto.createHash("sha256");
485
+ const file = fs.createWriteStream(into);
486
+ try {
487
+ for await (const chunk of answer.body) {
488
+ hash.update(chunk);
489
+ if (!file.write(chunk)) {
490
+ await new Promise((resolve) => file.once("drain", resolve));
491
+ }
492
+ }
493
+ } finally {
494
+ await new Promise((resolve) => file.end(resolve));
495
+ }
496
+ return hash.digest("hex");
497
+ }
498
+
499
+ /**
500
+ * The engine, downloading it first if this machine does not have it.
501
+ *
502
+ * Returns the path to the executable. The caller is whatever is about to run a build: not the
503
+ * watcher asking whether a folder is indexed, and not the MCP bridge answering a question from
504
+ * the cloud, because neither of those is a reason to pull 60 MB down somebody's connection.
505
+ */
506
+ async function ensure({ log = console.log, which = label(), force = false, latest = false } = {}) {
507
+ // With an engine here this asks nobody, unless a person is upgrading (`latest`) or asked for a fresh download
508
+ // (`force`): the watcher comes through here.
509
+ const already = installed();
510
+ if (already && !force && !(latest && !process.env.NIRO_LOCAL_BIN)) return already;
511
+
512
+ const wanted = await manifest({ which });
513
+ const newestHere = newestVersion(which);
514
+ if (newestHere && compareVersions(newestHere, wanted.version) > 0) {
515
+ // Never an older version over a newer one: across a major version every start would be refused, and every
516
+ // install would fetch the older one again.
517
+ log(
518
+ `This machine has the local engine ${newestHere}, newer than the published ${wanted.version}, so it is kept ` +
519
+ `and nothing was fetched. To use ${wanted.version} instead, delete ${path.join(home(), newestHere)} and ` +
520
+ "run niro engine install."
521
+ );
522
+ removeOtherVersions(newestHere);
523
+ return installed(newestHere);
524
+ }
525
+ return withLock(wanted.version, async () => {
526
+ // Asked again under the lock: another process may have finished while this one waited,
527
+ // and unpacking the same tarball over its own folder is the one thing the rename cannot
528
+ // make safe.
529
+ const meanwhile = installed(wanted.version);
530
+ if (meanwhile && force && inUse(wanted.version)) {
531
+ // `--force` unpacks again, but never over the folder a running engine reads from (merge of the plan-page
532
+ // release with the one-version work: deleting it under a running bridge or watcher broke their reads).
533
+ log(
534
+ `The local engine ${wanted.version} is running (the assistant's bridge or the background watcher), so it ` +
535
+ "was not downloaded again. Close them and run niro engine install --force again."
536
+ );
537
+ removeOtherVersions(wanted.version);
538
+ return meanwhile;
539
+ }
540
+ if (meanwhile && !force) {
541
+ removeOtherVersions(wanted.version);
542
+ return meanwhile;
543
+ }
544
+
545
+ const target = versionDir(wanted.version, which);
546
+ const staging = `${target}.part-${process.pid}`;
547
+ const tarball = path.join(
548
+ home(),
549
+ `niro-local-${which}-${wanted.version}.part-${process.pid}`
550
+ );
551
+ fs.mkdirSync(path.dirname(target), { recursive: true });
552
+ log(color.gray(`Downloading the local engine ${wanted.version} for ${which}...`));
553
+ try {
554
+ const got = await downloadTo(wanted.url, tarball);
555
+ if (got !== wanted.sha256) {
556
+ // Refused before anything is unpacked, which is the whole reason the digest comes
557
+ // from the service rather than from the download.
558
+ throw new Error(
559
+ "The local engine that arrived is not the one Niro published, so it was not " +
560
+ "installed. Try again, and tell us if it keeps happening."
561
+ );
562
+ }
563
+ // A checksum against the service's digest, and only that: the engine is not signed, so
564
+ // nothing here may say it is.
565
+ log("Checking SHA-256… ok");
566
+ fs.mkdirSync(staging, { recursive: true });
567
+ // Windows: the tar.exe Windows ships (10 and later), named in full. A GNU tar from Git for Windows
568
+ // earlier on the PATH reads "C:\..." as a remote host and fails.
569
+ const tar = process.platform === "win32" ? require("./windowsPaths").system32("tar.exe") : "tar";
570
+ const untarred = spawnSync(tar, ["-xzf", tarball, "-C", staging, "--strip-components", "1"], {
571
+ encoding: "utf8",
572
+ });
573
+ if (untarred.status !== 0) {
574
+ throw new Error(`Unpacking the local engine failed: ${unpackProblem(untarred)}`);
575
+ }
576
+ const binary = path.join(staging, EXECUTABLE);
577
+ if (!fs.existsSync(binary)) {
578
+ throw new Error(`The local engine's archive holds no ${EXECUTABLE}.`);
579
+ }
580
+ fs.chmodSync(binary, 0o755);
581
+ // Atomic: a reader sees the whole folder or none of it. A folder already there is one
582
+ // another process put down while this one worked, and is kept rather than replaced,
583
+ // unless this call was asked to replace it.
584
+ if (force) fs.rmSync(target, { recursive: true, force: true });
585
+ try {
586
+ fs.renameSync(staging, target);
587
+ } catch (err) {
588
+ if (err.code !== "ENOTEMPTY" && err.code !== "EEXIST") throw err;
589
+ fs.rmSync(staging, { recursive: true, force: true });
590
+ }
591
+ removeOtherVersions(wanted.version);
592
+ return path.join(target, EXECUTABLE);
593
+ } finally {
594
+ fs.rmSync(tarball, { force: true });
595
+ fs.rmSync(staging, { recursive: true, force: true });
596
+ }
597
+ });
598
+ }
599
+
600
+ module.exports = {
601
+ EXECUTABLE,
602
+ startHeld,
603
+ upgradeInstalled,
604
+ home,
605
+ lockStaleMs,
606
+ lockWaitMs,
607
+ MANIFEST_PATH,
608
+ ensure,
609
+ manifestRefusal,
610
+ installed,
611
+ label,
612
+ onThePath,
613
+ unpackProblem,
614
+ resolve,
615
+ manifest,
616
+ manifestUrl,
617
+ versionDir,
618
+ withLock,
619
+ };
@@ -202,7 +202,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
202
202
  */
203
203
  /** Current nesting depth as spaces. \r-repainted lines bypass withIndent's console wrappers. */
204
204
  function nestPad() {
205
- return " ".repeat(require("./nestedOutput").currentIndent());
205
+ return require("./nestedOutput").currentPad();
206
206
  }
207
207
 
208
208
  function buildManifest(rootDir, opts = {}) {
@@ -573,6 +573,7 @@ module.exports = {
573
573
  isConfigName,
574
574
  chunk,
575
575
  doInitialSync,
576
+ uploadBatchWithRetry,
576
577
  aggregateGuard,
577
578
  SOURCE_EXTENSIONS,
578
579
  STRUCTURAL_FILENAMES,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Can the local engine on this machine serve a graph?
3
+ *
4
+ * The engine keeps its graph in one SQLite file in its own home folder, so there is nothing to
5
+ * install or start: the question is only whether that file can be opened (a full disk, a home
6
+ * folder it may not write to). It is put to the engine itself, `niro-local db check`, which
7
+ * prints nothing when it can and says why in its own words when it cannot, so the reasons live
8
+ * in one place rather than in a JavaScript copy that would drift.
9
+ *
10
+ * **What was here before.** The engine's graph was a Memgraph container, many company laptops
11
+ * forbid a container runtime, and this file offered to index in Niro's cloud instead after
12
+ * asking (Niraj, 2026-10-16). With the graph in SQLite that machine no longer exists, and Niraj
13
+ * decided on 2026-10-31 to "drop the cloud offer altogether and remove the docker wording" (D2
14
+ * of niro-local's docs/usecases/sqlite-graph-store.md). A `cloudIndexingApproved` answer an
15
+ * older version saved in ~/.niro/config.json is simply never read again.
16
+ */
17
+ const localEngine = require("./localEngine");
18
+ const engine = require("./engine");
19
+
20
+ /**
21
+ * Returns `{ ok: true }`, or `{ ok: false, problem }` where `problem` is the engine's own
22
+ * message, or `{ ok: false, missing: true, problem }` when there is no engine here to ask, which
23
+ * is not a problem with the graph and must not be reported as one.
24
+ */
25
+ async function graphDatabaseUsable() {
26
+ if (!engine.resolve()) {
27
+ return { ok: false, missing: true, problem: "The local engine is not installed on this machine." };
28
+ }
29
+ try {
30
+ await localEngine.run(["db", "check"]);
31
+ return { ok: true };
32
+ } catch (err) {
33
+ // The engine's own stderr when there is any. Its `error:` prefix is for a person reading a
34
+ // command's output and is dropped here, since the caller prints the lines itself.
35
+ const words = (err && err.stderr) || (err && err.message) || String(err);
36
+ return { ok: false, problem: words.replace(/^error:\s*/m, "").trim() };
37
+ }
38
+ }
39
+
40
+ module.exports = { graphDatabaseUsable };