@niroai/niro 0.9.0 → 0.10.1

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.
@@ -73,9 +73,9 @@ async function takeover(pathArg, opts = {}) {
73
73
  const list = ctx.projects.map((p) => ` ${p.project_id} (${p.alias})`).join("\n");
74
74
  throw new Error(`Multiple projects match this repo — pass --project:\n${list}`);
75
75
  }
76
- console.log("\n Multiple projects match this repo:");
76
+ console.log("\n Multiple projects match this repo:");
77
77
  ctx.projects.forEach((p, i) => console.log(` ${i + 1}. ${p.alias || "(no alias)"} ${color.dim(p.project_id)}`));
78
- const answer = await prompt.ask(` Take over which one? [1-${ctx.projects.length}]`, "1");
78
+ const answer = await prompt.ask(` Take over which one? [1-${ctx.projects.length}]`, "1");
79
79
  const idx = parseInt(answer, 10) - 1;
80
80
  if (isNaN(idx) || idx < 0 || idx >= ctx.projects.length) {
81
81
  throw new Error(`Invalid choice: ${answer}`);
@@ -121,11 +121,11 @@ async function takeover(pathArg, opts = {}) {
121
121
  && path.resolve(e.folderPath) !== here);
122
122
  if (holder) {
123
123
  const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
124
- console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
125
- console.log(warn(" held by another folder:"));
124
+ console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
125
+ console.log(warn(" held by another folder:"));
126
126
  console.log(` ${holder.folderPath}${onBranch}`);
127
- console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
128
- console.log(color.dim(" project until `niro new-temp-project` is run there again."));
127
+ console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
128
+ console.log(color.dim(" project until `niro new-temp-project` is run there again."));
129
129
  if (prompt.isNonInteractive()) {
130
130
  // Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
131
131
  // would break any agent or script that runs this unattended. The way forward is two explicit
@@ -136,13 +136,13 @@ async function takeover(pathArg, opts = {}) {
136
136
  + holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
137
137
  + ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
138
138
  }
139
- if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
139
+ if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
140
140
  throw new Error("Cancelled — the temporary project stays where it is.");
141
141
  }
142
142
  }
143
143
 
144
- console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
145
- console.log(color.dim(" Your original project is not changed."));
144
+ console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
145
+ console.log(color.dim(" Your original project is not changed."));
146
146
 
147
147
  // 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
148
148
  // the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
@@ -163,16 +163,16 @@ async function takeover(pathArg, opts = {}) {
163
163
  // route; its absence means a version mismatch the user must hear about NOW, not discover via wrong answers.
164
164
  const serverRoutes = outcome.routing_supported === true || outcome.routingSupported === true;
165
165
  if (!serverRoutes) {
166
- console.log(warn("\n ⚠ Your backend did NOT confirm automatic routing (pre-ADR-018 server?)."));
166
+ console.log(warn("\n ⚠ Your backend did NOT confirm automatic routing (pre-ADR-018 server?)."));
167
167
  console.log(warn(" This CLI no longer re-pins .niro-project, so against this backend Niro's answers"));
168
168
  console.log(warn(" will KEEP coming from the original project — NOT this temporary project."));
169
169
  console.log(warn(" Fix: upgrade the backend (ai-assistant + ai-orchestrator), or use the previous CLI."));
170
170
  }
171
171
  if (wasExtended) {
172
- console.log(` ${step("●")} Added this repo to your temporary project: ${color.bold(outcome.alias || newProjectId)} ` +
172
+ console.log(` ${step("●")} Added this repo to your temporary project: ${color.bold(outcome.alias || newProjectId)} ` +
173
173
  color.dim("(existing copy extended — your other repos are untouched)"));
174
174
  } else {
175
- console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
175
+ console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
176
176
  color.dim(`(${outcome.nodes_copied ?? "?"} nodes copied, no re-index needed)`));
177
177
  }
178
178
 
@@ -190,7 +190,7 @@ async function takeover(pathArg, opts = {}) {
190
190
  folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
191
191
  const sizes = {};
192
192
  for (const m of manifest) sizes[m.path] = m.size_bytes;
193
- console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
193
+ console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
194
194
  `(branch ${color.cyan(branch || "detached HEAD")})...`);
195
195
  // Capture the uploaded hashes: takeover registers its watchState entry AFTER this upload (below), so
196
196
  // doInitialSync's own watchState.update no-ops (no entry yet). Seeding these into the final set means
@@ -203,9 +203,9 @@ async function takeover(pathArg, opts = {}) {
203
203
  await api.post(`/api/graph/${encodeURIComponent(newProjectId)}/build`, {}, {
204
204
  query: { force_full_rebuild: "false" },
205
205
  });
206
- console.log(` ${step("●")} Incremental index of your working tree triggered.`);
206
+ console.log(` ${step("●")} Incremental index of your working tree triggered.`);
207
207
  } catch (buildErr) {
208
- console.log(warn(` ⚠ Could not trigger the index build automatically (${buildErr.message}). ` +
208
+ console.log(warn(` ⚠ Could not trigger the index build automatically (${buildErr.message}). ` +
209
209
  "Run `niro build` to index your uploaded tree."));
210
210
  }
211
211
 
@@ -265,11 +265,11 @@ async function takeover(pathArg, opts = {}) {
265
265
  }
266
266
  }
267
267
 
268
- console.log(`\n ${ok("✓")} Niro now answers from your temporary project whenever you are on this branch`);
268
+ console.log(`\n ${ok("✓")} Niro now answers from your temporary project whenever you are on this branch`);
269
269
  console.log(color.dim(" (routing is automatic per branch — your .niro-project file was NOT changed)."));
270
- console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
271
- console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
272
- console.log(color.dim(" routing falls back to the original project by itself."));
270
+ console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
271
+ console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
272
+ console.log(color.dim(" routing falls back to the original project by itself."));
273
273
  }
274
274
 
275
275
 
@@ -157,7 +157,7 @@ async function watch(pathArg, opts = {}) {
157
157
  const resolved = path.resolve(entry.folderPath);
158
158
  if (!watched.has(resolved)) {
159
159
  watched.add(resolved);
160
- console.log(` ${color.green("↻")} Now watching new folder: ${entry.folderPath}`);
160
+ console.log(` ${color.green("↻")} Now watching new folder: ${entry.folderPath}`);
161
161
  startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
162
162
  }
163
163
  }
@@ -173,9 +173,9 @@ async function watch(pathArg, opts = {}) {
173
173
  fs.watch(stateDir, { persistent: false }, (_evt, filename) => {
174
174
  if (!filename || filename === stateName) reconcile();
175
175
  }).on("error", (err) =>
176
- console.error(color.yellow(` State-file watcher error (recovered): ${err.message}`)));
176
+ console.error(color.yellow(` State-file watcher error (recovered): ${err.message}`)));
177
177
  } catch (err) {
178
- console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
178
+ console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
179
179
  }
180
180
  // Keep process alive
181
181
  await new Promise(() => {});
@@ -203,7 +203,7 @@ async function handleEntryFailure(entry, err, watched) {
203
203
  const removed = watchState.remove(entry.folderPath, entry.gitId);
204
204
  if (!removed) {
205
205
  console.error(color.yellow(
206
- ` Self-heal skipped for ${entry.folderPath}: it was re-pointed to a new repo mid-check — kept.`));
206
+ ` Self-heal skipped for ${entry.folderPath}: it was re-pointed to a new repo mid-check — kept.`));
207
207
  return;
208
208
  }
