@niroai/niro 0.10.1 → 0.11.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.
@@ -17,6 +17,7 @@ const path = require("path");
17
17
  const api = require("../lib/api");
18
18
  const watchState = require("../lib/watchState");
19
19
  const tempProject = require("../lib/tempProject");
20
+ const niroProjectFile = require("../lib/niroProjectFile");
20
21
  const credentials = require("../lib/credentials");
21
22
  const color = require("../lib/color");
22
23
  const { resolveContext } = require("../lib/resolveContext");
@@ -159,14 +160,19 @@ async function info(pathArg, opts = {}) {
159
160
  const indexed = projects.length > 0;
160
161
  const ambiguous = projects.length > 1;
161
162
 
162
- return emit(opts, {
163
+ const payload = {
163
164
  git_root: ctx.gitRoot,
164
165
  repo_url: ctx.repoUrl,
165
166
  branch: ctx.branch, // null === detached HEAD
166
167
  indexed,
167
168
  ambiguous,
168
169
  projects,
169
- });
170
+ };
171
+ const emitted = emit(opts, payload);
172
+ // Said last, after the folder's own summary, so it reads as a footnote rather than an error. Never in
173
+ // --json mode: that output has an exact shape and callers parse it.
174
+ if (!opts.json) await warnAboutStalePin(payload);
175
+ return emitted;
170
176
  }
171
177
 
172
178
  /** Emit either JSON (exact shape) or a plain-English human summary. */
@@ -239,4 +245,47 @@ function printHuman(p) {
239
245
  }
240
246
  }
241
247
 
248
+ /**
249
+ * Tell the developer, HERE, when this folder's `.niro-project` names a project the server does not
250
+ * recognise.
251
+ *
252
+ * The background sweep already finds these, but a line in a daemon log is not a notification — nobody
253
+ * reads those, and the pin is consulted BEFORE the git remote, so a stale one quietly answers every
254
+ * question about the folder. `niro info` is where somebody looks when a folder behaves oddly, so this
255
+ * is where it belongs.
256
+ *
257
+ * Read-only, and silent unless something is actually wrong. A pin that resolves, a pin belonging to a
258
+ * PARENT folder, and a server that cannot be reached all print nothing: this is a hint, and a hint that
259
+ * fires on a bad connection is worse than no hint.
260
+ */
261
+ async function warnAboutStalePin(p) {
262
+ if (!p || !p.git_root) return;
263
+ let pin;
264
+ try { pin = niroProjectFile.read(p.git_root); } catch { return; }
265
+ if (!pin || !pin.id || !pin.source) return;
266
+ if (path.dirname(path.resolve(pin.source)) !== path.resolve(p.git_root)) return; // a parent's pin
267
+ // The ACCOUNT LIST first, and this is not optional. A pin may hold an ALIAS, and an alias never
268
+ // resolves on the single-project route — asking about a live project by its alias answers
269
+ // "Project not found". Warning on that alone contradicts the sweep, which uses the list and correctly
270
+ // says the pin is fine. This repo's own pin holds an alias, so the first cut of this warning fired on
271
+ // a perfectly healthy folder and pointed at a command that would then report nothing wrong.
272
+ try {
273
+ const listed = await api.get("/api/projects");
274
+ const rows = Array.isArray(listed) ? listed : (listed && listed.projects) || [];
275
+ if (rows.some((r) => r && (r.project_id === pin.id || r.alias === pin.id))) return;
276
+ } catch {
277
+ return; // could not ask: stay quiet
278
+ }
279
+ try {
280
+ await api.get(`/api/projects/${encodeURIComponent(pin.id)}`);
281
+ return; // resolves by id — nothing to say
282
+ } catch (err) {
283
+ if (!tempProject.isProjectNotFound(err)) return; // could not ask, or not allowed: stay quiet
284
+ }
285
+ console.log("");
286
+ console.log(color.yellow(`This folder's .niro-project names \`${pin.id}\`, which this backend does not recognise.`));
287
+ console.log(color.dim(" It is read before your git remote, so it may be answering for this folder."));
288
+ console.log(color.dim(" The project may simply have been renamed. `niro doctor sync-registry` shows it."));
289
+ }
290
+
242
291
  module.exports = { info };
