@niroai/niro 0.4.0 → 0.6.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.
@@ -19,6 +19,8 @@ const credentials = require("../lib/credentials");
19
19
  const prompt = require("../lib/prompt");
20
20
  const color = require("../lib/color");
21
21
  const folderSync = require("../lib/folderSync");
22
+ const git = require("../lib/git");
23
+ const tempProject = require("../lib/tempProject");
22
24
  const {
23
25
  SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
24
26
  IGNORED_DIRS, isConfigName, chunk,
@@ -31,10 +33,52 @@ const DEBOUNCE_MS = 2_000;
31
33
  // so a slower run would clobber a faster one's hash updates. A per-folder guard serializes them (a skipped
32
34
  // run is safe — the in-progress one re-hashes the whole tree and so already covers the skipped trigger).
33
35
  const syncInFlight = new Set();
36
+ // Folders that are NOT the one their temporary project currently reflects. Uploads from these are held
37
+ // back until the slot returns, so two branches cannot blend into one copy. Set by deltaSyncOnResume and
38
+ // honoured by the live file handler, so one check per sync pass covers both paths rather than asking
39
+ // the server on every save.
40
+ const notHolding = new Set();
34
41
  // The state file is rewritten several times during a `niro takeover` (per-file hashes), so debounce
35
42
  // before reconciling the watch set. Also the interval for the mtime backstop re-scan below.
36
43
  const STATE_RELOAD_DEBOUNCE_MS = 1_500;
37
44
  const RESCAN_INTERVAL_MS = 60_000;
45
+ /** Ceiling for the backstop's exponential backoff. Long enough to stop hammering a broken server,
46
+ * short enough that a recovered one is picked up within half an hour without anyone intervening. */
47
+ const BACKSTOP_BACKOFF_MAX_MS = 30 * 60_000;
48
+
49
+ /**
50
+ * Per-folder retry state for the backstop re-scan: { failures, nextAttemptAt }.
51
+ *
52
+ * The backstop used to fire every 60s regardless of how many times it had just failed, with no
53
+ * backoff, no cap and no notion that anything was wrong. When the server broke on 8-9 August it
54
+ * minted a fresh sync session every single tick — roughly 1,400 failed requests a day, for two days,
55
+ * per watched folder. The bug was ours, but the client turned it into a flood.
56
+ */
57
+ const backstopBackoff = new Map();
58
+
59
+ /** Doubling delay, capped. failures=1 gives 2 minutes; the cap is reached after about six. */
60
+ function nextBackoffMs(failures) {
61
+ return Math.min(RESCAN_INTERVAL_MS * Math.pow(2, failures), BACKSTOP_BACKOFF_MAX_MS);
62
+ }
63
+
64
+ /** False while a folder is still serving a backoff from its last failure. */
65
+ function shouldAttemptBackstop(key, now = Date.now()) {
66
+ const state = backstopBackoff.get(key);
67
+ return !state || now >= state.nextAttemptAt;
68
+ }
69
+
70
+ /** @return the delay applied, so the caller can say how long it is waiting. */
71
+ function recordBackstopFailure(key, now = Date.now()) {
72
+ const failures = (backstopBackoff.get(key)?.failures || 0) + 1;
73
+ const delay = nextBackoffMs(failures);
74
+ backstopBackoff.set(key, { failures, nextAttemptAt: now + delay });
75
+ return delay;
76
+ }
77
+
78
+ /** @return true if this success ended a run of failures (so recovery is worth announcing). */
79
+ function recordBackstopSuccess(key) {
80
+ return backstopBackoff.delete(key);
81
+ }
38
82
 
39
83
  async function watch(pathArg, opts = {}) {
40
84
  const creds = credentials.requireCreds();
@@ -192,8 +236,42 @@ async function startWatch(targetDir, opts = {}) {
192
236
  const rescanTimer = setInterval(() => {
193
237
  const fresh = watchState.get(targetDir, api.currentBaseUrl());
194
238
  if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
195
- deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true }).catch(err =>
196
- console.error(color.red(` Backstop re-scan failed for ${targetDir}: ${err.message}`)));
239
+ // Back off after failures instead of retrying every 60s forever. Retrying at full rate against a
240
+ // server that is failing does not fix anything and buries the real error in its own noise.
241
+ const backoffKey = path.resolve(targetDir);
242
+ if (!shouldAttemptBackstop(backoffKey)) return;
243
+ deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true })
244
+ .then(result => {
245
+ // A run that never reached the server proves nothing about the server. The in-flight guard
246
+ // returns at once when another sync for this folder is already going, and during an outage a
247
+ // large delta holds that flag for minutes — so a skipped tick was clearing the failure count
248
+ // and printing a green "Sync recovered" in the middle of the incident, while the real run was
249
+ // still heading for its own failure. The count then never grew past one, so the backoff never
250
+ // climbed towards its ceiling and kept hammering every two minutes: both things this backoff
251
+ // was added to stop.
252
+ // Only a pass that actually reached the server and had nothing fail counts as a recovery.
253
+ // A skipped run (another sync in flight) and a no-change run both contact nothing, and a pass
254
+ // whose per-file deletes were all refused resolves cleanly — so all three used to clear the
255
+ // failure count and print a green "Sync recovered" during an outage, which is how the retry rate
256
+ // never decayed and the real error stayed buried.
257
+ if (!result || result.skipped || result.noContact || result.notHolder) return;
258
+ if (result.failures > 0) {
259
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
260
+ console.error(color.red(
261
+ ` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
262
+ `${Math.round(delayMs / 60_000)} min.`));
263
+ return;
264
+ }
265
+ if (recordBackstopSuccess(backoffKey)) {
266
+ console.log(color.green(` Sync recovered for ${targetDir}.`));
267
+ }
268
+ })
269
+ .catch(err => {
270
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
271
+ console.error(color.red(
272
+ ` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
273
+ `${Math.round(delayMs / 60_000)} min.`));
274
+ });
197
275
  }, RESCAN_INTERVAL_MS);