209
209
  // CRITICAL: also drop the folder from the daemon's live `watched` set. Otherwise, when the same
@@ -212,7 +212,7 @@ async function handleEntryFailure(entry, err, watched) {
212
212
  // silently stop syncing until a full daemon restart. Pruning here lets the reconcile re-adopt it.
213
213
  if (watched) watched.delete(path.resolve(entry.folderPath));
214
214
  console.error(color.yellow(
215
- ` Self-healed: ${entry.folderPath} pointed at a repo that no longer exists on this ` +
215
+ ` Self-healed: ${entry.folderPath} pointed at a repo that no longer exists on this ` +
216
216
  `backend (${entry.gitId}) — entry removed; other folders unaffected.`));
217
217
  return;
218
218
  }
@@ -238,8 +238,8 @@ async function startWatch(targetDir, opts = {}) {
238
238
 
239
239
  const { gitId } = state;
240
240
 
241
- console.log(`\n ${color.cyan("Watching")} ${targetDir}`);
242
- console.log(` gitId: ${color.dim(gitId)}`);
241
+ console.log(`\n ${color.cyan("Watching")} ${targetDir}`);
242
+ console.log(` gitId: ${color.dim(gitId)}`);
243
243
 
244
244
  // Delta sync on startup. A repo-not-found error must still propagate so the caller's handleEntryFailure
245
245
  // can self-heal a poison entry. But a TRANSIENT failure (network blip at daemon start) must NOT prevent
@@ -251,7 +251,7 @@ async function startWatch(targetDir, opts = {}) {
251
251
  } catch (err) {
252
252
  if (agentApi.isRepoNotFound(err)) throw err; // poison entry — let handleEntryFailure remove it
253
253
  console.error(color.yellow(
254
- ` Initial sync failed for ${targetDir} (${err.message}) — watching anyway; the backstop will retry.`));
254
+ ` Initial sync failed for ${targetDir} (${err.message}) — watching anyway; the backstop will retry.`));
255
255
  }