@@ -23,6 +23,7 @@ const niroProjectFile = require("../lib/niroProjectFile");
23
23
  const prompt = require("../lib/prompt");
24
24
  const watchState = require("../lib/watchState");
25
25
  const tempProject = require("../lib/tempProject");
26
+ const takeoverRestore = require("../lib/takeoverRestore");
26
27
 
27
28
  const ok = (s) => color.green(s);
28
29
  const warn = (s) => color.yellow(s);
@@ -171,66 +172,11 @@ async function release(pathArg, opts = {}) {
171
172
  await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
172
173
  }
173
174
 
174
- // Restore each folder's local registration. ADR-018: the .niro-project file is left ALONE unless it
175
- // still carries the OLD-STYLE swapped pin (pre-ADR-018 takeover) — then it points at a now-deleted
176
- // project and must be healed: previousPin verbatim when recorded; explicit "no pin existed" → delete
177
- // the file; otherwise the source project id. New-style folders (pin untouched) skip all of this.
178
- const repinned = [];
179
- const healed = [];
180
- const failed = [];
181
- for (const e of folders) {
182
- try {
183
- const backup = e.takeover || {};
184
- const sourceProjectId = backup.sourceProjectId || cloneOf;
185
- const entryDir = path.resolve(e.folderPath);
186
-
187
- if (fs.existsSync(entryDir)) {
188
- const pin = niroProjectFile.read(entryDir);
189
- const pinIsSwapped = pin && pin.id === tempProjectId
190
- && path.dirname(pin.source) === entryDir; // only heal THIS folder's own pin, never a parent's
191
- if (pinIsSwapped) {
192
- const recordedNoPin = ("previousPin" in backup) && backup.previousPin === null;
193
- if (backup.previousPin) {
194
- niroProjectFile.write(entryDir, backup.previousPin, { overwrite: true });
195
- healed.push({ folder: e.folderPath, to: backup.previousPin });
196
- } else if (recordedNoPin) {
197
- niroProjectFile.remove(entryDir);
198
- healed.push({ folder: e.folderPath, to: "(no pin — removed)" });
199
- } else {
200
- niroProjectFile.write(entryDir, sourceProjectId, { overwrite: true });
201
- healed.push({ folder: e.folderPath, to: sourceProjectId });
202
- }
203
- }
204
- }
205
-
206
- // Watch/daemon registration goes back to the source project regardless of pin style.
207
- watchState.set(e.folderPath, {
208
- ...e,
209
- gitId: backup.previousGitId || null,
210
- projectId: sourceProjectId,
211
- // The baseline describes what the TEMPORARY project holds, so it MUST NOT survive the move
212
- // back to the source repo. Carrying it across told the daemon the source already had every
213
- // file at its sandbox-era fingerprint — which it did not, because those edits only ever went
214
- // to the temporary copy. The next pass then compared disk against that baseline, found no
215
- // difference, and uploaded nothing. Every edit made during the temporary project was silently
216
- // lost to the source project, with no error and nothing to prompt a re-sync.
217
- //
218
- // Clearing it makes the next pass treat every file as new and upload it. That is a full
219
- // re-upload once, which is the cost of being sure: the client cannot know which files the
220
- // source repo is missing, so the only safe assumption is all of them.
221
- //
222
- // The opposite direction already gets this right — takeover.js seeds fileHashes with what it
223
- // actually uploaded to the new gitId. A baseline belongs to ONE gitId; when that changes, so
224
- // must it.
225
- fileHashes: {},
226
- lastSyncedAt: null,
227
- takeover: undefined,
228
- }, e.apiUrl || baseUrl);
229
- repinned.push(e.folderPath);
230
- } catch (err) {
231
- failed.push({ folder: e.folderPath, error: err.message });
232
- }
233
- }
175
+ // Restore each folder's local registration. Shared with `niro cleanup-temp-projects`, which reaches
176
+ // the same point by a different route (it finds copies already gone from the server instead of being
177
+ // pointed at one), so the loop itself lives in lib/takeoverRestore.
178
+ const { repinned, healed, failed, skipped } = takeoverRestore.restoreFolders(folders,
179
+ { tempProjectId, cloneOf, baseUrl });
234
180
 
