@niroai/niro 0.8.0 → 0.10.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.
@@ -22,6 +22,7 @@ const git = require("../lib/git");
22
22
  const prompt = require("../lib/prompt");
23
23
  const watchState = require("../lib/watchState");
24
24
  const resolveCtxLib = require("../lib/resolveContext");
25
+ const spinner = require("../lib/spinner");
25
26
 
26
27
  const ok = (s) => color.green(s);
27
28
  const warn = (s) => color.yellow(s);
@@ -72,9 +73,9 @@ async function takeover(pathArg, opts = {}) {
72
73
  const list = ctx.projects.map((p) => ` ${p.project_id} (${p.alias})`).join("\n");
73
74
  throw new Error(`Multiple projects match this repo — pass --project:\n${list}`);
74
75
  }
75
- console.log("\n Multiple projects match this repo:");
76
+ console.log("\n Multiple projects match this repo:");
76
77
  ctx.projects.forEach((p, i) => console.log(` ${i + 1}. ${p.alias || "(no alias)"} ${color.dim(p.project_id)}`));
77
- 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");
78
79
  const idx = parseInt(answer, 10) - 1;
79
80
  if (isNaN(idx) || idx < 0 || idx >= ctx.projects.length) {
80
81
  throw new Error(`Invalid choice: ${answer}`);
@@ -120,11 +121,11 @@ async function takeover(pathArg, opts = {}) {
120
121
  && path.resolve(e.folderPath) !== here);
121
122
  if (holder) {
122
123
  const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
123
- console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
124
- 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:"));
125
126
  console.log(` ${holder.folderPath}${onBranch}`);
126
- console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
127
- 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."));
128
129
  if (prompt.isNonInteractive()) {
129
130
  // Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
130
131
  // would break any agent or script that runs this unattended. The way forward is two explicit
@@ -135,20 +136,18 @@ async function takeover(pathArg, opts = {}) {
135
136
  + holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
136
137
  + ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
137
138
  }
138
- 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))) {
139
140
  throw new Error("Cancelled — the temporary project stays where it is.");
140
141
  }
141
142
  }
142
143
 
143
- console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
144
- 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."));
145
146
 
146
147
  // 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
147
148
  // the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
148
149
  // cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
149
- const outcome = await api.post(
150
- `/api/projects/${encodeURIComponent(source.project_id)}/clone`,
151
- { local_git_ids: [source.git_id], branch });
150
+ const outcome = await requestClone(source.project_id, source.git_id, branch);
152
151
  const newProjectId = outcome.new_project_id || outcome.newProjectId;
153
152
  const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
154
153
  const newGitId = gitIdMap[source.git_id];
@@ -164,16 +163,16 @@ async function takeover(pathArg, opts = {}) {
164
163
  // route; its absence means a version mismatch the user must hear about NOW, not discover via wrong answers.
165
164
  const serverRoutes = outcome.routing_supported === true || outcome.routingSupported === true;
166
165
  if (!serverRoutes) {
167
- 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?)."));
168
167
  console.log(warn(" This CLI no longer re-pins .niro-project, so against this backend Niro's answers"));
169
168
  console.log(warn(" will KEEP coming from the original project — NOT this temporary project."));
170
169
  console.log(warn(" Fix: upgrade the backend (ai-assistant + ai-orchestrator), or use the previous CLI."));
171
170
  }
172
171
  if (wasExtended) {
173
- 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)} ` +
174
173
  color.dim("(existing copy extended — your other repos are untouched)"));
175
174
  } else {
176
- console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
175
+ console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
177
176
  color.dim(`(${outcome.nodes_copied ?? "?"} nodes copied, no re-index needed)`));
178
177
  }
179
178
 
@@ -191,7 +190,7 @@ async function takeover(pathArg, opts = {}) {
191
190
  folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
192
191
  const sizes = {};
193
192
  for (const m of manifest) sizes[m.path] = m.size_bytes;
194
- console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
193
+ console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
195
194
  `(branch ${color.cyan(branch || "detached HEAD")})...`);
196
195
  // Capture the uploaded hashes: takeover registers its watchState entry AFTER this upload (below), so
197
196
  // doInitialSync's own watchState.update no-ops (no entry yet). Seeding these into the final set means
@@ -204,9 +203,9 @@ async function takeover(pathArg, opts = {}) {
204
203
  await api.post(`/api/graph/${encodeURIComponent(newProjectId)}/build`, {}, {
205
204
  query: { force_full_rebuild: "false" },
206
205
  });
207
- console.log(` ${step("●")} Incremental index of your working tree triggered.`);
206
+ console.log(` ${step("●")} Incremental index of your working tree triggered.`);
208
207
  } catch (buildErr) {
209
- 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}). ` +
210
209
  "Run `niro build` to index your uploaded tree."));