256
256
 
257
257
  // Start heartbeat. Re-read fresh state each tick exactly like the backstop below: a `niro
@@ -294,16 +294,16 @@ async function startWatch(targetDir, opts = {}) {
294
294
  // A rejection here would be an unhandled promise rejection = process death in
295
295
  // modern Node. One file's failure must never take the daemon down.
296
296
  handleFileEvent(gitId, targetDir, relativePath, state).catch(err =>
297
- console.error(color.red(` Watch event failed for ${relativePath}: ${err.message}`)));
297
+ console.error(color.red(` Watch event failed for ${relativePath}: ${err.message}`)));
298
298
  }, DEBOUNCE_MS));
299
299
  });
300
300
 
301
301
  // fs.watch emits 'error' events (EMFILE, watched dir removed, …); with no listener
302
302
  // an EventEmitter 'error' THROWS and kills the process.
303
303
  watcher.on("error", (err) =>
304
- console.error(color.red(` Watcher error for ${targetDir}: ${err.message}`)));
304
+ console.error(color.red(` Watcher error for ${targetDir}: ${err.message}`)));
305
305
 
306
- console.log(` ${color.green("✓")} Watching for changes. Press Ctrl+C to stop.\n`);
306
+ console.log(` ${color.green("✓")} Watching for changes. Press Ctrl+C to stop.\n`);
307
307
 
308
308
  // Graceful shutdown
309
309
  process.on("SIGINT", () => {
@@ -311,7 +311,7 @@ async function startWatch(targetDir, opts = {}) {
311
311
  clearInterval(rescanTimer);
312
312
  watcher.close();
313
313
  for (const t of debounceTimers.values()) clearTimeout(t);
314
- console.log("\n Watch stopped.");
314
+ console.log("\n Watch stopped.");
315
315
  process.exit(0);
316
316
  });
317
317
 
@@ -366,7 +366,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
366
366
  return { notHolder: true };
367
367
  }
368
368
  if (notHolding.delete(inFlightKey)) {
369
- console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
369
+ console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
370
370
  }
371
371
  }
372
372
  const storedHashes = state.fileHashes || {};
@@ -387,14 +387,14 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
387
387
  if (!unreadableRoots.has(path.resolve(targetDir))) {
388
388
  unreadableRoots.add(path.resolve(targetDir));
389
389
  console.error(color.red(
390
- ` Cannot read ${targetDir} — skipping this sync entirely rather than reporting every file ` +
390
+ ` Cannot read ${targetDir} — skipping this sync entirely rather than reporting every file ` +
391
391
  `deleted. Check the folder still exists and is readable. ` +
392
392
  `This will not be repeated until it can be read again.`));
393
393
  }
394
394
  return;
395
395
  }
396
396
  if (unreadableRoots.delete(path.resolve(targetDir))) {
397
- console.log(color.green(` ${targetDir} is readable again — syncing resumed.`));
397
+ console.log(color.green(` ${targetDir} is readable again — syncing resumed.`));
398
398
  }
399
399
 
