@niroai/niro 0.16.0 → 0.18.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.
- package/CHANGELOG.md +149 -0
- package/README.md +123 -302
- package/bin/niro.js +12 -1
- package/docs/agent-antigravity.md +37 -0
- package/docs/agent-claude-code.md +48 -0
- package/docs/agent-claude-desktop.md +47 -0
- package/docs/agent-cline.md +42 -0
- package/docs/agent-codex.md +45 -0
- package/docs/agent-copilot-cli.md +42 -0
- package/docs/agent-cursor.md +44 -0
- package/docs/agent-factory.md +42 -0
- package/docs/agent-gemini-cli.md +42 -0
- package/docs/agent-goose.md +42 -0
- package/docs/agent-kiro.md +37 -0
- package/docs/agent-opencode.md +37 -0
- package/docs/agent-vscode.md +45 -0
- package/docs/agent-windsurf.md +38 -0
- package/docs/answer-shapes.md +69 -0
- package/docs/builds-and-updates.md +68 -0
- package/docs/cloud-README.md +372 -0
- package/docs/configuring-a-folder.md +51 -0
- package/docs/connect-ci.md +24 -0
- package/docs/connect-overview.md +20 -0
- package/docs/connect-review-bots.md +17 -0
- package/docs/connect-team-server.md +12 -0
- package/docs/connect-terminal.md +49 -0
- package/docs/connect-web-agents.md +9 -0
- package/docs/connect-your-ai-assistant.md +59 -175
- package/docs/coverage-audit-and-consent.md +51 -0
- package/docs/evaluate-what-niro-did.md +271 -0
- package/docs/faq-assistants-and-mcp.md +122 -0
- package/docs/faq-feedback-and-receipts.md +33 -0
- package/docs/faq-indexing.md +127 -0
- package/docs/faq-install-and-setup.md +103 -0
- package/docs/faq-licence-and-plans.md +86 -0
- package/docs/faq-login-and-account.md +69 -0
- package/docs/faq-offline.md +34 -0
- package/docs/faq-performance.md +33 -0
- package/docs/faq-privacy-and-security.md +113 -0
- package/docs/faq-uninstall.md +47 -0
- package/docs/faq-updates.md +59 -0
- package/docs/faq-worktrees-and-branches.md +59 -0
- package/docs/faq-wrong-or-missing-answers.md +275 -0
- package/docs/hosted-only.md +33 -0
- package/docs/how-the-graph-works.md +47 -0
- package/docs/index.md +157 -0
- package/docs/licence-and-plans.md +72 -0
- package/docs/lifecycle-debug.md +48 -0
- package/docs/lifecycle-onboarding.md +32 -0
- package/docs/lifecycle-overview.md +77 -0
- package/docs/lifecycle-plan-and-design.md +44 -0
- package/docs/lifecycle-review.md +30 -0
- package/docs/lifecycle-security-and-privacy.md +35 -0
- package/docs/lifecycle-test.md +28 -0
- package/docs/lifecycle-write-code.md +35 -0
- package/docs/llms-full.txt +8015 -0
- package/docs/llms.txt +126 -0
- package/docs/playbook-add-a-feature.md +108 -0
- package/docs/playbook-change-safely.md +93 -0
- package/docs/playbook-data-and-events.md +107 -0
- package/docs/playbook-debug-after-a-change.md +96 -0
- package/docs/playbook-debug-config.md +95 -0
- package/docs/playbook-debug-exception.md +124 -0
- package/docs/playbook-debug-failing-service-call.md +119 -0
- package/docs/playbook-debug-slow-path.md +80 -0
- package/docs/playbook-debug-wrong-value.md +137 -0
- package/docs/playbook-onboard.md +95 -0
- package/docs/playbook-review-a-change.md +72 -0
- package/docs/playbook-test-a-change.md +73 -0
- package/docs/reference-cli.md +113 -0
- package/docs/reference-config-files.md +42 -0
- package/docs/reference-engine-commands.md +56 -0
- package/docs/reference-environment-variables.md +89 -0
- package/docs/reference-exit-codes.md +67 -0
- package/docs/reference-limits.md +75 -0
- package/docs/reference-mcp-tools.md +326 -0
- package/docs/reference-messages.md +180 -0
- package/docs/secrets-and-masking.md +48 -0
- package/docs/supported-c.md +130 -0
- package/docs/supported-cpp.md +157 -0
- package/docs/supported-csharp.md +211 -0
- package/docs/supported-go.md +258 -0
- package/docs/supported-java.md +226 -0
- package/docs/supported-javascript.md +223 -0
- package/docs/supported-kotlin.md +124 -0
- package/docs/supported-overview.md +89 -0
- package/docs/supported-php.md +157 -0
- package/docs/supported-python.md +224 -0
- package/docs/supported-ruby.md +162 -0
- package/docs/supported-rust.md +150 -0
- package/docs/supported-swift.md +133 -0
- package/docs/supported-typescript.md +220 -0
- package/docs/tokens-speed-and-accuracy.md +99 -0
- package/docs/troubleshooting.md +42 -0
- package/docs/uninstall.md +74 -0
- package/docs/what-leaves-your-machine.md +71 -0
- package/docs/where-things-are-stored.md +59 -0
- package/docs/worktrees-and-branches.md +55 -0
- package/package.json +5 -3
- package/src/commands/add-repo.js +5 -0
- package/src/commands/auditSend.js +67 -0
- package/src/commands/build.js +6 -0
- package/src/commands/docsSend.js +73 -0
- package/src/commands/engine.js +120 -0
- package/src/commands/engineRoutes.js +105 -0
- package/src/commands/guide.js +254 -0
- package/src/commands/init.js +16 -7
- package/src/commands/initLocal.js +96 -0
- package/src/commands/journalSend.js +47 -0
- package/src/commands/login.js +89 -12
- package/src/commands/mcp.js +22 -6
- package/src/commands/plan.js +99 -0
- package/src/commands/repoPush.js +33 -0
- package/src/commands/requestIntegration.js +38 -0
- package/src/commands/share.js +56 -0
- package/src/commands/start.js +451 -371
- package/src/commands/status.js +57 -0
- package/src/commands/takeover.js +6 -0
- package/src/commands/uninstall.js +180 -0
- package/src/commands/upgrade.js +38 -8
- package/src/commands/watch.js +420 -6
- package/src/index.js +174 -3
- package/src/lib/agentApi.js +32 -1
- package/src/lib/api.js +20 -6
- package/src/lib/auditUpload.js +293 -0
- package/src/lib/branchDrift.js +149 -0
- package/src/lib/choose.js +116 -0
- package/src/lib/cloudGuard.js +74 -0
- package/src/lib/credentials.js +2 -2
- package/src/lib/docsMarked.js +55 -0
- package/src/lib/docsState.js +213 -0
- package/src/lib/docsUpload.js +481 -0
- package/src/lib/engine.js +619 -0
- package/src/lib/folderSync.js +2 -1
- package/src/lib/graphCheck.js +40 -0
- package/src/lib/guide.js +293 -0
- package/src/lib/guideSearch.js +252 -0
- package/src/lib/integrationRequest.js +89 -0
- package/src/lib/journalUpload.js +147 -0
- package/src/lib/languages.js +129 -0
- package/src/lib/lease.js +375 -0
- package/src/lib/localBuild.js +285 -0
- package/src/lib/localEngine.js +210 -0
- package/src/lib/localOnboard.js +124 -0
- package/src/lib/localWorktrees.js +243 -0
- package/src/lib/machineCheck.js +58 -0
- package/src/lib/mcpClients.js +10 -1
- package/src/lib/migrationNotice.js +118 -0
- package/src/lib/modelFetch.js +153 -0
- package/src/lib/nestedOutput.js +23 -9
- package/src/lib/onboardingLog.js +34 -0
- package/src/lib/planUsage.js +165 -0
- package/src/lib/progressBar.js +46 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/redact.js +155 -0
- package/src/lib/release.js +33 -0
- package/src/lib/repoPush.js +495 -0
- package/src/lib/secret.js +1 -1
- package/src/lib/service.js +372 -63
- package/src/lib/shareCode.js +367 -0
- package/src/lib/shareExclusions.js +114 -0
- package/src/lib/shareFiles.js +129 -0
- package/src/lib/spinner.js +9 -5
- package/src/lib/watchDaemon.js +33 -10
- package/src/lib/watchState.js +12 -1
- package/src/lib/windowsPaths.js +67 -0
- package/src/mcp/lineCheck.js +382 -0
- package/src/mcp/lineName.js +92 -0
- package/src/mcp/localBackend.js +751 -0
- package/src/mcp/localMode.js +383 -0
- package/src/mcp/localSkill.js +189 -0
- package/src/mcp/server.js +610 -79
- package/src/mcp/setup.js +519 -131
- package/src/mcp/shellSearchHook.js +509 -0
- package/src/mcp/teardown.js +173 -91
- package/src/mcp/usingNiro.js +39 -0
- package/docs/working-on-branches.md +0 -162
package/src/commands/watch.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Also exported as `startWatch(dir)` so `niro add repo` can transition into
|
|
11
11
|
* watch mode automatically after initial sync.
|
|
12
12
|
*/
|
|
13
|
+
const crypto = require("crypto");
|
|
13
14
|
const path = require("path");
|
|
14
15
|
const fs = require("fs");
|
|
15
16
|
const api = require("../lib/api");
|
|
@@ -22,6 +23,9 @@ const folderSync = require("../lib/folderSync");
|
|
|
22
23
|
const git = require("../lib/git");
|
|
23
24
|
const tempProject = require("../lib/tempProject");
|
|
24
25
|
const periodicClaim = require("../lib/periodicClaim");
|
|
26
|
+
const localBuild = require("../lib/localBuild");
|
|
27
|
+
const localEngine = require("../lib/localEngine");
|
|
28
|
+
const { startLocalDocs } = require("../lib/docsUpload");
|
|
25
29
|
const {
|
|
26
30
|
SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
|
|
27
31
|
IGNORED_DIRS, isConfigName, chunk,
|
|
@@ -124,13 +128,22 @@ function recordBackstopSuccess(key) {
|
|
|
124
128
|
|
|
125
129
|
async function watch(pathArg, opts = {}) {
|
|
126
130
|
const creds = credentials.requireCreds();
|
|
131
|
+
if (pathArg && !require("../lib/release").cloud) {
|
|
132
|
+
// `niro watch <path>` syncs one folder to the cloud, which this release does not have.
|
|
133
|
+
console.error("`niro watch <path>` syncs a folder to Niro's cloud, which this release does not include. The background watcher (`niro service install`) keeps your local folders current.");
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
127
137
|
|
|
128
138
|
if (!pathArg) {
|
|
129
139
|
// Watch all registered local repos for the CURRENT backend (entries from
|
|
130
140
|
// another stack are skipped — their gitIds are invalid here).
|
|
131
|
-
const all =
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
const { cloud: all, local, engineAnswered } = await daemonTargets({ run: opts.run });
|
|
142
|
+
// An engine that answered with no folder yet still gets a daemon: the folder list is read
|
|
143
|
+
// again every round (`followLocalFolders`), so the first `niro init` is picked up without a
|
|
144
|
+
// restart. Only a machine with neither an engine nor a cloud folder has nothing to do.
|
|
145
|
+
if (all.length === 0 && local.length === 0 && !engineAnswered) {
|
|
146
|
+
console.error("No folders to watch. Run `niro start` in a project folder first.");
|
|
134
147
|
process.exitCode = 1;
|
|
135
148
|
return;
|
|
136
149
|
}
|
|
@@ -146,6 +159,10 @@ async function watch(pathArg, opts = {}) {
|
|
|
146
159
|
watched.add(path.resolve(entry.folderPath));
|
|
147
160
|
startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
|
|
148
161
|
}
|
|
162
|
+
// Folders the local engine indexes and nothing registered for the cloud: a Niro Local
|
|
163
|
+
// machine has only these. Followed for as long as the daemon runs, not listed once: see
|
|
164
|
+
// followLocalFolders.
|
|
165
|
+
followLocalFolders(local, { ...opts, cloudFolders: watched });
|
|
149
166
|
// Pick up folders added by `niro takeover` / `niro add repo` WITHOUT a daemon restart. Those commands
|
|
150
167
|
// rewrite the state file, so watch it and start watching any NEW entry live. (A takeover that only
|
|
151
168
|
// re-points an ALREADY-watched folder's gitId is handled per-event by handleFileEvent re-reading
|
|
@@ -154,7 +171,9 @@ async function watch(pathArg, opts = {}) {
|
|
|
154
171
|
const reconcile = () => {
|
|
155
172
|
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
156
173
|
reconcileTimer = setTimeout(() => {
|
|
157
|
-
|
|
174
|
+
// The same list as start-up (`hostedFolders`): without the cloud it is empty, so a hosted
|
|
175
|
+
// folder left in projects.json is not started here either when the file is rewritten.
|
|
176
|
+
for (const entry of hostedFolders()) {
|
|
158
177
|
const resolved = path.resolve(entry.folderPath);
|
|
159
178
|
if (!watched.has(resolved)) {
|
|
160
179
|
watched.add(resolved);
|
|
@@ -178,7 +197,13 @@ async function watch(pathArg, opts = {}) {
|
|
|
178
197
|
} catch (err) {
|
|
179
198
|
console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
|
|
180
199
|
}
|
|
181
|
-
startPeriodicSweep();
|
|
200
|
+
startPeriodicSweep({ startupDelayMs: sweepStartupDelayMs() });
|
|
201
|
+
startLocalUploads();
|
|
202
|
+
// Documentation requests for folders built with `niro-local build --llm`. Its own timer and
|
|
203
|
+
// claim, kept in docsUpload.js because this file is already over 1,000 lines. Not started while
|
|
204
|
+
// LLM documentation is off (src/lib/release.js `llmDocs`): against an engine with it off every
|
|
205
|
+
// round would end refused, after a token refresh and a log line, every ten minutes.
|
|
206
|
+
if (require("../lib/release").llmDocs) startLocalDocs();
|
|
182
207
|
|
|
183
208
|
// Keep process alive
|
|
184
209
|
await new Promise(() => {});
|
|
@@ -189,6 +214,163 @@ async function watch(pathArg, opts = {}) {
|
|
|
189
214
|
await startWatch(targetDir, opts);
|
|
190
215
|
}
|
|
191
216
|
|
|
217
|
+
/**
|
|
218
|
+
* What the no-path daemon watches: `cloud`, the registered folders for this backend, and
|
|
219
|
+
* `local`, every folder the local engine knows (`niro-local projects --json`) that is still
|
|
220
|
+
* on disk and not already among the cloud ones. A machine with no engine has no local folders.
|
|
221
|
+
*
|
|
222
|
+
* Before this the daemon exited when ~/.niro/projects.json was empty, so a Niro Local machine,
|
|
223
|
+
* which registers nothing there, had no daemon at all and "updates automatically" was false.
|
|
224
|
+
*/
|
|
225
|
+
/**
|
|
226
|
+
* How long `niro-local projects --json` may take before it is stopped. It answers in well under a
|
|
227
|
+
* second; a call that hangs would otherwise hold the daemon's round for ever, and no later round
|
|
228
|
+
* would run (VP-rel-watch N4). `NIRO_LOCAL_LIST_TIMEOUT_MS` changes it.
|
|
229
|
+
*/
|
|
230
|
+
const LIST_TIMEOUT_MS = 20_000;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The folders registered for hosted Niro that the daemon syncs: the entries of ~/.niro/projects.json
|
|
234
|
+
* for this backend. None without the cloud (src/lib/release.js): a machine that once registered folders
|
|
235
|
+
* for the cloud keeps them in that file, and watching those would keep uploading their source to Niro.
|
|
236
|
+
* Start-up and the re-read after projects.json changes both take their list from here.
|
|
237
|
+
*/
|
|
238
|
+
function hostedFolders() {
|
|
239
|
+
return require("../lib/release").cloud ? watchState.allLocal(api.currentBaseUrl()) : [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function daemonTargets({ run = localEngine.run } = {}) {
|
|
243
|
+
const cloud = hostedFolders();
|
|
244
|
+
const taken = new Set(cloud.map((e) => path.resolve(e.folderPath)));
|
|
245
|
+
const local = [];
|
|
246
|
+
// Whether the engine answered at all, so the daemon can tell "no engine" from "an engine that
|
|
247
|
+
// holds no folder yet" (the second keeps the daemon up, waiting for the first folder).
|
|
248
|
+
let engineAnswered = false;
|
|
249
|
+
let problem = null;
|
|
250
|
+
try {
|
|
251
|
+
const timeoutMs = Number(process.env.NIRO_LOCAL_LIST_TIMEOUT_MS) > 0
|
|
252
|
+
? Number(process.env.NIRO_LOCAL_LIST_TIMEOUT_MS) : LIST_TIMEOUT_MS;
|
|
253
|
+
const parsed = JSON.parse(await run(["projects", "--json"], undefined, { timeoutMs }));
|
|
254
|
+
engineAnswered = true;
|
|
255
|
+
for (const project of parsed.projects || []) {
|
|
256
|
+
for (const w of project.worktrees || []) {
|
|
257
|
+
const folder = w && typeof w.path === "string" ? path.resolve(w.path) : null;
|
|
258
|
+
if (!folder || taken.has(folder) || !fs.existsSync(folder)) continue;
|
|
259
|
+
taken.add(folder);
|
|
260
|
+
local.push(folder);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// No engine, or one that cannot answer: no local folders. The reason is passed back for the
|
|
265
|
+
// daemon's rounds, which say it once; the start-up caller only needs the yes or no.
|
|
266
|
+
problem = (err && err.message) || String(err);
|
|
267
|
+
}
|
|
268
|
+
return { cloud, local, engineAnswered, problem };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* How often the daemon asks niro-local again which folders it indexes. Thirty seconds: one
|
|
273
|
+
* `niro-local projects --json` and two git calls per held repository, so a folder indexed by
|
|
274
|
+
* `niro init` or a `git worktree add` is followed within half a minute without a restart.
|
|
275
|
+
* `NIRO_LOCAL_RECONCILE_MS` changes it (read when the daemon starts; under one second is ignored).
|
|
276
|
+
*/
|
|
277
|
+
const LOCAL_RECONCILE_MS = 30_000;
|
|
278
|
+
|
|
279
|
+
/** Said under a failed new-worktree build: that folder is not watched yet, so saving does nothing. */
|
|
280
|
+
const WORKTREE_RETRY_HINT = () => `It is tried again in a few minutes. To see why now, run \`${require("../lib/release").rebuildCommand()}\` in that folder.`;
|
|
281
|
+
|
|
282
|
+
/** The local folders being watched now: resolved folder -> handle with `stop()`. */
|
|
283
|
+
const localWatches = new Map();
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Keep the daemon's local folders the same as niro-local's list, for as long as it runs.
|
|
287
|
+
*
|
|
288
|
+
* Until release finding 6 the list was read once, when the daemon started, so a folder indexed
|
|
289
|
+
* afterwards was never watched until the next reboot, a folder removed with `niro-local remove` was
|
|
290
|
+
* still built on every save (and each build failed in red), and a new git worktree of a held
|
|
291
|
+
* repository was never indexed. Now each round:
|
|
292
|
+
* - a folder niro-local lists and nobody watches starts being watched;
|
|
293
|
+
* - a watched folder it no longer lists, or that is gone from disk, stops being watched;
|
|
294
|
+
* - a worktree of a held repository that is new since the repository was first seen is built
|
|
295
|
+
* with `niro-local build` (`localWorktrees.js`), which makes its temporary copy; the next
|
|
296
|
+
* round finds the copy in the list and watches it. A build that fails is tried twice more,
|
|
297
|
+
* minutes apart, then given up with one line.
|
|
298
|
+
* A round where the engine cannot answer changes nothing, so a passing failure never stops a watch.
|
|
299
|
+
*
|
|
300
|
+
* `cloudFolders` are left to the cloud half. Returns `{ stop, round }`; `round` is for tests.
|
|
301
|
+
*/
|
|
302
|
+
function followLocalFolders(initial, {
|
|
303
|
+
run, intervalMs, log = console.log, cloudFolders = new Set(),
|
|
304
|
+
newWorktreeFolders = require("../lib/localWorktrees").newWorktreeFolders,
|
|
305
|
+
} = {}) {
|
|
306
|
+
const every = usableReconcileMs(intervalMs) || usableReconcileMs(Number(process.env.NIRO_LOCAL_RECONCILE_MS))
|
|
307
|
+
|| LOCAL_RECONCILE_MS;
|
|
308
|
+
let busy = false;
|
|
309
|
+
let failing = false;
|
|
310
|
+
|
|
311
|
+
const apply = async (folders, { first = false } = {}) => {
|
|
312
|
+
const wanted = new Set(folders.map((f) => path.resolve(f)).filter((f) => !cloudFolders.has(f)));
|
|
313
|
+
for (const folder of wanted) {
|
|
314
|
+
if (localWatches.has(folder)) continue;
|
|
315
|
+
if (!first) log(` ${color.green("\u21bb")} Now watching new folder: ${folder}`);
|
|
316
|
+
await startLocalWatch(folder, { once: true, log });
|
|
317
|
+
}
|
|
318
|
+
for (const [folder, handle] of [...localWatches]) {
|
|
319
|
+
// A cloud folder that niro-local also indexes is watched through the cloud half
|
|
320
|
+
// (`startWatch` hands it to `startLocalWatch`), so it is in this map but never in `wanted`.
|
|
321
|
+
// It is not this round's to stop (VP-rel-watch N2: it lost its watch at the first round).
|
|
322
|
+
if (wanted.has(folder) || cloudFolders.has(folder)) continue;
|
|
323
|
+
handle.stop();
|
|
324
|
+
const why = fs.existsSync(folder) ? "niro-local no longer indexes it" : "it is gone from disk";
|
|
325
|
+
log(` Stopped watching ${folder}: ${why}.`);
|
|
326
|
+
}
|
|
327
|
+
for (const folder of await newWorktreeFolders([...wanted], { log })) {
|
|
328
|
+
log(` ${color.green("\u21bb")} New worktree of a repository niro-local indexes: building ${folder}`);
|
|
329
|
+
localBuild.noteChange(folder, { settleMs: 0, log, hint: WORKTREE_RETRY_HINT() });
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const round = async () => {
|
|
334
|
+
if (busy) return;
|
|
335
|
+
busy = true;
|
|
336
|
+
try {
|
|
337
|
+
const { local, engineAnswered, problem } = await daemonTargets({ run });
|
|
338
|
+
if (engineAnswered) {
|
|
339
|
+
if (failing) log(` niro-local answers again; following its folders.`);
|
|
340
|
+
failing = false;
|
|
341
|
+
await apply(local);
|
|
342
|
+
} else if (!failing) {
|
|
343
|
+
// Said once per run of failures, not every 30 s. Nothing is stopped meanwhile.
|
|
344
|
+
failing = true;
|
|
345
|
+
log(color.yellow(` Could not ask niro-local for its folders (${problem}); watching the same folders and asking again every ${Math.round(every / 1000)} s.`));
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
log(color.yellow(` Could not check niro-local's folders: ${err && err.message ? err.message : err}`));
|
|
349
|
+
} finally {
|
|
350
|
+
busy = false;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
busy = true;
|
|
355
|
+
const started = apply(initial, { first: true })
|
|
356
|
+
.catch((err) => log(color.red(` Could not watch niro-local's folders: ${err && err.message ? err.message : err}`)))
|
|
357
|
+
.finally(() => { busy = false; });
|
|
358
|
+
// Not unref'd: with no folder yet this timer is what keeps the daemon up, waiting for the first.
|
|
359
|
+
const timer = setInterval(round, every);
|
|
360
|
+
return {
|
|
361
|
+
started,
|
|
362
|
+
round,
|
|
363
|
+
stop: () => {
|
|
364
|
+
clearInterval(timer);
|
|
365
|
+
for (const handle of [...localWatches.values()]) handle.stop();
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function usableReconcileMs(ms) {
|
|
371
|
+
return Number.isFinite(ms) && ms >= 1000 ? ms : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
192
374
|
/**
|
|
193
375
|
* How often the daemon sweeps for temporary projects that have gone from the server. Six hours, not
|
|
194
376
|
* six seconds: its job is repairing pointers that go stale over days, not reacting to keystrokes.
|
|
@@ -201,6 +383,16 @@ const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
|
201
383
|
*/
|
|
202
384
|
const SWEEP_STARTUP_DELAY_MS = 60 * 1000;
|
|
203
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The first sweep's delay. `NIRO_SWEEP_STARTUP_DELAY_MS` (at least 1000) shortens it, so the end-to-end
|
|
388
|
+
* test of the daemon (test/watchHostedGuard.test.js) does not wait a minute; it changes only when the
|
|
389
|
+
* first sweep runs, since every run is still gated by the six-hour claim.
|
|
390
|
+
*/
|
|
391
|
+
function sweepStartupDelayMs() {
|
|
392
|
+
const ms = Number(process.env.NIRO_SWEEP_STARTUP_DELAY_MS);
|
|
393
|
+
return Number.isFinite(ms) && ms >= 1000 ? ms : SWEEP_STARTUP_DELAY_MS;
|
|
394
|
+
}
|
|
395
|
+
|
|
204
396
|
/**
|
|
205
397
|
* Periodically run `niro cleanup-temp-projects`. Machine-wide, so it belongs HERE, in the no-path
|
|
206
398
|
* branch, and not in startWatch — startWatch runs once per folder, and this must run once per machine.
|
|
@@ -239,6 +431,96 @@ function startPeriodicSweep({ intervalMs = SWEEP_INTERVAL_MS, startupDelayMs = S
|
|
|
239
431
|
return { stop: () => { clearTimeout(first); clearInterval(timer); } };
|
|
240
432
|
}
|
|
241
433
|
|
|
434
|
+
/**
|
|
435
|
+
* How often the local engine's queues are emptied: its coverage-audit findings and its record
|
|
436
|
+
* of which MCP tools were asked for.
|
|
437
|
+
*
|
|
438
|
+
* Five minutes (Divya, 2026-11-05; it was half an hour). The engine holds back a session that is
|
|
439
|
+
* still in use and the audit queue dedups by content hash, so a run that finds nothing costs one
|
|
440
|
+
* licence request and nothing is sent twice.
|
|
441
|
+
*/
|
|
442
|
+
const LOCAL_UPLOAD_INTERVAL_MS = 5 * 60 * 1000;
|
|
443
|
+
|
|
444
|
+
/** The same reasoning as the sweep's delay: let the daemon's startup burst settle first. */
|
|
445
|
+
const LOCAL_UPLOAD_STARTUP_DELAY_MS = 2 * 60 * 1000;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Periodically send what the local engine is holding.
|
|
449
|
+
*
|
|
450
|
+
* Until now both of these needed a person to type `niro audit-send` or `niro journal-send`,
|
|
451
|
+
* which means in practice they were never sent: a developer has no reason to run either and
|
|
452
|
+
* no way to know there is anything waiting.
|
|
453
|
+
*
|
|
454
|
+
* **Why on a timer rather than after each build.** The audit's findings come out of a build,
|
|
455
|
+
* so after-each-build looks like the obvious hook. But the watcher builds after every quiet
|
|
456
|
+
* period, which during a working session is every few minutes, and an upload per build is a
|
|
457
|
+
* request per save for a queue that is usually empty. The journal has no build to hang off at
|
|
458
|
+
* all. One timer, three jobs.
|
|
459
|
+
*
|
|
460
|
+
* **The third job, shared code (2026-11-11).** While the lease says `share_code: true`, the code
|
|
461
|
+
* of every indexed folder that is not excluded is sent, redacted here: the whole indexed set the
|
|
462
|
+
* first time, then only changed files (src/lib/shareCode.js, decision 01M75JHE64PYYNKR2G4JJ4JCZ3).
|
|
463
|
+
* The audit and the journal do not depend on that switch; this job does nothing without it.
|
|
464
|
+
*
|
|
465
|
+
* **Machine-wide, so it belongs in the no-path branch** beside the temporary-project sweep,
|
|
466
|
+
* and gated by periodicClaim for the same two reasons: PM2 restarting the daemon must not turn
|
|
467
|
+
* a half-hourly job into one that runs on every restart, and a `niro watch` left in a terminal
|
|
468
|
+
* must not upload at the same moment as the daemon.
|
|
469
|
+
*
|
|
470
|
+
* **It stays quiet on a machine with no engine.** A cloud-only developer has no niro-local, so
|
|
471
|
+
* each attempt fails to run it; that is never logged, and the next round tries again, so an
|
|
472
|
+
* engine installed later is sent from without restarting the daemon.
|
|
473
|
+
*
|
|
474
|
+
* Never throws: an upload failure must not touch the daemon's actual job.
|
|
475
|
+
*/
|
|
476
|
+
function startLocalUploads({
|
|
477
|
+
intervalMs = LOCAL_UPLOAD_INTERVAL_MS,
|
|
478
|
+
startupDelayMs = LOCAL_UPLOAD_STARTUP_DELAY_MS,
|
|
479
|
+
claimName = "localEngineUploads",
|
|
480
|
+
log = console.log,
|
|
481
|
+
} = {}) {
|
|
482
|
+
let stopped = false;
|
|
483
|
+
const handle = { stop: () => {} };
|
|
484
|
+
|
|
485
|
+
const run = async () => {
|
|
486
|
+
if (stopped || !periodicClaim.claim(claimName, intervalMs)) return;
|
|
487
|
+
for (const job of [
|
|
488
|
+
{ what: "audit findings", send: () => require("../lib/auditUpload").uploadPending() },
|
|
489
|
+
{ what: "MCP journal", send: () => require("../lib/journalUpload").uploadPending() },
|
|
490
|
+
// Only while "Help improve Niro" is on; the rest of its rules are in shareCode.js.
|
|
491
|
+
{ what: "shared code files", send: () => require("../lib/shareCode").shareRound() },
|
|
492
|
+
]) {
|
|
493
|
+
try {
|
|
494
|
+
const result = await job.send();
|
|
495
|
+
const sent = result.submitted || result.sent || 0;
|
|
496
|
+
if (sent > 0) log(` Sent ${sent} ${job.what} to Niro.`);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
const message = (err && err.message) || String(err);
|
|
499
|
+
if (/cannot run /.test(message)) {
|
|
500
|
+
// No engine on this machine yet: nothing to send this round, and nothing worth saying.
|
|
501
|
+
// Tried again next round, so an engine installed after the daemon started is picked up
|
|
502
|
+
// without a restart (until 2026-11-06 the timer stopped here for good).
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Said rather than swallowed: a queue that silently never drains is the failure this
|
|
506
|
+
// whole path exists to prevent. Once per interval, not once per item.
|
|
507
|
+
log(color.yellow(` Could not send the ${job.what}: ${message}`));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const first = setTimeout(run, startupDelayMs);
|
|
513
|
+
const timer = setInterval(run, intervalMs);
|
|
514
|
+
if (first.unref) first.unref();
|
|
515
|
+
if (timer.unref) timer.unref();
|
|
516
|
+
handle.stop = () => {
|
|
517
|
+
stopped = true;
|
|
518
|
+
clearTimeout(first);
|
|
519
|
+
clearInterval(timer);
|
|
520
|
+
};
|
|
521
|
+
return handle;
|
|
522
|
+
}
|
|
523
|
+
|
|
242
524
|
/**
|
|
243
525
|
* One watch entry failed to start (its startup delta sync threw). Contain the failure to that
|
|
244
526
|
* entry — and when the cause is "the repo no longer exists on this backend" (a poison entry left
|
|
@@ -277,7 +559,139 @@ async function handleEntryFailure(entry, err, watched) {
|
|
|
277
559
|
`Watch error for ${entry.folderPath}: ${err.message} — entry kept, will retry on next daemon start.`));
|
|
278
560
|
}
|
|
279
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Watch a folder the local engine indexes: rebuild it when it goes quiet.
|
|
564
|
+
*
|
|
565
|
+
* Self-contained rather than branching through startWatch below, and the ~8 lines of
|
|
566
|
+
* fs.watch setup are duplicated on purpose. Two reasons, written down because a silent
|
|
567
|
+
* copy-paste is not allowed. First, the cloud half of this file carries protections that
|
|
568
|
+
* were each paid for by a real failure, and its fs.watch wiring is NOT covered by any test
|
|
569
|
+
* ("the fs.watch wiring is not driven here", test/watchDeleteSettle.test.js), so
|
|
570
|
+
* restructuring it to share with a new path would be an unguarded change to the riskiest
|
|
571
|
+
* code here. Second, phase 12 of the plan deletes the cloud half outright, so the
|
|
572
|
+
* duplication ends by deletion rather than by an abstraction that would then have one
|
|
573
|
+
* caller.
|
|
574
|
+
*
|
|
575
|
+
* What is NOT duplicated, because it is not needed: the per-file hashes, the gitignore
|
|
576
|
+
* check at upload time, the manifest scope and the heartbeat. The engine compares the whole
|
|
577
|
+
* folder against its own content hashes on every build, so it finds a change this watcher
|
|
578
|
+
* missed without being told. The one thing it cannot do is know that something happened,
|
|
579
|
+
* which is what this provides: a file event, or, for an event fs.watch dropped, a re-check of
|
|
580
|
+
* the folder's file sizes and times every few minutes (`localBuild.startRecheck`, rank 176; the
|
|
581
|
+
* cloud half's backstop re-hashes every file each minute, which a local build does not need).
|
|
582
|
+
*/
|
|
583
|
+
/** How often a folder is checked for changes when this Node cannot watch a folder tree (startLocalWatch). */
|
|
584
|
+
const WATCHLESS_POLL_MS = 10_000;
|
|
585
|
+
|
|
586
|
+
async function startLocalWatch(targetDir, opts = {}) {
|
|
587
|
+
const log = opts.log || console.log;
|
|
588
|
+
// One watch per folder, whichever path asks for it (the daemon's list and a cloud entry that
|
|
589
|
+
// turns out to be local can both name the same folder).
|
|
590
|
+
const existing = localWatches.get(path.resolve(targetDir));
|
|
591
|
+
if (existing) return existing;
|
|
592
|
+
let watcher = null;
|
|
593
|
+
const handle = {
|
|
594
|
+
stop: () => {
|
|
595
|
+
if (localWatches.get(path.resolve(targetDir)) === handle) localWatches.delete(path.resolve(targetDir));
|
|
596
|
+
if (watcher) watcher.close();
|
|
597
|
+
watcher = null;
|
|
598
|
+
localBuild.forget(targetDir);
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
localWatches.set(path.resolve(targetDir), handle);
|
|
602
|
+
log(`\n ${color.cyan("Watching")} ${targetDir}`);
|
|
603
|
+
log(` ${color.dim("indexed locally by niro-local")}`);
|
|
604
|
+
|
|
605
|
+
// Build once at startup. The folder may have changed while nothing was watching it, and
|
|
606
|
+
// that is the stale-graph case this exists to prevent; waiting for the next edit would
|
|
607
|
+
// leave it stale until one happened to arrive.
|
|
608
|
+
localBuild.noteChange(targetDir, { settleMs: 0, log });
|
|
609
|
+
|
|
610
|
+
// A Node that cannot watch a folder tree (Node 18 on Linux, Ubuntu 24.04's own package) throws here. It used to
|
|
611
|
+
// end the watch before the re-check below was started, with the folder still marked as watched, so nothing ever
|
|
612
|
+
// synced while PM2 showed the watcher online (2026-11-11). Then the re-check is the watcher, every 10 s.
|
|
613
|
+
let pollMs;
|
|
614
|
+
try {
|
|
615
|
+
watcher = fs.watch(targetDir, { recursive: true }, (eventType, filename) => {
|
|
616
|
+
if (!filename) return;
|
|
617
|
+
const relativePath = filename.replace(/\\/g, "/");
|
|
618
|
+
if (shouldIgnore(relativePath)) return;
|
|
619
|
+
// Straight to the folder's settle timer, with no per-file debounce in between: the
|
|
620
|
+
// settle timer already collapses a burst, and adding the per-file 2s on top would only
|
|
621
|
+
// delay every rebuild by two seconds for nothing.
|
|
622
|
+
localBuild.noteChange(targetDir, { log });
|
|
623
|
+
});
|
|
624
|
+
} catch (err) {
|
|
625
|
+
pollMs = WATCHLESS_POLL_MS;
|
|
626
|
+
log(color.yellow(` Node ${process.version} cannot watch a folder tree here (${err.code || err.message}); checking ${targetDir} for changes every ${pollMs / 1000} s instead. Node 20 or later sees changes as they happen.`));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// A watch that errors (its folder deleted, too many open files) is stopped rather than left
|
|
630
|
+
// half alive; the daemon starts it again next round if niro-local still lists the folder.
|
|
631
|
+
if (watcher) watcher.on("error", (err) => {
|
|
632
|
+
// A folder deleted on purpose (`git worktree remove`) is not a fault: said plainly, not in red.
|
|
633
|
+
if (!fs.existsSync(targetDir)) log(` Stopped watching ${targetDir}: it is gone from disk.`);
|
|
634
|
+
else log(color.red(` Watcher error for ${targetDir}: ${err.message}`));
|
|
635
|
+
handle.stop();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// fs.watch drops events, so a few minutes apart the folder is listed again and rebuilt when it
|
|
639
|
+
// changed unseen (rank 176). Stopped by `forget` below. Without fs.watch it is the watcher (pollMs).
|
|
640
|
+
localBuild.startRecheck(targetDir, { intervalMs: pollMs, list: () => folderDigest(targetDir), log });
|
|
641
|
+
|
|
642
|
+
log(` ${color.green("\u2713")} Watching for changes. Press Ctrl+C to stop.\n`);
|
|
643
|
+
|
|
644
|
+
// `once` is the daemon (and tests): it returns the handle and the daemon stops it. A Ctrl+C
|
|
645
|
+
// handler per folder there would add one listener per folder for nothing.
|
|
646
|
+
if (opts.once) return handle;
|
|
647
|
+
|
|
648
|
+
process.on("SIGINT", () => {
|
|
649
|
+
handle.stop();
|
|
650
|
+
log("\n Watch stopped.");
|
|
651
|
+
process.exit(0);
|
|
652
|
+
});
|
|
653
|
+
await new Promise(() => {});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* A digest of a locally indexed folder for the periodic re-check: every file the walk keeps (the same
|
|
658
|
+
* walk and filters as the cloud manifest, `buildMinimalManifest`), with its size and modification time.
|
|
659
|
+
* No file is read. Null when a folder under it cannot be listed: unknown is not "changed".
|
|
660
|
+
*/
|
|
661
|
+
function folderDigest(rootDir) {
|
|
662
|
+
const { files, failedDirs } = buildMinimalManifest(rootDir);
|
|
663
|
+
if (failedDirs.length > 0) return null;
|
|
664
|
+
const hash = crypto.createHash("sha1");
|
|
665
|
+
for (const relativePath of files.sort()) {
|
|
666
|
+
let stamp = "gone";
|
|
667
|
+
try {
|
|
668
|
+
const stat = fs.statSync(path.join(rootDir, relativePath));
|
|
669
|
+
stamp = `${stat.mtimeMs}:${stat.size}`;
|
|
670
|
+
} catch {
|
|
671
|
+
// Removed between the walk and the stat: a change like any other.
|
|
672
|
+
}
|
|
673
|
+
hash.update(`${relativePath}\t${stamp}\n`);
|
|
674
|
+
}
|
|
675
|
+
return hash.digest("hex");
|
|
676
|
+
}
|
|
677
|
+
|
|
280
678
|
async function startWatch(targetDir, opts = {}) {
|
|
679
|
+
// A locally indexed folder has no gitId and nothing to upload, so it is answered before
|
|
680
|
+
// the registration guard below, which would otherwise refuse it.
|
|
681
|
+
if (await localBuild.isLocallyIndexed(targetDir)) {
|
|
682
|
+
return startLocalWatch(targetDir, opts);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Without the cloud (src/lib/release.js) nothing is synced to hosted Niro. Every sync of a folder
|
|
686
|
+
// starts here (daemon start-up, the re-read when projects.json changes, `niro watch <path>`), so this
|
|
687
|
+
// one check stops all of them: the delta sync below uploads whole files, unmasked. Before it, the
|
|
688
|
+
// re-read started syncing old hosted folders whenever projects.json was rewritten, including by the
|
|
689
|
+
// daemon's own sweep (2026-11-10).
|
|
690
|
+
if (!require("../lib/release").cloud) {
|
|
691
|
+
console.log(` Not syncing ${targetDir}: the local engine does not index it, and this release does not sync folders to hosted Niro.`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
281
695
|
// Scope to the current backend: a folder registered against a different stack
|
|
282
696
|
// has a gitId that is invalid here, so resume must refuse rather than 404 on
|
|
283
697
|
// every sync. (The daemon's allLocal() already filters; this also guards the
|
|
@@ -976,7 +1390,7 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
|
976
1390
|
// gitignore/fail-closed guards without a live server or fs.watch loop. matchesPatterns is
|
|
977
1391
|
// exported for the same reason: it is the gate all three sync paths share, and a pattern that
|
|
978
1392
|
// cannot be compiled stops every one of them, so it is worth testing on its own.
|
|
979
|
-
module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
1393
|
+
module.exports = { watch, daemonTargets, followLocalFolders, localWatches, LOCAL_RECONCILE_MS, startWatch, startLocalWatch, folderDigest, startPeriodicSweep, startLocalUploads, startLocalDocs, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
980
1394
|
matchesPatterns,
|
|
981
1395
|
queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
|
|
982
1396
|
unreadableRoots,
|