211
210
  }
212
211
 
@@ -266,11 +265,94 @@ async function takeover(pathArg, opts = {}) {
266
265
  }
267
266
  }
268
267
 
269
- 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`);
270
269
  console.log(color.dim(" (routing is automatic per branch — your .niro-project file was NOT changed)."));
271
- console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
272
- console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
273
- 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."));
274
273
  }
275
274
 
276
- module.exports = { takeover };
275
+
276
+ /**
277
+ * Ask the server for a working copy, and wait for it without holding the request open.
278
+ *
279
+ * WHY. The copy takes 44 to 60 seconds. It used to run inside the POST, and the gateway in front of
280
+ * the server gives up at 60, so three requests at once on 2026-08-26 all came back as HTML 504 pages.
281
+ * A 504 also says nothing about whether the work finished: the real error underneath, a duplicate key
282
+ * from a migration that had not been run, went unread for an afternoon because of it.
283
+ *
284
+ * WORKS AGAINST BOTH SERVERS, and that is why it branches on the RESPONSE rather than on a version.
285
+ * A server that understands `async` returns 202 and a job id to poll. An older one ignores the unknown
286
+ * field and returns the outcome inline exactly as before, which is the shape this already handled. So
287
+ * there is no capability check to keep in step, and no flag day.
288
+ */
289
+ async function requestClone(projectId, gitId, branch, opts = {}) {
290
+ const started = await api.post(
291
+ `/api/projects/${encodeURIComponent(projectId)}/clone`,
292
+ { local_git_ids: [gitId], branch, async: true });
293
+
294
+ const jobId = started && (started.job_id || started.jobId);
295
+ if (!jobId) {
296
+ return started; // older server: it did the work inline and this IS the outcome
297
+ }
298
+
299
+ // Poll until it finishes. The interval is deliberately not tight: nothing is waiting on the answer
300
+ // faster than a person reads, and a shorter one only adds load to the server doing the copy.
301
+ // A seam, not a knob: tests pass a small value so they do not sit through real polls.
302
+ const POLL_MS = opts.pollMs || 2000;
303
+ const GIVE_UP_MS = opts.giveUpMs || 15 * 60 * 1000;
304
+ const startedAt = Date.now();
305
+ const deadline = startedAt + GIVE_UP_MS;
306
+ const sp = spinner.start("Building your temporary project...");
307
+ try {
308
+ for (;;) {
309
+ await sleep(POLL_MS);
310
+ let job;
311
+ try {
312
+ job = await api.get(`/api/projects/clone/job/${encodeURIComponent(jobId)}`);
313
+ } catch (e) {
314
+ // A blip while polling is not a failed clone. The copy is running on the server regardless of
315
+ // whether this one request reached it, so keep waiting rather than reporting a failure that
316
+ // has not happened. A 404 is different: the job is gone, and waiting cannot bring it back.
317
+ // Waiting cannot fix an expired token or a revoked account, and 15 minutes of silent retrying
318
+ // before finally reporting it is worse than saying so at once.
319
+ if (e && (e.status === 401 || e.status === 403)) {
320
+ throw e;
321
+ }
322
+ if (e && e.status === 404) {
323
+ throw new Error(
324
+ `The server has no record of clone job ${jobId}. It may have expired, or the server was `
325
+ + "restarted mid-copy. Run `niro new-temp-project` again.");
326
+ }
327
+ if (Date.now() > deadline) throw e;
328
+ continue;
329
+ }
330
+ const state = job && job.state;
331
+ if (state === "SUCCEEDED") {
332
+ sp.stop();
333
+ return job.outcome;
334
+ }
335
+ if (state === "FAILED") {
336
+ sp.stop();
337
+ throw new Error(job.error || "The server could not create the temporary project.");
338
+ }
339
+ if (Date.now() > deadline) {
340
+ sp.stop();
341
+ throw new Error(
342
+ `Still building after ${Math.round(GIVE_UP_MS / 60000)} minutes. The server may still finish; `
343
+ + `check with \`niro status\` before running this again.`);
344
+ }
345
+ // Deliberately NOT re-labelled with the elapsed time. Without a TTY the spinner prints a fresh
346
+ // line every time the label CHANGES, so putting the seconds in it produced one line per poll:
347
+ // measured at 10 lines in 11 seconds. The spinner already shows elapsed time where it can.
348
+ }
349
+ } finally {
350
+ sp.stop();
351
+ }
352
+ }
353
+
354
+ function sleep(ms) {
355
+ return new Promise((resolve) => setTimeout(resolve, ms));
356
+ }
357
+
358
+ module.exports = { takeover, requestClone};
@@ -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.