400
400
  const unreadable = [];
@@ -423,7 +423,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
423
423
 
424
424
  if (unreadable.length > 0) {
425
425
  console.warn(color.yellow(
426
- ` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
426
+ ` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
427
427
  `reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
428
428
  }
429
429
 
@@ -446,13 +446,13 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
446
446
 
447
447
  if (unreadableDirs.length > 0) {
448
448
  console.warn(color.yellow(
449
- ` ${failedDirs.length} directory(ies) could not be read this pass, so ${unreadableDirs.length} ` +
449
+ ` ${failedDirs.length} directory(ies) could not be read this pass, so ${unreadableDirs.length} ` +
450
450
  `file(s) under them are being left alone rather than reported deleted. ` +
451
451
  `Sample: ${failedDirs.slice(0, 3).join(", ")}`));
452
452
  }
453
453
 
454
454
  if (changed.length === 0 && deleted.length === 0) {
455
- if (!quiet) console.log(` No changes since last sync.`);
455
+ if (!quiet) console.log(` No changes since last sync.`);
456
456
  watchState.mergeFileHashes(targetDir, { set: currentHashes, expectedGitId: gitId });
457
457
  // Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
458
458
  // This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
@@ -461,7 +461,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
461
461
  }
462
462
 
463
463
  // A quiet backstop run only speaks up when it actually found something fs.watch missed.
464
- console.log(` ${quiet ? color.yellow("↻ backstop: ") : ""}Delta: ${changed.length} changed, ${deleted.length} deleted — syncing...`);
464
+ console.log(` ${quiet ? color.yellow("↻ backstop: ") : ""}Delta: ${changed.length} changed, ${deleted.length} deleted — syncing...`);
465
465
 
466
466
  // Honor .gitignore (+ fail-closed-for-config) on the changed set BEFORE upload,
467
467
  // using the same helper as init/add-repo. A gitignored secret edited after
@@ -525,12 +525,12 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
525
525
  // only never throws, so without this the caller saw a clean resolve while every single delete was
526
526
  // being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
527
527
  failures++;
528
- console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
528
+ console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
529
529
  }
530
530
  }
531
531
  if (reappeared.length > 0) {
532
532
  console.warn(color.yellow(
533
- ` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
533
+ ` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
534
534
  `Sample: ${reappeared.slice(0, 5).join(", ")}`));
535
535
  // Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
536
536
  // (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
@@ -550,9 +550,9 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
550
550
  patch: { lastSyncedAt: new Date().toISOString() },
551
551
  expectedGitId: gitId,
552
552
  })) {
553
- console.log(color.dim(" Folder was re-pointed during this sync — its results were not recorded."));
553
+ console.log(color.dim(" Folder was re-pointed during this sync — its results were not recorded."));
554
554
  }
555
- console.log(` ${color.green("✓")} Delta sync complete.`);
555
+ console.log(` ${color.green("✓")} Delta sync complete.`);
556
556
  return { failures };
557
557
  } finally {
558
558
  syncInFlight.delete(inFlightKey);
@@ -573,7 +573,7 @@ function queueDelete(targetDir, relativePath, settleMs = DELETE_SETTLE_MS) {
573
573
  if (entry.timer) clearTimeout(entry.timer);
574
574
  entry.timer = setTimeout(() => {
575
575
  flushPendingDeletes(targetDir).catch(err =>
576
- console.error(color.red(` Delete flush failed for ${targetDir}: ${err.message}`)));
576
+ console.error(color.red(` Delete flush failed for ${targetDir}: ${err.message}`)));
577
577
  }, settleMs);
578
578
  if (entry.timer.unref) entry.timer.unref(); // must never hold the daemon open
579
579
  }
@@ -609,7 +609,7 @@ async function flushPendingDeletes(targetDir) {
609
609
 
610
610
  if (returned.length > 0) {
611
611
  console.log(color.dim(
612
- ` ${returned.length} file(s) reappeared while settling — not deleted` +
612
+ ` ${returned.length} file(s) reappeared while settling — not deleted` +
613
613
  (returned.length <= 3 ? `: ${returned.join(", ")}` : "")));
614
614
  }
615
615
 
@@ -621,11 +621,11 @@ async function flushPendingDeletes(targetDir) {
621
621
  patch: { lastSyncedAt: new Date().toISOString() },
622
622
  expectedGitId: fresh.gitId,
623
623
  });
624
- console.log(` ${color.yellow("−")} ${rp}`);
624
+ console.log(` ${color.yellow("−")} ${rp}`);
625
625
  } catch (err) {
626
626
  // Keep the baseline entry so a later pass retries. Dropping it here is how a refused delete
627
627
  // became permanent (see fb3ff0a).
628
- console.error(color.red(` Failed to delete ${rp}: ${err.message}`));
628
+ console.error(color.red(` Failed to delete ${rp}: ${err.message}`));
629
629
  }
630
630
  }
631
631
  }
@@ -661,18 +661,18 @@ function backstopTick(targetDir) {
661
661
  if (result.failures > 0) {
662
662
  const delayMs = recordBackstopFailure(backoffKey, Date.now());
663
663
  console.error(color.red(
664
- ` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
664
+ ` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
665
665
  `${Math.round(delayMs / 60_000)} min.`));
666
666
  return;
667
667
  }
668
668
  if (recordBackstopSuccess(backoffKey)) {
669
- console.log(color.green(` Sync recovered for ${targetDir}.`));
669
+ console.log(color.green(` Sync recovered for ${targetDir}.`));
670
670
  }
671
671
  })
672
672
  .catch(err => {
673
673
  const delayMs = recordBackstopFailure(backoffKey, Date.now());
674
674
  console.error(color.red(
675
- ` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
675
+ ` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
676
676
  `${Math.round(delayMs / 60_000)} min.`));
677
677
  });
678
678
  }
@@ -714,19 +714,19 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
714
714
  // A daemon has no TTY — never block on (or crash in) a prompt. Leave the
715
715
  // directory unsynced and tell the user how to include it.
716
716
  console.log(color.yellow(
717
- ` New directory '${relativePath}' detected — not synced. ` +
717
+ ` New directory '${relativePath}' detected — not synced. ` +
718
718
  `Run \`niro watch\` interactively (or \`niro edit\`) to include it.`));
719
719
  return;
720
720
  }