198
276
 
199
277
  // Start file watcher
@@ -244,11 +322,51 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
244
322
  if (syncInFlight.has(inFlightKey)) {
245
323
  // A sync (startup, live event, or backstop) is already running for this folder — skip to avoid the
246
324
  // whole-map fileHashes clobber race; the in-progress run re-hashes the whole tree and covers this trigger.
247
- return;
325
+ // Reported so the caller can tell "nothing to do" from "the server answered": this run never
326
+ // contacted it, so it is not evidence that anything is working.
327
+ return { skipped: true };
248
328
  }
249
329
  syncInFlight.add(inFlightKey);
250
330
  try {
251
331
  const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
332
+
333
+ // Am I still the folder this temporary project reflects? Every folder taken over for one project
334
+ // shares the SAME repo id, so the server cannot tell us apart. If I keep uploading after the slot has
335
+ // moved, my branch blends into the copy that is reflecting someone else, and every answer drawn from
336
+ // it is a mixture — with nothing on screen to say so. Verified live with two worktrees.
337
+ //
338
+ // Nothing is changed or discarded here. The local record is left exactly as it is, so running
339
+ // `niro new-temp-project` here picks straight back up. This only declines to WRITE.
340
+ if (state.takeover && state.takeover.sourceProjectId) {
341
+ const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
342
+ const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
343
+ // A null heldBranch means the server could not be asked. That must NEVER be read as "you lost the
344
+ // slot", or one network hiccup would stop a perfectly healthy folder syncing.
345
+ if (heldBranch && myBranch && heldBranch !== myBranch) {
346
+ if (!notHolding.has(inFlightKey)) {
347
+ notHolding.add(inFlightKey);
348
+ // TWO very different causes, and they need different advice. Another folder bound to the same
349
+ // copy is the stealing case. NO other folder means you simply switched branch in place, and
350
+ // saying "another folder holds it" there sends you hunting for a folder that does not exist —
351
+ // which is exactly what it did on a real repo the first time this ran.
352
+ const otherFolder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
353
+ e.projectId === state.projectId && path.resolve(e.folderPath) !== inFlightKey);
354
+ console.warn(color.yellow(otherFolder
355
+ ? ` ${targetDir} is on \`${myBranch}\`, but this project's temporary project is on ` +
356
+ `\`${heldBranch}\`, held by ${otherFolder.folderPath}. Pausing uploads from here so the ` +
357
+ `two branches do not mix. Run \`niro new-temp-project\` here to move it, or ` +
358
+ `\`niro discard-temp-project\` in that folder.`
359
+ : ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still on ` +
360
+ `\`${heldBranch}\` — you switched branch in this folder. Pausing uploads so the two ` +
361
+ `branches do not mix. Run \`niro new-temp-project\` here to move the temporary project ` +
362
+ `onto \`${myBranch}\`.`));
363
+ }
364
+ return { notHolder: true };
365
+ }
366
+ if (notHolding.delete(inFlightKey)) {
367
+ console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
368
+ }
369
+ }
252
370
  const storedHashes = state.fileHashes || {};