235
181
  if (alreadyGone) {
236
182
  console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
@@ -242,6 +188,13 @@ async function release(pathArg, opts = {}) {
242
188
  } else {
243
189
  console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
244
190
  }
191
+ if (skipped.length) {
192
+ // Somebody re-pointed the folder while this ran. Leaving it alone is the safe half; saying so is
193
+ // the other half, because a silently skipped folder looks exactly like a repaired one.
194
+ console.log(warn(`\n ⚠ ${skipped.length} folder(s) were re-registered while this ran and were ` +
195
+ "left alone:"));
196
+ for (const s of skipped) console.log(color.dim(` ${s.folder}`));
197
+ }
245
198
  if (healed.length) {
246
199
  console.log(color.dim(" Healed old-style pins:"));
247
200
  for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
@@ -147,7 +147,38 @@ async function takeover(pathArg, opts = {}) {
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
149
149
  // cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
150
- const outcome = await requestClone(source.project_id, source.git_id, branch);
150
+ let outcome;
151
+ try {
152
+ outcome = await requestClone(source.project_id, source.git_id, branch);
153
+ } catch (err) {
154
+ // The server refuses a copy when you already hold the limit, and its message is good: it says how
155
+ // many, what the limit is, and which BRANCHES the copies are for. What it cannot say is where those
156
+ // branches are checked out, because that is a fact about this machine — and its own advice is "run
157
+ // `niro discard-temp-project` in the folder for one you have finished with". So the folder is the
158
+ // one thing the developer still has to work out, and it is the one thing this side knows.
159
+ //
160
+ // Matched on the refusal, not attached to every failure. The closing line of this hint tells you to
161
+ // discard a copy, and that is only sound advice when capacity is what stopped you: on a dropped
162
+ // connection it would send somebody deleting a working copy for nothing. The existing one-slot
163
+ // guard tests caught exactly that by failing when the hint arrived on their sentinel error.
164
+ //
165
+ // The fragment is short and drawn from the server's own text (ProjectCloneService: "You already
166
+ // have N working copies of this project, which is the limit (N)"), and it survives the generic
167
+ // handler's "An unexpected error occurred: " prefix. If the server ever rewords it, the hint stops
168
+ // appearing — a false negative, which costs a developer one lookup, rather than a false positive,
169
+ // which costs them a copy.
170
+ const isCapacityRefusal = /working cop(y|ies) of this project/i.test(err.message || "");
171
+ const held = isCapacityRefusal ? copiesOfSourceProject(source.project_id) : [];
172
+ if (held.length) {
173
+ const rows = held.map((h) => ` ${(h.branch || "(unknown branch)").padEnd(28)}${h.folderPath}`);
174
+ // No closing instruction: the server's message already ends with "Run `niro
175
+ // discard-temp-project` in the folder for one you have finished with". Repeating it made the
176
+ // developer read the same sentence twice. This side adds the one thing the server cannot know —
177
+ // where each of those branches is checked out.
178
+ err.message = `${err.message}\n\n Those copies are checked out here:\n${rows.join("\n")}`;
179
+ }
180
+ throw err;
181
+ }
151
182
  const newProjectId = outcome.new_project_id || outcome.newProjectId;
152
183
  const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
153
184
  const newGitId = gitIdMap[source.git_id];
@@ -286,6 +317,25 @@ async function takeover(pathArg, opts = {}) {
286
317
  * field and returns the outcome inline exactly as before, which is the shape this already handled. So
287
318
  * there is no capability check to keep in step, and no flag day.
288
319
  */
320
+ /**
321
+ * The folders on THIS machine that hold a working copy of `sourceProjectId`, with the branch each was
322
+ * taken over on.
323
+ *
324
+ * Read-only and local: no server call, no parsing of anybody's message. Used to turn the branches the
325
+ * server names in a capacity refusal into folders the developer can actually go and stand in.
326
+ *
327
+ * Not the same question as the holder guard above, which asks whether a DIFFERENT folder holds the slot
328
+ * for this particular repo and therefore matches on sourceGitId and excludes the current folder. This
329
+ * one wants all of them, including the folder you are standing in — that copy is as discardable as any
330
+ * other, and leaving it out of the list would be confusing when it is the one you had forgotten about.
331
+ */
332
+ function copiesOfSourceProject(sourceProjectId) {
333
+ return watchState.allLocal(api.currentBaseUrl())
334
+ .filter((e) => e.takeover && e.takeover.sourceProjectId === sourceProjectId)
335
+ .map((e) => ({ folderPath: e.folderPath, branch: e.branch || null }))
336
+ .sort((a, b) => (a.folderPath < b.folderPath ? -1 : 1));
337
+ }
338
+
289
339
  async function requestClone(projectId, gitId, branch, opts = {}) {
290
340
  const started = await api.post(
291
341
  `/api/projects/${encodeURIComponent(projectId)}/clone`,
@@ -355,4 +405,4 @@ function sleep(ms) {
355
405
  return new Promise((resolve) => setTimeout(resolve, ms));
356
406
  }
357
407
 
358
- module.exports = { takeover, requestClone};
408
+ module.exports = { takeover, requestClone, copiesOfSourceProject };
@@ -21,6 +21,7 @@ const color = require("../lib/color");
21
21
  const folderSync = require("../lib/folderSync");
22
22
  const git = require("../lib/git");
23
23
  const tempProject = require("../lib/tempProject");
24
+ const periodicClaim = require("../lib/periodicClaim");
24
25
  const {
25
26
  SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
26
27
  IGNORED_DIRS, isConfigName, chunk,
@@ -177,6 +178,8 @@ async function watch(pathArg, opts = {}) {
177
178
  } catch (err) {
178
179
  console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
179
180
  }
181
+ startPeriodicSweep();
182
+
180
183
  // Keep process alive
181
184
  await new Promise(() => {});
182
185
  return;
@@ -186,6 +189,56 @@ async function watch(pathArg, opts = {}) {
186
189
  await startWatch(targetDir, opts);
187
190
  }
188
191
 
192
+ /**
193
+ * How often the daemon sweeps for temporary projects that have gone from the server. Six hours, not
194
+ * six seconds: its job is repairing pointers that go stale over days, not reacting to keystrokes.
195
+ * Every run is a handful of small GETs.
196
+ */
197
+ const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
198
+ /**
199
+ * A short delay before the first sweep rather than one at startup. The daemon has a burst of work when
200
+ * it starts (a delta sync per folder), and the sweep is in no hurry — it can wait for that to settle.
201
+ */
202
+ const SWEEP_STARTUP_DELAY_MS = 60 * 1000;
203
+
204
+ /**
205
+ * Periodically run `niro cleanup-temp-projects`. Machine-wide, so it belongs HERE, in the no-path
206
+ * branch, and not in startWatch — startWatch runs once per folder, and this must run once per machine.
207
+ *
208
+ * It runs the sweep in its DEFAULT mode, which only repairs local registrations whose temporary project
209
+ * the server no longer has. It never deletes anything server-side. That is what makes it safe to run
210
+ * unattended: every project it acts on is already gone, so the worst case is a pointer left as it was.
211
+ *
212
+ * Every run is gated by periodicClaim, which persists the last run time. That stops two things that
213
+ * would otherwise both happen: PM2 restarting the daemon (on a crash, a reboot, or
214
+ * `niro service restart`) turning a six-hourly job into a job that runs on every restart, and a
215
+ * `niro watch` left running in a terminal sweeping at the same moment as the PM2 daemon.
216
+ *
217
+ * Never throws: a sweep failure must not touch the daemon's actual job, which is syncing files.
218
+ * Exported so tests can drive it without starting a daemon.
219
+ */
220
+ function startPeriodicSweep({ intervalMs = SWEEP_INTERVAL_MS, startupDelayMs = SWEEP_STARTUP_DELAY_MS,
221
+ claimName = "tempProjectSweep" } = {}) {
222
+ const run = async () => {
223
+ if (!periodicClaim.claim(claimName, intervalMs)) return;
224
+ try {
225
+ const cleanupCmd = require("./cleanup");
226
+ await cleanupCmd.cleanup({
227
+ yes: true, // nobody is at the terminal to answer a prompt
228
+ quiet: true, // only speak when it actually changed something
229
+ });
230
+ } catch (err) {
231
+ console.error(color.yellow(` Temporary-project sweep skipped: ${err && err.message}`));
232
+ }
233
+ };
234
+ const first = setTimeout(run, startupDelayMs);
235
+ const timer = setInterval(run, intervalMs);
236
+ // unref so neither timer holds the process open on its own; the daemon is kept alive by its watchers.
237
+ if (first.unref) first.unref();
238
+ if (timer.unref) timer.unref();
239
+ return { stop: () => { clearTimeout(first); clearInterval(timer); } };
240
+ }
241
+
189
242
  /**
190
243
  * One watch entry failed to start (its startup delta sync threw). Contain the failure to that
191
244
  * entry — and when the cause is "the repo no longer exists on this backend" (a poison entry left
@@ -332,40 +385,55 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
332
385
  try {
333
386
  const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
334
387
 
335
- // Am I still the folder this temporary project reflects? Every folder taken over for one project
336
- // shares the SAME repo id, so the server cannot tell us apart. If I keep uploading after the slot has
337
- // moved, my branch blends into the copy that is reflecting someone else, and every answer drawn from
338
- // it is a mixture — with nothing on screen to say so. Verified live with two worktrees.
388
+ // Is this folder still in step with the temporary project it uploads to? If my branch and the copy's
389
+ // branch label disagree, uploading writes this branch's files into a copy labelled with another one,
390
+ // and every answer later drawn from that copy is wrong with nothing on screen to say so.
391
+ //
392
+ // Pausing costs the developer nothing while they are out of step, which is the part that is easy to
393
+ // get wrong. ai-assistant will not route a session to a copy whose label is not the caller's branch
394
+ // (`if (branch.equals(repo.getBranch())) return true;`, TempProjectRoutingService.java:358, and the
395
+ // copies are pre-filtered to that branch key at :325). So a drifted copy is already answering nobody.
396
+ // Uploading into it does not restore anything, it only fills it with the wrong branch's files, and
397
+ // nothing in the upload path ever relabels it (zero setBranch calls under service/agent). The fix is
398
+ // for the developer to run `niro new-temp-project`, which is what the message says.
339
399
  //
340
400
  // Nothing is changed or discarded here. The local record is left exactly as it is, so running
341
401
  // `niro new-temp-project` here picks straight back up. This only declines to WRITE.
342
402
  if (state.takeover && state.takeover.sourceProjectId) {
343
403
  const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
344
404
  const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
345
- // A null heldBranch means the server could not be asked. That must NEVER be read as "you lost the
346
- // slot", or one network hiccup would stop a perfectly healthy folder syncing.
347
- if (heldBranch && myBranch && heldBranch !== myBranch) {
405
+
406
+ if (!heldBranch || !myBranch) {
407
+ // COULD NOT ASK. This used to fall through to the release below, which both printed "resuming
408
+ // uploads" and let a folder that had genuinely lost its slot start writing again on any network
409
+ // hiccup. It is also where a customer's 306 pauses against 286 resumes came from: nearly equal
410
+ // counts because it was flapping, one warning printed on every re-entry.
411
+ //
412
+ // Not knowing is not news. Whatever was decided last time stands until the server answers again.
413
+ if (notHolding.has(inFlightKey)) return { notHolder: true };
414
+ } else if (heldBranch !== myBranch) {
348
415
  if (!notHolding.has(inFlightKey)) {
349
416
  notHolding.add(inFlightKey);
350
- // TWO very different causes, and they need different advice. Another folder bound to the same
351
- // copy is the stealing case. NO other folder means you simply switched branch in place, and
352
- // saying "another folder holds it" there sends you hunting for a folder that does not exist —
353
- // which is exactly what it did on a real repo the first time this ran.
354
- const otherFolder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
355
- e.projectId === state.projectId && path.resolve(e.folderPath) !== inFlightKey);
356
- console.warn(color.yellow(otherFolder
357
- ? ` ${targetDir} is on \`${myBranch}\`, but this project's temporary project is on ` +
358
- `\`${heldBranch}\`, held by ${otherFolder.folderPath}. Pausing uploads from here so the ` +
359
- `two branches do not mix. Run \`niro new-temp-project\` here to move it, or ` +
360
- `\`niro discard-temp-project\` in that folder.`
361
- : ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still on ` +
362
- `\`${heldBranch}\` — you switched branch in this folder. Pausing uploads so the two ` +
363
- `branches do not mix. Run \`niro new-temp-project\` here to move the temporary project ` +
364
- `onto \`${myBranch}\`.`));
417
+ // TWO very different causes needing different advice, and this is where the old message was
418
+ // wrong. It looked for another folder on the same PROJECT id, but a project can hold several
419
+ // repos and each folder can have its own, so it named folders that were writing nowhere near
420
+ // this one and told the developer to go and discard them. `gitId` is the repo this pass
421
+ // actually uploads to, so another folder carrying the same gitId is, by definition, writing to
422
+ // the same place. That is the only thing that makes it somebody else's doing rather than yours.
423
+ const rival = watchState.allLocal(api.currentBaseUrl()).find((e) =>
424
+ e.gitId === gitId && path.resolve(e.folderPath) !== inFlightKey);
425
+ console.warn(color.yellow(rival
426
+ ? ` ${targetDir} and ${rival.folderPath} are both uploading to the same temporary project. ` +
427
+ `This folder is on \`${myBranch}\`, the copy is on \`${heldBranch}\`. Pausing uploads from ` +
428
+ `here so the two branches do not mix. Run \`niro new-temp-project\` here to give this folder ` +
429
+ `a copy of its own, or \`niro discard-temp-project\` in the other folder.`
430
+ : ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still labelled ` +
431
+ `\`${heldBranch}\`. You switched branch in this folder. Your sessions will not use this copy ` +
432
+ `until the two agree, so uploads are paused rather than filling it with the wrong branch. ` +
433
+ `Run \`niro new-temp-project\` here to move it onto \`${myBranch}\`.`));
365
434
  }
366
435
  return { notHolder: true };
367
- }
368
- if (notHolding.delete(inFlightKey)) {
436
+ } else if (notHolding.delete(inFlightKey)) {
369
437
  console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
370
438
  }
371
439
  }
@@ -877,7 +945,7 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
877
945
 
878
946
  // handleFileEvent + deltaSyncOnResume are exported for unit testing the
879
947
  // gitignore/fail-closed guards without a live server or fs.watch loop.
880
- module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
948
+ module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
881
949
  queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
882
950
  unreadableRoots,
883
951
  nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
package/src/index.js CHANGED
@@ -249,7 +249,28 @@ function main() {
249
249
  await cmd.release(pathArg, { ...opts });
250
250
  }));
251
251
 
252
- program
252
+ // `niro doctor` — checks that compare this machine against the server and repair what is safe to
253
+ // repair. A group from the start, because there is more than one of these coming; `sync-registry` is
254
+ // simply the first. Published commands (`status`, `info`, `service status`) deliberately stay where
255
+ // they are: they shipped in 0.10.1 and that surface is stable.
256
+ const doctor = program
257
+ .command("doctor")
258
+ .description("Check this machine against the server and repair what is safe to repair.");
259
+
260
+ const syncRegistry = (opts) => run(async () => {
261
+ const cmd = require("./commands/cleanup");
262
+ await cmd.cleanup({ ...opts });
263
+ });
264
+
265
+ doctor
266
+ .command("sync-registry", { isDefault: true })
267
+ .description("Bring this machine's record of your projects back in line with the server: folders pointing at projects that are gone, and .niro-project files naming something unknown.")
268
+ .option("--dry-run", "list what would change and change nothing")
269
+ .option("--forget <path>", "drop one folder's registration (for a folder you have deleted); the folder itself is never touched")
270
+ .option("-y, --yes", "skip the confirmation prompt")
271
+ .option("--json", "emit machine-readable JSON")
272
+ .action(syncRegistry);
273
+ program
253
274
  .command("status [project_id]")
254
275
  .description("Show the current build status for a project.")
255
276
  .option("--json", "emit machine-readable JSON")
@@ -372,8 +393,7 @@ function run(fn) {
372
393
  console.error(err.message);
373
394
  process.exit(1);
374
395
  }
375
- console.error(err && err.stack ? err.stack.split("\n")[0] : String(err));
376
- if (err && err.status) console.error(` (HTTP ${err.status})`);
396
+ require("./lib/printError").printError(err);
377
397
  process.exit(1);
378
398
  });
379
399
  }
@@ -69,6 +69,12 @@ const STRUCTURAL_FILENAMES = new Set([
69
69
  "project.json", "directory.build.props", "next.config.js", "next.config.mjs",
70
70
  "next.config.ts", "vite.config.ts", "vite.config.js", "setupproxy.js",
71
71
  "setupproxy.ts",
72
+ // Java servlet deployment descriptor. A pre-Spring-Boot web app declares its whole
73
+ // HTTP surface here rather than in annotations, so without this the server sees a
74
+ // servlet application with zero endpoints. Measured on one customer repo: 11 real
75
+ // endpoints, 0 detected. Read server-side by WebXmlServletRouteReader.
76
+ // MIRROR: ai-orchestrator SourceFileFilterService.STRUCTURAL_FILENAMES.
77
+ "web.xml",
72
78
  ]);
73
79
  const STRUCTURAL_EXTENSIONS = new Set([
74
80
  ".csproj", ".fsproj", ".sln", ".gemspec", ".rockspec", ".nimble", ".opam",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * "Has this periodic job already run recently, and may I be the one to run it now?"
3
+ *
4
+ * Two problems, one answer.
5
+ *
6
+ * PM2 restarts the watch daemon on a crash, on a reboot, and on `niro service restart`. A job wired to
7
+ * a plain setInterval that also runs at startup therefore runs on every restart, and a crash loop turns
8
+ * a daily job into a hot loop. So the last run time has to OUTLIVE the process, which means a file.
9
+ *
10
+ * And two daemons can be up at once — the PM2 one plus a `niro watch` somebody left running in a
11
+ * terminal. Both would fire the same job at the same moment. For a job that only reads that is waste;
12
+ * for one that deletes on the server it is two processes racing over the same decision.
13
+ *
14
+ * A timestamp file answers both: whoever writes it first owns the interval, and everybody else sees a
15
+ * recent stamp and stands down. The write is atomic (tmp + rename, the same technique watchState uses)
16
+ * so a reader never sees a half-written file.
17
+ *
18
+ * NOT a mutex. Two processes calling claim() in the same millisecond can both win: each reads the old
19
+ * stamp before either writes. Closing that needs an O_EXCL lock file plus stale-lock recovery, which
20
+ * costs more than it saves here — the jobs this guards are idempotent (a second sweep finds the work
21
+ * already done) and the interval is hours, so the collision window is milliseconds wide once a day.
22
+ * The comment is here so nobody later mistakes this for a lock and puts something non-idempotent
23
+ * behind it.
24
+ */
25
+ const fs = require("fs");
26
+ const os = require("os");
27
+ const path = require("path");
28
+
29
+ const DIR = path.join(os.homedir(), ".niro");
30
+ const FILE = path.join(DIR, "periodic.json");
31
+
32
+ function load() {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(FILE, "utf8"));
35
+ // An ARRAY passes `typeof === "object"`, and JSON.stringify then drops the named property we set on
36
+ // it — so the stamp never persisted and the job claimed on EVERY tick, which is precisely the crash
37
+ // loop this module exists to prevent. Anything that is not a plain object starts again from empty.
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
39
+ return parsed;
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ function save(state) {
46
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
47
+ const tmp = `${FILE}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
49
+ fs.renameSync(tmp, FILE);
50
+ }
51
+
52
+ /** When `name` last ran, in epoch millis, or null if it never has (or the stamp is unreadable). */
53
+ /** Said once per process, not once per tick — a six-hourly job would otherwise fill the log. */
54
+ let warnedAboutWrite = false;
55
+
56
+ function lastRun(name) {
57
+ const at = load()[name];
58
+ const n = Number(at);
59
+ return Number.isFinite(n) && n > 0 ? n : null;
60
+ }
61
+
62
+ /**
63
+ * Try to claim the current interval for `name`.
64
+ *
65
+ * Returns true and stamps the file when `intervalMs` has passed since the last run (or it has never
66
+ * run). Returns false and writes nothing otherwise.
67
+ *
68
+ * The stamp is written BEFORE the job runs, not after. A job that dies half way through must not be
69
+ * retried immediately on the next tick — that is the crash loop this exists to prevent — and these
70
+ * jobs are safe to skip for one interval. The cost of the choice is that a run interrupted at 1% waits
71
+ * a full interval to try again, which is the right trade for something that runs every few hours.
72
+ *
73
+ * A stamp in the FUTURE (a clock that jumped back, or a file copied between machines) would otherwise
74
+ * block the job until real time caught up, potentially for years. Treated as "never ran" instead.
75
+ */
76
+ function claim(name, intervalMs, now = Date.now()) {
77
+ const last = lastRun(name);
78
+ if (last !== null && last <= now && now - last < intervalMs) return false;
79
+ const state = load();
80
+ state[name] = now;
81
+ try {
82
+ save(state);
83
+ } catch (err) {
84
+ // Cannot persist the stamp (read-only home, a file left by a `sudo` run, a full disk). Refuse the
85
+ // claim rather than run: an unstampable job would run on EVERY tick, which is worse than not
86
+ // running at all.
87
+ //
88
+ // But SAY SO, once. Returning a quiet false disables the job for good, and the developer has no
89
+ // way to know: their folders simply stop being tidied and nothing ever mentions it.
90
+ if (!warnedAboutWrite) {
91
+ warnedAboutWrite = true;
92
+ console.warn(` Could not write ${FILE} (${err.message}). Periodic housekeeping is paused; ` +
93
+ "fix the permissions on ~/.niro to re-enable it.");
94
+ }
95
+ return false;
96
+ }
97
+ return true;
98
+ }
99
+
100
+ module.exports = { claim, lastRun, FILE };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How a failed command is written to the terminal.
3
+ *
4
+ * Extracted from index.js's `run` for one reason: it was dropping text the user needed, and there was
5
+ * no way to test that. index.js calls main() at load, so nothing could require it.
6
+ *
7
+ * The bug it exists to stop: the printer took `err.stack.split("\n")[0]`, which is the error's class
8
+ * name plus the FIRST LINE of its message. Any message built over several lines therefore printed its
9
+ * opening line and silently swallowed the rest. Two real cases, both of which end in the user staring
10
+ * at a sentence that promises a list and then shows nothing:
11
+ *
12
+ * "Multiple projects match this repo — pass --project:" ...and then no projects.
13
+ * "You already have 5 working copies..." ...and then no folders to go and look in.
14
+ *
15
+ * Nothing was broken in either feature. The text existed, was correct, and never reached the screen.
16
+ */
17
+
18
+ /**
19
+ * Print `err` the way the CLI always has, plus the lines that were being lost.
20
+ *
21
+ * The first line still comes from the stack, so an error's real class name survives (a TypeError says
22
+ * TypeError, not Error). Subsequent lines come from the MESSAGE, never the stack, because a stack trace
23
+ * is noise to somebody running a CLI and this repo deliberately does not print them.
24
+ *
25
+ * A single-line message prints exactly as it did before, byte for byte.
26
+ *
27
+ * @param {*} err the thrown value; need not be an Error
28
+ * @param {(...args: any[]) => void} write where to send each line (console.error by default)
29
+ */
30
+ function printError(err, write = console.error) {
31
+ // With a stack, its first line already carries the class name and the message's first line, so only
32
+ // the REMAINING message lines are added. Without one — anything thrown that is not an Error — there
33
+ // is no such line, so the whole message is printed rather than silently starting at line two.
34
+ const hasStack = Boolean(err && err.stack);
35
+ const first = hasStack ? String(err.stack).split("\n")[0] : String(err);
36
+ write(first);
37
+ // Only lines the FIRST line did not already carry. With a stack that is everything after line one.
38
+ // Without one, String(err) may already be the whole message (an Error whose stack was stripped
39
+ // stringifies to "Error: <the lot>"), so printing from line one again said it all twice.
40
+ const messageLines = err && typeof err.message === "string" ? err.message.split("\n") : [];
41
+ const rest = hasStack ? messageLines.slice(1) : messageLines.filter((l) => !first.includes(l));
42
+ for (const line of rest) write(line);
43
+ if (err && err.status) write(` (HTTP ${err.status})`);
44
+ }
45
+
46
+ module.exports = { printError };