721
721
  const add = await prompt.confirm(
722
- `\n New directory '${relativePath}' detected. Add to sync?`,
722
+ `\n New directory '${relativePath}' detected. Add to sync?`,
723
723
  false
724
724
  );
725
725
  if (add) {
726
726
  const newPattern = relativePath + "/**";
727
727
  state.includePatterns.push(newPattern);
728
728
  watchState.update(targetDir, { includePatterns: state.includePatterns });
729
- console.log(` Added include pattern: ${color.cyan(newPattern)}`);
729
+ console.log(` Added include pattern: ${color.cyan(newPattern)}`);
730
730
  } else {
731
731
  state.excludePatterns.push(relativePath + "/**");
732
732
  watchState.update(targetDir, { excludePatterns: state.excludePatterns });
@@ -784,9 +784,9 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
784
784
  patch: { lastSyncedAt: new Date().toISOString() },
785
785
  expectedGitId: gitId,
786
786
  });
787
- console.log(` ${color.green("+")} ${relativePath}`);
787
+ console.log(` ${color.green("+")} ${relativePath}`);
788
788
  } catch (err) {
789
- console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
789
+ console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
790
790
  }
791
791
  }
792
792
 
package/src/index.js CHANGED
@@ -31,13 +31,21 @@ function main() {
31
31
  if (!program.opts().apiUrl) return;
32
32
  if (actionCommand.name() === "login") return;
33
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:");
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
36
  console.error(" niro login --api-url <url> log in to that backend");
37
37
  console.error(" NIRO_API_URL=<url> niro <cmd> or set it for every command");
38
38
  process.exit(1);
39
39
  });
40
40
 
41
+ program
42
+ .command("start [path]")
43
+ .description("First-time setup, end to end: sign in, onboard this folder, connect your AI assistant.")
44
+ .action((pathArg) => run(async () => {
45
+ const startCmd = require("./commands/start");
46
+ await startCmd.start(pathArg, {});
47
+ }));
48
+
41
49
  program
42
50
  .command("login")