253
371
  const currentHashes = {};
254
372
  const changed = [];
@@ -257,27 +375,50 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
257
375
  // Walk all currently included files
258
376
  const manifest = buildMinimalManifest(targetDir, state.includePatterns, state.excludePatterns);
259
377
 
378
+ const unreadable = [];
260
379
  for (const relativePath of manifest) {
261
380
  const absPath = path.join(targetDir, relativePath);
262
381
  const hash = watchState.hashFile(absPath);
263
- if (!hash) continue;
382
+ if (!hash) {
383
+ // The walk that produced this manifest JUST FOUND this file, so it exists. Failing to READ it
384
+ // is not evidence that it was deleted — it is a permission error, a transient lock, a file being
385
+ // rewritten, or simply too many open files. hashFile returns null for every one of those.
386
+ //
387
+ // Skipping the file here used to leave it out of currentHashes, and the loop below reads
388
+ // "absent from currentHashes" as "deleted", so an unreadable file became a delete instruction to
389
+ // the server. Carrying the stored hash forward keeps it in the baseline and out of `deleted`.
390
+ unreadable.push(relativePath);
391
+ if (Object.prototype.hasOwnProperty.call(storedHashes, relativePath)) {
392
+ currentHashes[relativePath] = storedHashes[relativePath];
393
+ }
394
+ continue;
395
+ }
264
396
  currentHashes[relativePath] = hash;
265
397
  if (storedHashes[relativePath] !== hash) {
266
398
  changed.push(relativePath);
267
399
  }
268
400
  }
269
401
 
