@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,285 @@
1
+ /**
2
+ * Keep a locally indexed folder's graph current by rebuilding it after the folder goes
3
+ * quiet.
4
+ *
5
+ * This is the local half of what the watcher does. For a cloud-indexed folder the watcher
6
+ * uploads each changed file as it sees it. For a locally indexed one there is nothing to
7
+ * upload: the engine reads the files itself, works out what changed, and rebuilds. So the
8
+ * shape is different on purpose. One build per quiet period, for the whole folder, rather
9
+ * than one action per file.
10
+ *
11
+ * **Why a settle delay and not a build per event.** Saving a file in an editor can produce
12
+ * several events, a formatter on save produces more, and a `git checkout` produces one per
13
+ * file in the tree. A build per event would mean hundreds of builds for one branch switch.
14
+ * Waiting for the folder to stop changing collapses all of them into the single build that
15
+ * was actually needed.
16
+ *
17
+ * **Why it coalesces instead of queueing.** While a build runs, more edits arrive. Those
18
+ * edits need A build, not one build each: the engine compares the whole folder against what
19
+ * it last recorded, so one run picks up everything that happened during the previous run.
20
+ * So a folder has at most one build running and at most one more wanted, and the wanted one
21
+ * is started when the running one finishes. The engine's own lock is the backstop for a
22
+ * build somebody types by hand at the same time, not the plan for this.
23
+ *
24
+ * **Nothing here decides what changed.** The engine does, from content hashes, which is why
25
+ * the watcher does not have to track hashes for a local folder the way it does for an
26
+ * uploaded one.
27
+ */
28
+ const localEngine = require("./localEngine");
29
+ const color = require("./color");
30
+
31
+ /**
32
+ * How long a folder must be quiet before it is rebuilt. Longer than the watcher's per-file
33
+ * debounce of 2s, because a build costs more than an upload of one file and a branch switch
34
+ * takes a moment to finish writing.
35
+ */
36
+ const SETTLE_MS = Number(process.env.NIRO_LOCAL_BUILD_SETTLE_MS) || 3000;
37
+
38
+ /**
39
+ * How often a watched folder is listed again, to catch a change no file event reported (rank 176).
40
+ * fs.watch drops events (recursive watches especially, some editors and filesystems), and a file
41
+ * added while an event was lost was not built until some later change arrived. Five minutes:
42
+ * a listing is a walk and one stat per file, no file is read, so it costs little, and a graph at most
43
+ * five minutes behind a lost event is the trade. `NIRO_LOCAL_RECHECK_MS` changes it, read when the
44
+ * watch starts, not when this file loads, so a test or a person can set it at any time.
45
+ */
46
+ const RECHECK_MS = 5 * 60_000;
47
+
48
+ /** Per folder: the settle timer, whether a build is running, whether another is wanted. */
49
+ const folders = new Map();
50
+
51
+ /** Folders already known to be locally indexed or not, so the engine is asked once each. */
52
+ const localities = new Map();
53
+
54
+ /** Folders we have already complained about not being able to run the engine for. */
55
+ const complained = new Set();
56
+
57
+ function stateFor(dir) {
58
+ let state = folders.get(dir);
59
+ if (!state) {
60
+ state = { timer: null, running: false, wantedAgain: false, list: null, listed: null, recheckTimer: null };
61
+ folders.set(dir, state);
62
+ }
63
+ return state;
64
+ }
65
+
66
+ /**
67
+ * Is this folder indexed by the local engine?
68
+ *
69
+ * Asked once per folder and remembered, because the watcher would otherwise spawn a
70
+ * process per file event. Asked of the engine rather than worked out here: `status --check`
71
+ * prints nothing and answers in its exit code, so this is a yes or a no and not a string to
72
+ * match. Matching the engine's prose would break the first time somebody improved it.
73
+ *
74
+ * An engine that cannot be run at all answers "not local", so a machine without it keeps
75
+ * the cloud behaviour instead of losing its watcher.
76
+ */
77
+ async function isLocallyIndexed(dir) {
78
+ if (localities.has(dir)) return localities.get(dir);
79
+ let local = false;
80
+ try {
81
+ await localEngine.run(["status", "--path", dir, "--check"]);
82
+ local = true;
83
+ } catch {
84
+ local = false;
85
+ }
86
+ localities.set(dir, local);
87
+ return local;
88
+ }
89
+
90
+ /** Forget what is remembered about a folder. For tests, and for a folder that is released. */
91
+ function forget(dir) {
92
+ const state = folders.get(dir);
93
+ if (state && state.timer) clearTimeout(state.timer);
94
+ if (state && state.recheckTimer) clearInterval(state.recheckTimer);
95
+ folders.delete(dir);
96
+ localities.delete(dir);
97
+ complained.delete(dir);
98
+ }
99
+
100
+ /**
101
+ * Note that something in this folder changed, and rebuild it once the folder is quiet.
102
+ *
103
+ * Returns immediately. Deliberately not awaited by the caller: the watcher's event handler
104
+ * must never be held open by a build, or events arriving during it are delayed behind it.
105
+ */
106
+ function noteChange(dir, { settleMs = SETTLE_MS, log = console.log, hint } = {}) {
107
+ const state = stateFor(dir);
108
+ if (state.running) {
109
+ // A build is already going. It may not see this edit, since it reads the files once at
110
+ // the start, so another one is owed. One is enough however many edits land meanwhile.
111
+ state.wantedAgain = true;
112
+ return;
113
+ }
114
+ if (state.timer) clearTimeout(state.timer);
115
+ state.timer = setTimeout(() => {
116
+ state.timer = null;
117
+ runBuild(dir, { settleMs, log, hint });
118
+ }, settleMs);
119
+ }
120
+
121
+ /**
122
+ * The engine's own prefix when it says something is wrong with its graph's age.
123
+ *
124
+ * A cross-repository contract, and the only string this file takes from the engine. The engine
125
+ * writes two of these on the first tool call of a session, one for a graph that is behind the
126
+ * files and one for a folder that has never been built
127
+ * (`niro_local/mcp/freshness.py`, `Staleness.notice`), and a test at each end pins the prefix
128
+ * so a rewording cannot quietly turn this off.
129
+ */
130
+ const BEHIND_MARKER = "NOTE FROM niro-local:";
131
+
132
+ /**
133
+ * Rebuild in the background when the engine reports its graph is behind the files.
134
+ *
135
+ * **Why the bridge does this at all, when the watcher already rebuilds.** The watcher is one
136
+ * process per machine and it can be absent: not installed yet, stopped, or removed by
137
+ * something else entirely (on 2026-10-16 this package's own test suite deleted it). When it is
138
+ * absent, nothing acts on the engine's notice, and a developer gets answers from yesterday's
139
+ * graph with a warning they did not ask for and cannot act on without stopping their work.
140
+ * Niraj, 2026-10-16: "yes bridge should kick off a background rebuild when the engine reports
141
+ * a stale graph".
142
+ *
143
+ * **Local folders only, deliberately.** This is reached from the bridge's local answer path,
144
+ * which exists only for a folder the engine on this machine indexes. A cloud project's
145
+ * automatic rebuild is its own setting (`niro init --auto-build on|off`, with its own quiet
146
+ * period) and nothing here touches it.
147
+ *
148
+ * Returns true when a build was scheduled. Never throws, never blocks the answer, and writes
149
+ * through `log`, which the bridge points at stderr: stdout is the JSON-RPC channel and one
150
+ * stray line on it ends the session.
151
+ */
152
+ function catchUpIfBehind(dir, result, { log = console.log } = {}) {
153
+ const content = Array.isArray(result && result.content) ? result.content : [];
154
+ const said = content.find(
155
+ (block) => block && typeof block.text === "string" && block.text.includes(BEHIND_MARKER)
156
+ );
157
+ if (!said) return false;
158
+ const state = stateFor(dir);
159
+ // Once per notice per folder per process. What this guards against is a stream of tool calls in
160
+ // ONE session each scheduling a build of the same folder: the engine repeats the notice for a
161
+ // branch with no index until the build has made one. A different notice is a different thing to
162
+ // catch up on: a session that moves to a second new branch needs a second build.
163
+ const notice = said.text.split("\n").find((line) => line.includes(BEHIND_MARKER));
164
+ if (state.caughtUp === notice) return false;
165
+ state.caughtUp = notice;
166
+ log(`the local graph is behind the files; rebuilding ${dir} in the background`);
167
+ noteChange(dir, { settleMs: 0, log });
168
+ return true;
169
+ }
170
+
171
+ /** What to do after a failed build, said under the reason. A caller with its own retry passes its own. */
172
+ const FIX_THEN_SAVE = "Edits are not reaching the graph. Fix this, then save a file to retry.";
173
+
174
+ /**
175
+ * Why the engine failed, in its own words. niro-local writes a refusal such as "... is not part of
176
+ * any project" to standard output, and only a crash or a licence refusal to standard error, so a
177
+ * reason read from standard error alone was blank for every refusal (VP-rel-watch N1).
178
+ */
179
+ function failureReason(err) {
180
+ if (!err || err.exitCode === undefined) return (err && err.message) || String(err);
181
+ const said = err.stderr || err.stdout || "no reason given";
182
+ return `niro-local exited ${err.exitCode}: ${said}`;
183
+ }
184
+
185
+ async function runBuild(dir, { settleMs = SETTLE_MS, log = console.log, hint = FIX_THEN_SAVE } = {}) {
186
+ const state = stateFor(dir);
187
+ state.running = true;
188
+ state.wantedAgain = false;
189
+ // What the folder looked like as this build started, for the periodic re-check: a change after
190
+ // this point either comes with an event (and another build) or is what the re-check is for.
191
+ if (state.list) state.listed = listOrNull(state.list);
192
+ try {
193
+ // Licensed, because this is the one call here that indexes. A released engine refuses to
194
+ // build without a lease, so the lease has to travel with the build rather than be fetched
195
+ // once at startup: a watcher is up for hours and a lease lasts an hour.
196
+ const out = await localEngine.runLicensed(["build", "--path", dir], { log });
197
+ // The engine's own report, printed rather than parsed. It is already written to be
198
+ // read, and a parser here would go stale the next time a line is reworded.
199
+ for (const line of out.split("\n")) {
200
+ if (line.trim()) log(` ${line.trimEnd()}`);
201
+ }
202
+ } catch (err) {
203
+ // Loud but not fatal, and said once per folder: a watcher that cannot build is useless
204
+ // and the developer has to know, but one message per file event is unreadable.
205
+ if (!complained.has(dir)) {
206
+ complained.add(dir);
207
+ const [first, ...rest] = failureReason(err).split("\n");
208
+ log(color.red(` Could not rebuild ${dir}: ${first}`));
209
+ for (const line of rest) if (line.trim()) log(color.red(` ${line.trimEnd()}`));
210
+ log(color.yellow(` ${hint}`));
211
+ }
212
+ } finally {
213
+ state.running = false;
214
+ }
215
+ if (state.wantedAgain) {
216
+ // Edits arrived while that build was running. Go round once more, after the same quiet
217
+ // period, so a long stream of edits still settles instead of building back to back.
218
+ state.wantedAgain = false;
219
+ noteChange(dir, { settleMs, log, hint });
220
+ }
221
+ }
222
+
223
+ function listOrNull(list) {
224
+ try {
225
+ return list();
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Re-check a watched folder every `intervalMs`, and rebuild it when its listing changed with no file
233
+ * event to say so (rank 176). `list` returns a digest of the folder's file names, sizes and
234
+ * modification times, or null when the folder cannot be listed (a renamed or unmounted folder is
235
+ * unknown, never "changed"). The digest is compared with the one taken when the last build started.
236
+ *
237
+ * Never two builds at once: a tick does nothing while a build runs or is due, since that build lists
238
+ * the folder itself when it starts, and a change goes through `noteChange`, the same settle and
239
+ * coalescing a file event takes. The engine's own lock still stops a build typed by hand from
240
+ * overlapping. Returns a function that stops the re-check; `forget` stops it too.
241
+ */
242
+ function startRecheck(dir, { intervalMs, list, log = console.log } = {}) {
243
+ const every = usableInterval(intervalMs) || usableInterval(Number(process.env.NIRO_LOCAL_RECHECK_MS)) || RECHECK_MS;
244
+ const state = stateFor(dir);
245
+ if (state.recheckTimer) clearInterval(state.recheckTimer);
246
+ state.list = list;
247
+ state.recheckTimer = setInterval(() => recheck(dir, { log }), every);
248
+ // The watch keeps the process up on its own; this timer must not, or a test or a stopped watch hangs.
249
+ if (state.recheckTimer.unref) state.recheckTimer.unref();
250
+ return () => {
251
+ clearInterval(state.recheckTimer);
252
+ state.recheckTimer = null;
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The interval when it is at least a second, else undefined (the default is used). A negative or tiny
258
+ * value passed `Number(...) ||` and `setInterval` ran it every millisecond: 187 listings in 200 ms (VP9).
259
+ */
260
+ const RECHECK_FLOOR_MS = 1000;
261
+ function usableInterval(ms) {
262
+ return Number.isFinite(ms) && ms >= RECHECK_FLOOR_MS ? ms : undefined;
263
+ }
264
+
265
+ /** One re-check. Returns true when it asked for a build. */
266
+ function recheck(dir, { log = console.log } = {}) {
267
+ const state = folders.get(dir);
268
+ if (!state || !state.list || state.running || state.timer) return false;
269
+ const now = listOrNull(state.list);
270
+ if (now === null) return false;
271
+ if (state.listed === null) {
272
+ // No build has listed the folder yet (the startup build could not list it): start from here.
273
+ state.listed = now;
274
+ return false;
275
+ }
276
+ if (now === state.listed) return false;
277
+ log(` ${color.yellow("\u21bb")} ${dir} changed with no file event seen; rebuilding`);
278
+ noteChange(dir, { log });
279
+ return true;
280
+ }
281
+
282
+ module.exports = {
283
+ noteChange, isLocallyIndexed, forget, catchUpIfBehind, failureReason, startRecheck, recheck, BEHIND_MARKER, SETTLE_MS,
284
+ RECHECK_MS, RECHECK_FLOOR_MS,
285
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * How to run the local engine, in one place.
3
+ *
4
+ * Two callers need it now: `audit-send`, which asks the engine for the findings it is
5
+ * holding, and the watcher, which asks it to rebuild a folder after an edit. Extracted
6
+ * rather than copied, because the one thing both must agree on is WHERE the engine is: a
7
+ * second copy of that resolution is a second thing to change when the engine moves, and a
8
+ * CLI that finds it for one command and not the other is worse than one that finds it for
9
+ * neither.
10
+ *
11
+ * Nothing here knows what the engine does. It spawns it, collects its output, and reports
12
+ * a non-zero exit as an error naming the command. Deliberately no retry: the two callers
13
+ * want different answers to a failure (the audit keeps its queue and tries later, the
14
+ * watcher lets the next file change trigger another build), so retrying here would take
15
+ * that decision away from both.
16
+ */
17
+ const { spawn } = require("child_process");
18
+ const lease = require("./lease");
19
+
20
+ /**
21
+ * Where the engine is.
22
+ *
23
+ * `NIRO_LOCAL_BIN` wins, so somebody working on the engine runs their own build. Then the
24
+ * download cache, which is where `niro engine install` puts it, because that is where it will
25
+ * be on a customer's machine. Then the bare name, which finds it on the PATH: that is how a
26
+ * developer of this repository runs it, and it is the behaviour this had before the cache
27
+ * existed, so nothing that works today stops working.
28
+ *
29
+ * No download from here, deliberately. This is called by the watcher to ask whether a folder
30
+ * is indexed, which happens on every file event, and a 60 MB download must be something a
31
+ * person started rather than a side effect of saving a file.
32
+ */
33
+ function enginePath() {
34
+ const engine = require("./engine");
35
+ const found = engine.resolve();
36
+ // The bare name is still the last resort: `spawn` searches the PATH itself, so an engine
37
+ // this cannot see a file for (an odd permission, a shim) can still be started by name.
38
+ return found ? found.path : engine.EXECUTABLE;
39
+ }
40
+
41
+ /**
42
+ * Run the engine and collect stdout.
43
+ *
44
+ * Rejects with the engine's stderr, which never carries a payload: the engine prints
45
+ * counts and messages there, and the source it holds only ever leaves through `--emit` on
46
+ * stdout. That distinction is why a failure can be logged safely.
47
+ */
48
+ function run(args, stdin, { timeoutMs } = {}) {
49
+ return spawnEngine(args, { stdin, timeoutMs });
50
+ }
51
+
52
+ /**
53
+ * Run the engine with a licence on a pipe of its own.
54
+ *
55
+ * Used for anything that indexes. The engine checks its licence before it writes anything, so
56
+ * without this a released engine refuses the build and says why. Two more pipes are opened and
57
+ * their numbers are put in the engine's environment; `lease.js` writes the lease down one of
58
+ * them and renews it while the engine runs, which matters for a build that takes minutes and
59
+ * will matter more for the MCP server, which runs for hours.
60
+ *
61
+ * Not used for the engine's other commands. Asking it whether a folder is indexed, reading
62
+ * what the audit is holding, and recording what was sent all write nothing to the graph and
63
+ * need no licence, and a lease request per file event would be a request per keystroke.
64
+ */
65
+ function runLicensed(args, { stdin, log, onStderrLine, withStderr = false } = {}) {
66
+ return spawnEngine(args, { stdin, licensed: true, log, onStderrLine, withStderr });
67
+ }
68
+
69
+ /**
70
+ * `onStderrLine`, when given, hears each complete stderr line as it arrives, for a caller
71
+ * that shows progress while the engine runs (`build --progress`, contract C3). stderr is still
72
+ * collected in full for the error, so a caller that streams loses nothing on failure.
73
+ *
74
+ * `withStderr` resolves `{ stdout, stderr }` instead of standard output alone. Added for
75
+ * `docs-send`, which needs the engine's counts line to know whether any folder is built with
76
+ * --llm when there is nothing to send; every other caller keeps the plain string.
77
+ */
78
+ /**
79
+ * `timeoutMs`, when given, stops the engine (SIGTERM) and rejects once that long has passed. For
80
+ * a caller on a timer, the watcher's folder list: one call that hangs must not stop every later
81
+ * round (VP-rel-watch N4), and the process is ended rather than left behind on each round.
82
+ */
83
+ function spawnEngine(args, { stdin, licensed = false, log, onStderrLine, withStderr = false, timeoutMs } = {}) {
84
+ return new Promise((resolve, reject) => {
85
+ // Three pipes as usual, plus the two the licence travels on. The engine is told their
86
+ // numbers rather than guessing: 3 and 4 are only free because this is where it is started.
87
+ const stdio = licensed
88
+ ? ["pipe", "pipe", "pipe", "pipe", "pipe"]
89
+ : ["pipe", "pipe", "pipe"];
90
+ const executable = enginePath();
91
+ const child = require("./engine").startHeld(executable, () =>
92
+ spawn(executable, args, {
93
+ stdio,
94
+ env: licensed ? lease.childEnvironment() : process.env,
95
+ })
96
+ );
97
+ const licence = licensed ? lease.attach(child, log ? { log } : {}) : null;
98
+ let timedOut = false;
99
+ const timer = timeoutMs > 0
100
+ ? setTimeout(() => {
101
+ timedOut = true;
102
+ child.kill();
103
+ reject(new Error(`${enginePath()} ${args.join(" ")} gave no answer in ${timeoutMs} ms, so it was stopped`));
104
+ }, timeoutMs)
105
+ : null;
106
+ if (timer && timer.unref) timer.unref();
107
+ let out = "";
108
+ let err = "";
109
+ child.stdout.on("data", (chunk) => (out += chunk));
110
+ let partial = "";
111
+ child.stderr.on("data", (chunk) => {
112
+ err += chunk;
113
+ if (!onStderrLine) return;
114
+ partial += chunk;
115
+ const lines = partial.split("\n");
116
+ partial = lines.pop();
117
+ for (const line of lines) onStderrLine(line);
118
+ });
119
+ child.on("error", (problem) => {
120
+ if (timer) clearTimeout(timer);
121
+ if (licence) licence.stop();
122
+ reject(new Error(`cannot run ${enginePath()}: ${problem.message}`));
123
+ });
124
+ child.on("close", (code) => {
125
+ // `lease.attach` also stops itself on close; this is here so the promise cannot settle
126
+ // while a renewal timer is still pending, whichever listener runs first.
127
+ if (licence) licence.stop();
128
+ if (timer) clearTimeout(timer);
129
+ if (timedOut) return; // already rejected
130
+ if (code === 0) {
131
+ resolve(withStderr ? { stdout: out, stderr: err } : out);
132
+ return;
133
+ }
134
+ // The message carries the command, because most callers log it and a log line
135
+ // without the command is unreadable. The engine's own words are also attached on
136
+ // their own, for the one caller that shows them to a developer: the engine writes
137
+ // the platform's install instructions there, and they must not arrive wrapped in
138
+ // our path and exit code.
139
+ const failure = new Error(
140
+ `${enginePath()} ${args.join(" ")} exited ${code}: ${err.trim()}`
141
+ );
142
+ failure.exitCode = code;
143
+ failure.stderr = err.trim();
144
+ failure.stdout = out.trim();
145
+ reject(failure);
146
+ });
147
+ // An engine that exits before reading what it is given (an old engine refusing an unknown
148
+ // command, a crash) closes its end of the pipe, and the write then fails with EPIPE. Without a
149
+ // listener that error crashed niro-cli (the independent check: 4 of 6 runs). The exit code
150
+ // already says what happened, through "close" above, so the write error itself is dropped.
151
+ child.stdin.on("error", () => {});
152
+ if (stdin !== undefined) child.stdin.end(stdin);
153
+ else child.stdin.end();
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Run the engine attached to this terminal, for an engine command a person typed through niro
159
+ * (`niro ui`, `niro env list`, `niro ask ...`; src/commands/engineRoutes.js).
160
+ *
161
+ * Unlike `spawnEngine`, nothing is collected: the engine reads and writes the person's own
162
+ * terminal, so a server (`ui`) prints its address and keeps running, a question asked by the
163
+ * engine reaches the person, and its output can be piped. Resolves `{ code, signal }` when the
164
+ * engine ends, or `{ error }` when it could not be started; it never rejects.
165
+ *
166
+ * `licensed` adds the two lease pipes, the same as `runLicensed`, so a released engine accepts
167
+ * the command. Only standard input, output and error are inherited; pipes 3 and 4 stay ours.
168
+ * Renewal warnings go to standard error, so a piped answer (`niro ask ... | jq`) stays clean.
169
+ *
170
+ * Ctrl-C: the terminal sends it to the engine and to this process together, so this process
171
+ * ignores it while the engine runs and lets the engine decide how to stop; quitting first would
172
+ * hand the prompt back while the engine is still printing. SIGTERM and SIGHUP, which only reach
173
+ * this process (`kill <pid>`, a closed terminal), are passed on to the engine.
174
+ */
175
+ function runAttached(args, { licensed = false, spawnFn = spawn, log = (line) => console.error(line) } = {}) {
176
+ return new Promise((resolve) => {
177
+ const executable = enginePath();
178
+ let child;
179
+ try {
180
+ child = require("./engine").startHeld(executable, () =>
181
+ spawnFn(executable, args, {
182
+ stdio: licensed ? ["inherit", "inherit", "inherit", "pipe", "pipe"] : "inherit",
183
+ env: licensed ? lease.childEnvironment() : process.env,
184
+ })
185
+ );
186
+ } catch (problem) {
187
+ resolve({ error: problem });
188
+ return;
189
+ }
190
+ const licence = licensed ? lease.attach(child, { log }) : null;
191
+ const handlers = {
192
+ SIGINT: () => {},
193
+ SIGTERM: () => child.kill("SIGTERM"),
194
+ SIGHUP: () => child.kill("SIGHUP"),
195
+ };
196
+ for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
197
+ let settled = false;
198
+ const finish = (result) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ for (const [signal, handler] of Object.entries(handlers)) process.removeListener(signal, handler);
202
+ if (licence) licence.stop();
203
+ resolve(result);
204
+ };
205
+ child.on("error", (problem) => finish({ error: problem }));
206
+ child.on("close", (code, signal) => finish({ code, signal }));
207
+ });
208
+ }
209
+
210
+ module.exports = { enginePath, run, runLicensed, runAttached };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Put one folder into the local engine and build its graph, for `niro start` step 4.
3
+ *
4
+ * Three engine commands and nothing of its own: `init --progress` adds the folder and builds it, `build --progress`
5
+ * builds a folder already added, both reporting `progress <done> <total>` on stderr (contract C3), and
6
+ * `projects --json` says whether a folder is already attached and indexed, which is how a
7
+ * second run of the wizard knows to skip the step. `status --check` is deliberately not used
8
+ * for that: it is true as soon as a folder is attached, built or not.
9
+ */
10
+ const path = require("path");
11
+
12
+ const git = require("./git");
13
+ const localEngine = require("./localEngine");
14
+ const branchDrift = require("./branchDrift");
15
+
16
+ /**
17
+ * Add `dir` to the local engine and build it, in one engine command: the engine's `init` adds the folder and builds
18
+ * it (Niraj, 2026-11-06: "niro-local init should also build it"), so nothing is built a second time after it.
19
+ *
20
+ * Every option of the engine's `init` passes through with the same meaning (Niraj, 2026-11-06: "support all option
21
+ * of niro-local init to niro init"): `project` joins or starts that project (left out, the engine chooses: a new git
22
+ * worktree of a repository it holds becomes that project's temporary copy, any other folder a project named after
23
+ * it; Q6, release finding 3), `branch` names the branch of a folder outside git (left out, git's own branch when
24
+ * git can name one; the engine reads `.git/HEAD` itself for a folder in git), `baseUrl` the address its service
25
+ * answers on (left out unless given: an empty one tells the engine to drop the address a temporary copy takes over
26
+ * from its source), `force` a folder with no build file. Run with the licence, because the engine's `init` builds,
27
+ * and with `--progress`, so `onProgress(done, total)` hears each `progress <done> <total>` line (contract C3).
28
+ *
29
+ * The engine decides whether a folder can be indexed and whether the line limit leaves room for it, and says why
30
+ * not before it writes anything. Those words are the answer, so a refusal rejects with them as the message, not our
31
+ * path and exit code (Niraj, 2026-11-06: "niro should ask niro-engine").
32
+ */
33
+ async function initAndBuild(dir, onProgress, {
34
+ runLicensed = localEngine.runLicensed, log = console.log, project, branch, baseUrl, force = false,
35
+ inspect = git.inspect,
36
+ } = {}) {
37
+ const args = ["init", "--path", dir];
38
+ const named = branch || inspect(dir).branch;
39
+ if (named) args.push("--branch", named);
40
+ if (project) args.push("--project", project);
41
+ if (baseUrl !== undefined && baseUrl !== null) args.push("--base-url", String(baseUrl));
42
+ if (force) args.push("--force");
43
+ args.push("--progress");
44
+ const startedAt = Date.now();
45
+ let out;
46
+ try {
47
+ out = await runLicensed(args, { log, onStderrLine: progressListener(onProgress) });
48
+ } catch (err) {
49
+ const words = [err && err.stdout, withoutProgress(err && err.stderr)].filter(Boolean).join("\n").trim();
50
+ if (!words) throw err;
51
+ const refused = new Error(words.replace(/^error:\s*/gm, ""));
52
+ refused.refused = true;
53
+ refused.exitCode = err.exitCode;
54
+ throw refused;
55
+ }
56
+ return { ...parseReport(out), seconds: Math.round((Date.now() - startedAt) / 1000), said: saidBeforeReport(out) };
57
+ }
58
+
59
+ /** A stderr listener passing `progress <done> <total>` lines to `onProgress`. */
60
+ function progressListener(onProgress) {
61
+ return (line) => {
62
+ const m = /^progress (\d+) (\d+)\s*$/.exec(line);
63
+ if (m && onProgress) onProgress(Number(m[1]), Number(m[2]));
64
+ };
65
+ }
66
+
67
+ /** Standard error without the progress lines, which are for the bar and say nothing about a failure. */
68
+ function withoutProgress(stderr) {
69
+ return String(stderr || "").split("\n").filter((line) => !/^progress \d+ \d+\s*$/.test(line)).join("\n").trim();
70
+ }
71
+
72
+ /**
73
+ * What the engine's `init` said before its build report: which project the folder went into, a `--branch` it
74
+ * ignored, the address its service answers on, a folder already added. [] when it said nothing else.
75
+ */
76
+ function saidBeforeReport(stdout) {
77
+ const lines = String(stdout || "").split("\n");
78
+ const report = lines.findIndex((line) => /^(full|incremental) build of /.test(line) || /^Project .+:$/.test(line));
79
+ return (report === -1 ? lines : lines.slice(0, report)).map((line) => line.trimEnd()).filter(Boolean);
80
+ }
81
+
82
+ /** Files and lines out of the build report on stdout; null for a number it did not print. */
83
+ function parseReport(stdout) {
84
+ const files = /files\s+([\d,]+)\s+seen/.exec(stdout || "");
85
+ const lines = /lines\s+([\d,]+)/.exec(stdout || "");
86
+ const num = (m) => (m ? Number(m[1].replace(/,/g, "")) : null);
87
+ return { files: num(files), lines: num(lines) };
88
+ }
89
+
90
+ /**
91
+ * Build `dir`, calling `onProgress(done, total)` per progress line. `extraArgs` are engine build options
92
+ * passed on as they are (`niro build --full --project <name>`).
93
+ */
94
+ async function build(dir, onProgress, { runLicensed = localEngine.runLicensed, log = console.log, extraArgs = [] } = {}) {
95
+ const startedAt = Date.now();
96
+ const out = await runLicensed(["build", "--path", dir, "--progress", ...extraArgs], {
97
+ log,
98
+ onStderrLine: progressListener(onProgress),
99
+ });
100
+ return { ...parseReport(out), seconds: Math.round((Date.now() - startedAt) / 1000) };
101
+ }
102
+
103
+ /**
104
+ * This folder as the engine knows it, `{ project, worktree }`, or null.
105
+ *
106
+ * Exact by default: a subfolder of an attached folder is not what the wizard attached.
107
+ * `inside: true` also accepts a folder below an attached one, which is what `niro status`
108
+ * run from a subdirectory means.
109
+ */
110
+ async function worktreeFor(dir, { run = localEngine.run, inside = false } = {}) {
111
+ const project = await branchDrift.projectHolding(dir, { run });
112
+ if (!project) return null;
113
+ const wanted = path.resolve(dir);
114
+ const holds = (w) => {
115
+ const folder = path.resolve(String(w.path));
116
+ return folder === wanted || (inside && wanted.startsWith(folder + path.sep));
117
+ };
118
+ const matches = (project.worktrees || []).filter((w) => w && holds(w));
119
+ // The nearest one when worktrees nest.
120
+ const worktree = matches.sort((a, b) => String(b.path).length - String(a.path).length)[0];
121
+ return worktree ? { project, worktree } : null;
122
+ }
123
+
124
+ module.exports = { initAndBuild, build, parseReport, saidBeforeReport, worktreeFor };