43
51
  .description("Log in to Niro. Default: device authorization in your browser. (--api-key or --password for alternatives.)")
@@ -134,6 +142,44 @@ function main() {
134
142
  await cmd.addProject({ ...opts });
135
143
  }));
136
144
 
145
+ // `niro ab` subcommand group
146
+ const ab = program
147
+ .command("ab")
148
+ .description("Measure what Niro changes: run it off for half your sessions, then compare.");
149
+
150
+ ab
151
+ .command("status", { isDefault: true })
152
+ .description("Is a measurement window open, and until when?")
153
+ .action(() => run(async () => {
154
+ await require("./commands/ab").status();
155
+ }));
156
+
157
+ ab
158
+ .command("enable")
159
+ .description("Start a measurement window. Account-wide; asks first.")
160
+ .option("--days <n>", "window length in days (default: 14)")
161
+ .option("--name <name>", "label for this run")
162
+ .option("-y, --yes", "skip the confirmation (for scripts)")
163
+ .action((opts) => run(async () => {
164
+ await require("./commands/ab").enable({ ...opts });
165
+ }));
166
+
167
+ ab
168
+ .command("disable")
169
+ .description("Stop the current window early. Every session uses Niro again.")
170
+ .action(() => run(async () => {
171
+ await require("./commands/ab").disable();
172
+ }));
173
+
174
+ ab
175
+ .command("report")
176
+ .description("Show the with-Niro and without-Niro comparison.")
177
+ .option("--run-id <id>", "a specific past run (default: the latest)")
178
+ .option("--json", "emit machine-readable JSON")
179
+ .action((opts) => run(async () => {
180
+ await require("./commands/ab").report({ ...opts });
181
+ }));
182
+
137
183
  program
138
184
  .command("info [path]")
139
185
  .description("Show what Niro knows about this folder: git info + which project(s) it maps to and their status.")
@@ -300,6 +346,14 @@ function main() {
300
346
  .option("--off", "Stop showing the end-of-task line.")
301
347
  .action((opts) => { require("./commands/mcp").receipt(opts); });
302
348
 
349
+ // Bare `niro` used to print an 18-command help listing, which is the worst possible
350
+ // first screen for someone who does not yet know what any of them do. With no stored
351
+ // credentials that person is, by definition, new: send them to the wizard instead.
352
+ // Once they have credentials the help listing is what they asked for, so it stays.
353
+ if (process.argv.length <= 2 && !require("./commands/start").hasCredentials()) {
354
+ process.argv.push("start");
355
+ }
356
+
303
357
  program.parseAsync(process.argv).catch((err) => {
304
358
  console.error(err && err.message ? err.message : err);
305
359
  process.exit(1);
@@ -319,7 +373,7 @@ function run(fn) {
319
373
  process.exit(1);
320
374
  }
321
375
  console.error(err && err.stack ? err.stack.split("\n")[0] : String(err));
322
- if (err && err.status) console.error(` (HTTP ${err.status})`);
376
+ if (err && err.status) console.error(` (HTTP ${err.status})`);
323
377
  process.exit(1);
324
378
  });
325
379
  }
@@ -165,6 +165,11 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
165
165
  * candidate that git ignores. Pass { honorGitignore: false } to skip the git
166
166
  * pass (used by tests that assert the raw walk).
167
167
  */