270
- // Files that were in stored hashes but no longer exist
402
+ if (unreadable.length > 0) {
403
+ console.warn(color.yellow(
404
+ ` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
405
+ `reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
406
+ }
407
+
408
+ // Files that were in stored hashes but are no longer present in the walk at all.
271
409
  for (const rp of Object.keys(storedHashes)) {
272
- if (!currentHashes[rp]) {
410
+ if (!Object.prototype.hasOwnProperty.call(currentHashes, rp)) {
273
411
  deleted.push(rp);
274
412
  }
275
413
  }
276
414
 
277
415
  if (changed.length === 0 && deleted.length === 0) {
278
416
  if (!quiet) console.log(` No changes since last sync.`);
279
- watchState.update(targetDir, { fileHashes: currentHashes });
280
- return;
417
+ watchState.mergeFileHashes(targetDir, { set: currentHashes });
418
+ // Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
419
+ // This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
420
+ // very next tick after one, so the backoff could never grow past its first step.
421
+ return { noContact: true };
281
422
  }
282
423
 
283
424
  // A quiet backstop run only speaks up when it actually found something fs.watch missed.
@@ -287,6 +428,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
287
428
  // using the same helper as init/add-repo. A gitignored secret edited after
288
429
  // onboarding (e.g. a raw .env) must never be re-uploaded here.
289
430
  let changedToUpload = changed;
431
+ const gitignoreDropped = [];
290
432
  if (changed.length > 0) {
291
433
  const drops = folderSync.gitignoreDrops(targetDir, changed);
292
434
  if (drops.size > 0) {
@@ -294,7 +436,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
294
436
  // Don't persist hashes for dropped (gitignored / unverifiable) files — they
295
437
  // were never uploaded, so leave them out of state so they're re-evaluated
296
438
  // rather than recorded as synced.
297
- for (const rp of drops) delete currentHashes[rp];
439
+ for (const rp of drops) { delete currentHashes[rp]; gitignoreDropped.push(rp); }
298
440
  }
299
441
  }
300
442
 
@@ -313,16 +455,61 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
313
455
  await agentApi.completeSync(gitId, sessionId);
314
456
  }
315
457
 
458
+ // Re-check each file immediately before telling the server to remove it. The walk that built this
459
+ // list ran BEFORE the uploads above, which on a large delta take minutes, so by now a file may exist
460
+ // again — or may never have been gone, having merely been unreadable at the wrong moment. The live
461
+ // per-file path already stats before deleting; this one fired blind.
462
+ // Per-file failures are swallowed below so one bad path cannot abort the pass. They still have to be
463
+ // REPORTED, or a pass in which everything failed is indistinguishable from one in which all was well.
464
+ let failures = 0;
465
+ const reappeared = [];
466
+ // Only paths the server actually accepted. Marking a FAILED delete as done drops it from the
467
+ // baseline, and once dropped it can never re-enter `deleted` — so the file stays on the server, stays
468
+ // indexed, and nothing retries it. With the burst guard that is systematic: delete 500 files, the
469
+ // first 100 land, the rest are refused, and all 500 were being forgotten.
470
+ const deletedOk = [];
316
471
  for (const rp of deleted) {
472
+ // Existing on disk is NOT on its own a reason to keep it. A file leaves the manifest for two
473
+ // different reasons: it was removed from disk, or it stopped matching the include/exclude
474
+ // patterns. Only the first can "come back". Rescuing on existence alone meant adding an exclude
475
+ // pattern no longer removed the file from Niro — so a path excluded BECAUSE it holds secrets
476
+ // would have gone on being indexed for ever.
477
+ if (fs.existsSync(path.join(targetDir, rp)) && inManifestScope(rp, state)) {
478
+ reappeared.push(rp);
479
+ continue;
480
+ }
317
481
  try {
318
482
  await agentApi.deleteFile(gitId, rp);
483
+ deletedOk.push(rp);
319
484
  } catch (err) {
485
+ // Swallowed so one bad path cannot abort the pass, but COUNTED: a pass whose deltas are deletes
486
+ // only never throws, so without this the caller saw a clean resolve while every single delete was
487
+ // being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
488
+ failures++;
320
489
  console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
321
490
  }
322
491
  }
323
-
324
- watchState.update(targetDir, { fileHashes: currentHashes, lastSyncedAt: new Date().toISOString() });
492
+ if (reappeared.length > 0) {
493
+ console.warn(color.yellow(
494
+ ` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
495
+ `Sample: ${reappeared.slice(0, 5).join(", ")}`));
496
+ // Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
497
+ // (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
498
+ // was never uploaded — recording the new hash would claim the server holds content it never
499
+ // received. Leaving its OLD entry untouched is what makes the next pass see a hash mismatch and
500
+ // upload it. It is in scope and on disk, so that pass finds it in the manifest.
501
+ }
502
+ const actuallyDeleted = deletedOk;
503
+
504
+ // Merge rather than replace. Removals are explicit: the files we actually deleted, plus any the
505
+ // gitignore policy dropped (those were never uploaded, so they must not stay recorded as synced).
506
+ watchState.mergeFileHashes(targetDir, {
507
+ set: currentHashes,
508
+ remove: [...actuallyDeleted, ...gitignoreDropped],
509
+ patch: { lastSyncedAt: new Date().toISOString() },
510
+ });
325
511
  console.log(` ${color.green("✓")} Delta sync complete.`);
512
+ return { failures };
326
513
  } finally {
327
514
  syncInFlight.delete(inFlightKey);
328
515
  }
@@ -335,6 +522,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
335
522
  // daemon kept uploading to the OLD gitId — every edit failed with "Repository not found"
336
523
  // until the daemon was manually restarted. Reading fresh also picks up include/exclude
337
524
  // pattern changes from `niro edit`.
525
+ //
526
+ // Held back by the last sync pass: this folder is not the one its temporary project reflects, so
527
+ // uploading would mix two branches into one copy. Re-checked every pass, so it clears by itself.
528
+ if (notHolding.has(path.resolve(targetDir))) return;
338
529
  const fresh = watchState.get(targetDir, api.currentBaseUrl());
339
530
  if (!fresh || !fresh.gitId) {
340
531
  // Folder was released / is no longer tracked for this backend — stop syncing it.
@@ -390,9 +581,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
390
581
  // File deleted
391
582
  try {
392
583
  await agentApi.deleteFile(gitId, relativePath);
393
- const hashes = { ...(watchState.get(targetDir) || {}).fileHashes };
394
- delete hashes[relativePath];
395
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
584
+ watchState.mergeFileHashes(targetDir, {
585
+ remove: [relativePath],
586
+ patch: { lastSyncedAt: new Date().toISOString() },
587
+ });
396
588
  console.log(` ${color.yellow("−")} ${relativePath}`);
397
589
  } catch (err) {
398
590
  console.error(color.red(` Failed to delete ${relativePath}: ${err.message}`));
@@ -422,8 +614,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
422
614
 
423
615
  try {
424
616
  await agentApi.upsertFile(gitId, relativePath, absPath);
425
- const hashes = { ...storedHashes, [relativePath]: newHash };
426
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
617
+ watchState.mergeFileHashes(targetDir, {
618
+ set: { [relativePath]: newHash },
619
+ patch: { lastSyncedAt: new Date().toISOString() },
620
+ });
427
621
  console.log(` ${color.green("+")} ${relativePath}`);
428
622
  } catch (err) {
429
623
  console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
@@ -435,6 +629,13 @@ function shouldIgnore(relativePath) {
435
629
  return parts.some(p => IGNORED_DIRS.has(p) || p.startsWith("."));
436
630
  }
437
631
 
632
+ /** Mirrors buildMinimalManifest's membership rule: empty includePatterns means everything is in. */
633
+ function inManifestScope(relativePath, state) {
634
+ const include = state.includePatterns;
635
+ if (!include || include.length === 0) return true;
636
+ return matchesPatterns(relativePath, include, state.excludePatterns);
637
+ }
638
+
438
639
  function matchesPatterns(relativePath, includePatterns, excludePatterns) {
439
640
  if (!includePatterns || includePatterns.length === 0) return true;
440
641
  const matched = includePatterns.some(p => minimatch(relativePath, p));
@@ -495,4 +696,5 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
495
696
 
496
697
  // handleFileEvent + deltaSyncOnResume are exported for unit testing the
497
698
  // gitignore/fail-closed guards without a live server or fs.watch loop.
498
- module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure };
699
+ module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
700
+ nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
package/src/index.js CHANGED
@@ -9,7 +9,34 @@ function main() {
9
9
  .name("niro")
10
10
  .description("Niro CLI — onboard repos, build indexes, manage projects from the terminal.")
11
11
  .version(pkg.version)
12
- .option("--api-url <url>", "override API base URL (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
12
+ .option("--api-url <url>", "backend to log in to — `niro login` only (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
13
+
14
+ // --api-url is accepted by `niro login` and REFUSED everywhere else.
15
+ //
16
+ // It used to be a global override, but it was only half honoured. api.request takes it
17
+ // (api.js: `const base = apiUrl || baseUrl(creds)`), while the upload chain builds its own
18
+ // URL from the stored credentials and never sees it (agentApi.js:113 and :175). So
19
+ // `niro init --api-url <local>` while logged in elsewhere listed projects from one backend
20
+ // and uploaded the folder's source to the other — one command, two servers, no warning.
21
+ //
22
+ // Threading it through every path is the other possible fix, but the backend is a property
23
+ // of a SESSION, not of a single command: credentials, tokens, gitIds and watchState tags are
24
+ // all scoped to one backend, so a per-command override can only ever be a way to mix them.
25
+ // Choosing it once, at login, is the honest contract. NIRO_API_URL and the config file still
26
+ // work for every command, because both feed the same resolution chain the uploader uses.
27
+ //
28
+ // `niro new-temp-project` already refused this flag for exactly this reason; that local
29
+ // check is now redundant and has been removed in favour of this one.
30
+ program.hook("preAction", (_thisCommand, actionCommand) => {
31
+ if (!program.opts().apiUrl) return;
32
+ if (actionCommand.name() === "login") return;
33
+ console.error("`--api-url` only applies to `niro login`.");
34
+ console.error(" It was ignored by the upload path, so using it here would read from one");
35
+ console.error(" backend and write to another. Pick the backend once instead:");
36
+ console.error(" niro login --api-url <url> log in to that backend");
37
+ console.error(" NIRO_API_URL=<url> niro <cmd> or set it for every command");
38
+ process.exit(1);
39
+ });
13
40
 
14
41
  program
15
42
  .command("login")
@@ -19,7 +46,7 @@ function main() {
19
46
  .option("--email <email>", "email address (password flow only)")
20
47
  .action((opts) => run(async () => {
21
48
  const loginCmd = require("./commands/login");
22
- await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl });
49
+ await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl }); // login: the one command that may set it
23
50
  }));
24
51
 
25
52
  program
@@ -48,7 +75,7 @@ function main() {
48
75
  .option("--quiet-period <seconds>", "debounce seconds before an auto-build (used with --auto-build on)")
49
76
  .option("--confirm-build <yes|no>", "whether to trigger the initial index build now without prompting")
50
77
  .option("--defaults", "accept default values for all env vars without prompting")
51
- .option("--force", "proceed past cross-project/overlap warnings without confirming")
78
+ .option("--force", "onboard a folder with no build file, and proceed past cross-project/overlap warnings without confirming (never bypasses the home/root refusal)")
52
79
  .option("--no-watch", "return immediately after triggering the build instead of watching")
53
80
  .option(
54
81
  "-X, --module-exclude <pattern>",
@@ -64,7 +91,7 @@ function main() {
64
91
  )
65
92
  .action((pathArg, opts) => run(async () => {
66
93
  const initCmd = require("./commands/init");
67
- await initCmd.init(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
94
+ await initCmd.init(pathArg, { ...opts });
68
95
  }));
69
96
 
70
97
  // `niro add` subcommand group
@@ -79,6 +106,7 @@ function main() {
79
106
  .option("--name <name>", "display name for this repo")
80
107
  .option("--sync-mode <local|remote>", "sync on local file changes or remote commits (skips the interactive choice)")
81
108
  .option("--no-confirm", "skip confirmation prompts (non-interactive)")
109
+ .option("--force", "onboard a folder with no build file in it (never bypasses the home/root refusal)")
82
110
  .option("--token-id <id>", "git token ID for private repos")
83
111
  .option(
84
112
  "-X, --module-exclude <pattern>",
@@ -94,7 +122,7 @@ function main() {
94
122
  )
95
123
  .action((pathArg, opts) => run(async () => {
96
124
  const cmd = require("./commands/add-repo");
97
- await cmd.addRepo(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
125
+ await cmd.addRepo(pathArg, { ...opts });
98
126
  }));
99
127
 
100
128
  add
@@ -103,7 +131,7 @@ function main() {
103
131
  .option("--no-watch", "return immediately after triggering the build")
104
132
  .action((opts) => run(async () => {
105
133
  const cmd = require("./commands/add-project");
106
- await cmd.addProject({ ...opts, apiUrl: program.opts().apiUrl });
134
+ await cmd.addProject({ ...opts });
107
135
  }));
108
136
 
109
137
  program
@@ -112,7 +140,7 @@ function main() {
112
140
  .option("--json", "emit machine-readable JSON")
113
141
  .action((pathArg, opts) => run(async () => {
114
142
  const infoCmd = require("./commands/info");
115
- await infoCmd.info(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
143
+ await infoCmd.info(pathArg, { ...opts });
116
144
  }));
117
145
 
118
146
  program
@@ -131,7 +159,7 @@ function main() {
131
159
  .option("--json", "emit machine-readable JSON where supported (set-env, list-env)")
132
160
  .action((opts) => run(async () => {
133
161
  const editCmd = require("./commands/edit");
134
- await editCmd.edit({ ...opts, apiUrl: program.opts().apiUrl });
162
+ await editCmd.edit({ ...opts });
135
163
  }));
136
164
 
137
165
  program
@@ -143,7 +171,7 @@ function main() {
143
171
  .option("--confirm-name <value>", "required for --project: must equal the project's alias or id")
144
172
  .action((opts) => run(async () => {
145
173
  const removeCmd = require("./commands/remove");
146
- await removeCmd.remove({ ...opts, apiUrl: program.opts().apiUrl });
174
+ await removeCmd.remove({ ...opts });
147
175
  }));
148
176
 
149
177
  program
@@ -161,7 +189,7 @@ function main() {
161
189
  .option("--project <id_or_alias>", "which project to base the temporary project on when the repo maps to several")
162
190
  .action((pathArg, opts) => run(async () => {
163
191
  const cmd = require("./commands/takeover");
164
- await cmd.takeover(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
192
+ await cmd.takeover(pathArg, { ...opts });
165
193
  }));
166
194
 
167
195
  program
@@ -172,7 +200,7 @@ function main() {
172
200
  .option("--project <temp_project_id>", "the temporary project to discard, when this machine has no local record of it (e.g. created elsewhere)")
173
201
  .action((pathArg, opts) => run(async () => {
174
202
  const cmd = require("./commands/release");
175
- await cmd.release(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
203
+ await cmd.release(pathArg, { ...opts });
176
204
  }));
177
205
 
178
206
  program
@@ -181,7 +209,7 @@ function main() {
181
209
  .option("--json", "emit machine-readable JSON")
182
210
  .action((projectId, opts) => run(async () => {
183
211
  const statusCmd = require("./commands/status");
184
- await statusCmd.status(projectId, { ...opts, apiUrl: program.opts().apiUrl });
212
+ await statusCmd.status(projectId, { ...opts });
185
213
  }));
186
214
 
187
215
  program
@@ -191,7 +219,7 @@ function main() {
191
219
  .option("--json", "emit machine-readable JSON")
192
220
  .action((opts) => run(async () => {
193
221
  const projectsCmd = require("./commands/projects");
194
- await projectsCmd.projects({ ...opts, apiUrl: program.opts().apiUrl });
222
+ await projectsCmd.projects({ ...opts });
195
223
  }));
196
224
 
197
225
  program
@@ -202,7 +230,7 @@ function main() {
202
230
  .option("--json", "emit machine-readable JSON status (NDJSON: one object per status change + a final summary)")
203
231
  .action((projectId, opts) => run(async () => {
204
232
  const buildCmd = require("./commands/build");
205
- await buildCmd.build(projectId, { ...opts, apiUrl: program.opts().apiUrl });
233
+ await buildCmd.build(projectId, { ...opts });
206
234
  }));
207
235
 
208
236
  program
@@ -211,7 +239,7 @@ function main() {
211
239
  .option("-y, --yes", "skip the confirmation prompt")
212
240
  .action((projectId, opts) => run(async () => {
213
241
  const deleteCmd = require("./commands/delete");
214
- await deleteCmd.deleteProject(projectId, { ...opts, apiUrl: program.opts().apiUrl });
242
+ await deleteCmd.deleteProject(projectId, { ...opts });
215
243
  }));
216
244
 
217
245
  const svc = program
@@ -265,6 +293,12 @@ function main() {
265
293
  .command("serve")
266
294
  .description("Run the Niro MCP server over stdio (used by your editor's MCP config).")
267
295
  .action(() => { require("./commands/mcp").serve(); });
296
+ mcp
297
+ .command("receipt")
298
+ .description("Turn the end-of-task Niro line on or off for this machine. No flags shows the current state.")
299
+ .option("--on", "Turn the end-of-task line back on.")
300
+ .option("--off", "Stop showing the end-of-task line.")
301
+ .action((opts) => { require("./commands/mcp").receipt(opts); });
268
302
 
269
303
  program.parseAsync(process.argv).catch((err) => {
270
304
  console.error(err && err.message ? err.message : err);