168
+ /** Current nesting depth as spaces. \r-repainted lines bypass withIndent's console wrappers. */
169
+ function nestPad() {
170
+ return " ".repeat(require("./nestedOutput").currentIndent());
171
+ }
172
+
168
173
  function buildManifest(rootDir, opts = {}) {
169
174
  const manifest = [];
170
175
  walkDir(rootDir, rootDir, manifest);
@@ -244,7 +249,7 @@ function gitignoreDrops(rootDir, relPaths) {
244
249
  const { ok, ignored } = git.checkIgnored(repoRoot, repoRelPaths);
245
250
  if (!ok) {
246
251
  console.log(color.yellow(
247
- " ⚠ WARNING: could not run `git check-ignore`, so .gitignore can't be " +
252
+ " ⚠ WARNING: could not run `git check-ignore`, so .gitignore can't be " +
248
253
  "honored — dropping config/env files from this upload to avoid leaking " +
249
254
  "secrets (source files are still uploaded)."
250
255
  ));
@@ -358,10 +363,10 @@ async function aggregateGuard(targetDir, includedPaths, sizes) {
358
363
  const msg = `Large upload — ${reasons.join("; ")}.`;
359
364
 
360
365
  if (prompt.isNonInteractive()) {
361
- console.log(color.yellow(` ⚠ WARNING: ${msg} Proceeding (non-interactive).`));
366
+ console.log(color.yellow(` ⚠ WARNING: ${msg} Proceeding (non-interactive).`));
362
367
  return;
363
368
  }
364
- const proceed = await prompt.confirm(` ⚠ ${msg} Continue?`, false);
369
+ const proceed = await prompt.confirm(` ⚠ ${msg} Continue?`, false);
365
370
  if (!proceed) {
366
371
  throw new Error("Upload cancelled by user.");
367
372
  }
@@ -404,14 +409,14 @@ async function uploadBatchWithRetry(gitId, sessionId, fileBatch) {
404
409
  */
405
410
  async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
406
411
  if (!includedPaths || includedPaths.length === 0) {
407
- console.log(` ${color.yellow("No files to sync.")} Check your include/exclude patterns.`);
412
+ console.log(` ${color.yellow("No files to sync.")} Check your include/exclude patterns.`);
408
413
  return;
409
414
  }
410
415
 
411
416
  await aggregateGuard(targetDir, includedPaths, sizes);
412
417
 
413
418
  const total = includedPaths.length;
414
- console.log(`\n Starting initial sync (${total} files)...`);
419
+ console.log(`\n Starting initial sync (${total} files)...`);
415
420
  const sessionId = await agentApi.startSync(gitId);
416
421
 
417
422
  const batches = chunk(includedPaths, BATCH_SIZE);
@@ -442,7 +447,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
442
447
  uploaded += fileBatch.length;
443
448
  // Denominator excludes files that disappeared, so progress can still reach 100%.
444
449
  const pct = Math.round((uploaded / Math.max(1, total - dropped)) * 100);
445
- process.stdout.write(`\r Uploading... ${pct}% (${uploaded}/${total - dropped})`);
450
+ process.stdout.write(`\r${nestPad()} Uploading... ${pct}% (${uploaded}/${total - dropped})`);
446
451
  }
447
452
  }
448
453
 
@@ -454,7 +459,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
454
459
 
455
460
  // Never drop files silently — surface the count (no names/content).
456
461
  if (!firstError && dropped > 0) {
457
- console.log(color.yellow(` ⚠ ${dropped} file(s) disappeared during upload and were skipped.`));
462
+ console.log(color.yellow(` ⚠ ${dropped} file(s) disappeared during upload and were skipped.`));
458
463
  }
459
464
 
460
465
  if (firstError) {
@@ -476,7 +481,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
476
481
  }
477
482
  watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
478
483
 
479
- console.log(` ${color.green("✓")} Initial sync complete.`);
484
+ console.log(` ${color.green("✓")} Initial sync complete.`);
480
485
  // Return the computed hashes so a caller that registers its watchState entry AFTER the upload (takeover)
481
486
  // can seed fileHashes directly — watchState.update above no-ops when no entry exists yet, so without this
482
487
  // the daemon would re-upload the whole tree on its first pass.
@@ -23,7 +23,7 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
23
23
  }
24
24
  if (!Array.isArray(suggestions) || suggestions.length === 0) return;
25
25
 
26
- console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
26
+ console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
27
27
  const accepted = [];
28
28
  for (const s of suggestions) {
29
29
  const count = s.module_count || s.moduleCount || 0;
@@ -33,24 +33,24 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
33
33
  ` ${color.dim(s.pattern.padEnd(28))} ${String(count).padStart(3)} module${count === 1 ? "" : "s"} ${color.dim(label)}`
34
34
  );
35
35
  if (samples.length > 0) {
36
- console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
36
+ console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
37
37
  }
38
38
  const yes = await prompt.confirm(` Apply ${color.cyan(s.pattern)}?`, true);
39
39
  if (yes) accepted.push(s.pattern);
40
40
  }
41
41
 
42
42
  if (accepted.length === 0) {
43
- console.log(` ${color.dim("No exclusions applied.")}`);
43
+ console.log(` ${color.dim("No exclusions applied.")}`);
44
44
  return;
45
45
  }
46
46
 
47
47
  try {
48
48
  await agentApi.updateModuleExcludePatterns(gitId, accepted);
49
49
  console.log(
50
- ` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
50
+ ` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
51
51
  );
52
52
  } catch (err) {
53
- console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
53
+ console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
54
54
  }
55
55
  }
56
56
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Run something and shift everything it prints one rung deeper.
3
+ *
4
+ * `niro start` is a shell around commands that also run on their own. `niro init` printing at
5
+ * the top level is right when you typed `niro init`; inside the wizard the same lines belong
6
+ * UNDER "4/5 Onboarding and building the index", and printing them flush with the step markers
7
+ * is what made step 4 look like a different program had taken over.
8
+ *
9
+ * Wrapping the call is the only approach that does not require every helper it reaches
10
+ * (folderSync, onboardTarget, moduleExcludeSuggestions, watchDaemon, service) to know how deep
11
+ * it happens to be running. They keep printing at their own level; this adds the offset.
12
+ *
13
+ * Blank lines are left blank rather than padded with spaces, so nothing trails whitespace.
14
+ * process.stdout.write is untouched: the upload progress bar repaints one line with \r, and
15
+ * prefixing every repaint would smear it across the terminal.
16
+ */
17
+ let current = 0;
18
+
19
+ /** How deep the output is nested right now. prompt.js prefixes its questions with this. */
20
+ function currentIndent() {
21
+ return current;
22
+ }
23
+
24
+ function withIndent(spaces, fn) {
25
+ const pad = " ".repeat(spaces);
26
+ const shift = (orig) => (...args) => {
27
+ const text = args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
28
+ // A line with nothing but spaces is emitted empty. Some callers print a whitespace-only
29
+ // string as a spacer, and padding it left a visible run of trailing whitespace.
30
+ orig(text.split("\n").map((l) => (l.trim() ? pad + l : "")).join("\n"));
31
+ };
32
+ const log = console.log;
33
+ const error = console.error;
34
+ const warn = console.warn;
35
+ current += spaces;
36
+ console.log = shift(log);
37
+ console.error = shift(error);
38
+ console.warn = shift(warn);
39
+ return Promise.resolve()
40
+ .then(fn)
41
+ .finally(() => {
42
+ current -= spaces;
43
+ console.log = log;
44
+ console.error = error;
45
+ console.warn = warn;
46
+ });
47
+ }
48
+
49
+ module.exports = { withIndent, currentIndent };
@@ -208,7 +208,7 @@ function ensureOnboardable(targetDir, opts = {}) {
208
208
 
209
209
  const reason = protectedRootReason(resolved);
210
210
  if (reason) {
211
- console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
211
+ console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
212
212
  console.error(" That would upload source from every project underneath it, pin this folder");
213
213
  console.error(" with a .niro-project file, and watch the whole tree for changes.");
214
214
  console.error(` Change into the project folder you meant, then run the command there.`);
@@ -223,17 +223,17 @@ function ensureOnboardable(targetDir, opts = {}) {
223
223
  // The protected-root rule above still applied, and still refuses.
224
224
  if (opts.alreadyRegistered) {
225
225
  console.log(color.dim(
226
- ` No build file in ${resolved}, but it is already registered here — continuing.`
226
+ ` No build file in ${resolved}, but it is already registered here — continuing.`
227
227
  ));
228
228
  return true;
229
229
  }
230
230
  if (opts.force) {
231
231
  console.log(color.yellow(
232
- `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
232
+ `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
233
  ));
234
234
  return true;
235
235
  }
236
- console.error(color.red(`\n ✗ No project found in ${resolved}.`));
236
+ console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
237
  console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
238
  console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
239
  console.error(" Pass --force if you really mean to upload this folder.");
@@ -241,7 +241,7 @@ function ensureOnboardable(targetDir, opts = {}) {
241
241
  }
242
242
 
243
243
  console.log(
244
- ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
244
+ ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
245
245
  `(${describeIndicators(indicators)}).`
246
246
  );
247
247
  return true;