@niroai/niro 0.10.1 → 0.12.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.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * `niro cleanup-temp-projects` — find local registrations whose temporary project no longer exists on
3
+ * the server, and put those folders back on their original project.
4
+ *
5
+ * WHY THIS EXISTS. A temporary project can disappear without this machine ever being told: someone
6
+ * deletes it in the web UI, or the idle sweeper reaps it after N days. The folder keeps a registration
7
+ * pointing at a project id that resolves to nothing, and that dead pointer is not harmless — it is what
8
+ * makes `niro new-temp-project` in a second worktree refuse with "already held by another folder".
9
+ *
10
+ * `niro discard-temp-project` already repairs one folder in this state (release.js). What it cannot do
11
+ * is find them: you have to be standing in the affected folder, or already know the id. This is the
12
+ * sweep. It reads the same registry, asks the server once per temporary project, and repairs the ones
13
+ * that are gone.
14
+ *
15
+ * THREE RULES IT WILL NOT BREAK.
16
+ *
17
+ * 1. Only a definitive "Project not found" counts as gone. A 401, a 500, a timeout or a genuine
18
+ * permissions refusal leaves the entry exactly as it was and is reported as "could not check". This
19
+ * command touches many entries at once, so treating an unreachable server as "everything is gone"
20
+ * would wipe a whole machine's registrations on one bad connection.
21
+ * 2. Nothing is ever unregistered. A folder is moved back to its source project, not deleted from the
22
+ * registry — the watch daemon still needs somewhere to sync it.
23
+ * 3. Nothing is guessed. A temporary project is only repaired when some folder registered against it
24
+ * recorded which project it was a working copy of.
25
+ *
26
+ * It never deletes anything server-side. Every project it acts on is already gone.
27
+ *
28
+ * THREE KINDS OF STALE POINTER, and they need different repairs:
29
+ *
30
+ * a temporary copy -> the folder is pointed back at the project it was a copy OF. It has somewhere
31
+ * to fall back to, and the watch daemon still needs to sync it somewhere.
32
+ * an ordinary repo -> there is no fallback, so the registration is removed. Only when the repo is
33
+ * gone too: a project can be deleted while its repos are remapped to another
34
+ * one, and unregistering a folder that is still syncing fine would be a bug.
35
+ * a `.niro-project` -> the pin file is deleted. Nothing else sweeps these, so a pin naming a project
36
+ * somebody removed in the web app just dangles for ever.
37
+ */
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+
41
+ const agentApi = require("../lib/agentApi");
42
+ const api = require("../lib/api");
43
+ const color = require("../lib/color");
44
+ const credentials = require("../lib/credentials");
45
+ const prompt = require("../lib/prompt");
46
+ const { projectsToRows } = require("./projects");
47
+ const tempProject = require("../lib/tempProject");
48
+ const niroProjectFile = require("../lib/niroProjectFile");
49
+ const takeoverRestore = require("../lib/takeoverRestore");
50
+ const watchState = require("../lib/watchState");
51
+
52
+ const ok = (s) => color.green(s);
53
+ const warn = (s) => color.yellow(s);
54
+
55
+ /**
56
+ * Group the local registry into one candidate per temporary project.
57
+ *
58
+ * A temporary project can cover SEVERAL folders (ADR-016 extends one copy with more repos), so the
59
+ * unit of work is the project, not the folder: one server call answers for all of its folders.
60
+ *
61
+ * A project becomes a unit of work when SOME folder carries a takeover backup — that backup is the only
62
+ * thing on this machine saying "this id is a temporary project" rather than an ordinary one, and asking
63
+ * the server would not help because a project that is gone answers nothing about what it used to be.
64
+ *
65
+ * Membership is then by `projectId`, which deliberately includes a sibling folder that has no backup of
66
+ * its own. Those siblings exist — a multi-repo takeover only needs ONE folder to record the source — and
67
+ * leaving them behind is what strands a folder pointing at a deleted project while its neighbour gets
68
+ * repaired. The `witness` is the folder that did record it, and it answers for the whole group because
69
+ * every folder in one copy clones the SAME source project.
70
+ *
71
+ * `sourceProjectId` comes back null when no folder recorded one (a partial backup, or one written by an
72
+ * older CLI). That is not the same as "not a temporary project": it is a temporary project with no way
73
+ * back written down, which the caller reports and leaves alone rather than guessing at, exactly as
74
+ * `niro discard-temp-project` does.
75
+ *
76
+ * Pure, so the grouping can be tested without a server.
77
+ *
78
+ * @returns {Array<{tempProjectId: string, folders: object[], sourceProjectId: string|null}>}
79
+ */
80
+ function groupCandidates(entries) {
81
+ const takenOver = new Set(
82
+ entries.filter((e) => e.projectId && e.takeover).map((e) => e.projectId));
83
+ const out = [];
84
+ for (const tempProjectId of takenOver) {
85
+ const folders = entries.filter((e) => e.projectId === tempProjectId);
86
+ const witness = folders.find((e) => e.takeover && e.takeover.sourceProjectId);
87
+ out.push({
88
+ tempProjectId,
89
+ folders,
90
+ sourceProjectId: witness ? witness.takeover.sourceProjectId : null,
91
+ });
92
+ }
93
+ return out.sort((a, b) => (a.tempProjectId < b.tempProjectId ? -1 : 1));
94
+ }
95
+
96
+ /**
97
+ * Registry entries that are ORDINARY registrations: they name a project but were never taken over.
98
+ * Grouped by project so one server call answers for all of their folders, exactly as the temporary
99
+ * ones are.
100
+ *
101
+ * These are out of scope for the repair path — there is no source project to fall back to — but they
102
+ * are very much in scope for the sweep, because a folder registered against a project somebody deleted
103
+ * is the case that leaves the daemon throwing the same error on every start with nothing to clear it.
104
+ */
105
+ function plainRegistrations(entries, tempProjectIds = new Set()) {
106
+ const byProject = new Map();
107
+ for (const e of entries) {
108
+ if (!e.projectId || e.takeover) continue;
109
+ // A folder pulled into a multi-repo takeover records no backup of its own — only one folder in a
110
+ // copy has to. Such a sibling looks exactly like an ordinary registration here, and treating it as
111
+ // one would UNREGISTER it while its neighbour was being repaired, which is the strand-a-sibling bug
112
+ // groupCandidates already exists to avoid. Anything naming a known temporary copy belongs to that
113
+ // copy's group, not here.
114
+ if (tempProjectIds.has(e.projectId)) continue;
115
+ if (!byProject.has(e.projectId)) byProject.set(e.projectId, []);
116
+ byProject.get(e.projectId).push(e);
117
+ }
118
+ return [...byProject.entries()]
119
+ .map(([projectId, folders]) => ({ projectId, folders }))
120
+ .sort((a, b) => (a.projectId < b.projectId ? -1 : 1));
121
+ }
122
+
123
+ /**
124
+ * The `.niro-project` pins that the registry's own folders OWN, as {folderPath, projectId}.
125
+ *
126
+ * niroProjectFile.read walks UP the tree, so standing in a subfolder of a pinned repo returns the
127
+ * parent's pin. Healing that would delete a pin belonging to a folder this sweep was never looking at,
128
+ * so a pin only counts when the file sits in the folder itself — the same test `niro
129
+ * discard-temp-project` applies before it heals an old-style pin.
130
+ */
131
+ function ownedPins(entries, apiUrl) {
132
+ const out = [];
133
+ const seen = new Set();
134
+ for (const e of entries) {
135
+ let dir = path.resolve(e.folderPath);
136
+ // Resolved through symlinks, so a folder registered by its link and another by its target are ONE
137
+ // pin, not two. Without this the same file is counted, reported and deleted twice.
138
+ try { dir = fs.realpathSync(dir); } catch { continue; } // gone, or unreadable — nothing to do here
139
+ if (seen.has(dir)) continue;
140
+ seen.add(dir);
141
+ let pin = null;
142
+ try { pin = niroProjectFile.read(dir); } catch { continue; }
143
+ if (!pin || !pin.id || !pin.source) continue;
144
+ let pinDir;
145
+ try { pinDir = path.dirname(fs.realpathSync(pin.source)); } catch { continue; }
146
+ if (pinDir !== dir) continue; // a parent's pin, not this folder's
147
+ out.push({ folderPath: dir, projectId: pin.id, taggedForThisBackend: Boolean(e.apiUrl)
148
+ && watchState.matchesBackend(e, apiUrl) });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Is this string plausibly a project id or alias, rather than something that only ended up on the first
155
+ * line of a `.niro-project` file?
156
+ *
157
+ * niroProjectFile.parse takes the first line that is not a comment. A file left mid-merge therefore
158
+ * yields `<<<<<<< HEAD`, and the server reports that as not found exactly like a deleted project. One
159
+ * character class covers the realistic cases: ids are UUIDs and aliases are slugs, so neither contains
160
+ * whitespace or a conflict marker.
161
+ */
162
+ function looksLikeProjectRef(id) {
163
+ return typeof id === "string" && id.length > 0 && id.length <= 200 && !/[\s<>=|]/.test(id);
164
+ }
165
+
166
+ /**
167
+ * Ask the server about one temporary project and classify it.
168
+ *
169
+ * "gone" is decided by tempProject.isProjectNotFound, which reads the MESSAGE rather than the status:
170
+ * the backend answers 403 both for a deleted project and for one belonging to someone else, so status
171
+ * alone cannot tell "it is gone" from "you may not see it". Anything that is not a definitive
172
+ * not-found lands in "unknown" and changes nothing.
173
+ */
174
+ async function probe(candidate) {
175
+ try {
176
+ const project = await api.get(`/api/projects/${encodeURIComponent(candidate.tempProjectId)}`);
177
+ return { ...candidate, state: "alive", project };
178
+ } catch (err) {
179
+ if (tempProject.isProjectNotFound(err)) return { ...candidate, state: "gone" };
180
+ return { ...candidate, state: "unknown", error: err.message };
181
+ }
182
+ }
183
+
184
+ async function cleanup(opts = {}) {
185
+ credentials.requireCreds();
186
+ // `--json` reads like a formatting flag, and there is no way to ask a question down a JSON stream —
187
+ // so on its own, at a terminal, it would write to the registry without ever asking. Someone reaching
188
+ // for it to LOOK at the state would change it. Make the intent explicit instead. Unattended runs
189
+ // (an agent, a script) have no terminal and are unaffected.
190
+ if (opts.json && !opts.dryRun && !opts.yes && process.stdout.isTTY) {
191
+ throw new Error("`--json` cannot show a confirmation prompt, and this command writes to your local " +
192
+ "registry. Add `--dry-run` to look without changing anything, or `-y` to go ahead.");
193
+ }
194
+ // Quiet mode is for the daemon, which runs this every six hours into a log nobody reads by choice.
195
+ // It silences the routine narration ("checking 2 projects", "nothing to clean up") and keeps every
196
+ // line that reports something CHANGED. A background job that says nothing when it did nothing is one
197
+ // people still trust when it does speak.
198
+ const say = (...args) => { if (!opts.quiet) console.log(...args); };
199
+ // `--forget <path>`: drop ONE registration, because the developer said so.
200
+ //
201
+ // This is the supported way to clear a folder you have deleted, and the answer to a customer who
202
+ // asked whether they should edit projects.json by hand. It is deliberately a separate, explicit
203
+ // action rather than something the sweep does: absence is not proof a folder is gone for good, and
204
+ // the sweep only ever REPORTS a missing folder. Here a person has looked and decided.
205
+ //
206
+ // It never touches the folder or anything on the server — only this machine's note that the folder
207
+ // was being synced.
208
+ if (opts.forget) {
209
+ const target = path.resolve(opts.forget);
210
+ const entry = watchState.peek(target);
211
+ if (!entry) {
212
+ throw new Error(`Nothing on this machine is registered for ${target}. ` +
213
+ "Run `niro doctor sync-registry` to see what is.");
214
+ }
215
+ if (fs.existsSync(target) && !opts.yes && process.stdout.isTTY && !prompt.isNonInteractive()) {
216
+ console.log(warn(` ${target} still exists on disk, and is being synced.`));
217
+ const sure = await prompt.confirm(" Stop syncing it anyway?", false);
218
+ if (!sure) { console.log("Aborted."); return; }
219
+ }
220
+ // Compare-and-delete on the gitId that was read a moment ago, the same guard the sweep uses: a
221
+ // `niro new-temp-project` landing in between must not have its fresh registration removed.
222
+ if (!watchState.remove(target, entry.gitId)) {
223
+ throw new Error(`${target} was re-registered while this ran — nothing was changed. Try again.`);
224
+ }
225
+ console.log(` ${ok("✓")} Stopped tracking ${target}. The folder itself was not touched.`);
226
+ return;
227
+ }
228
+
229
+ const baseUrl = api.currentBaseUrl();
230
+ const entries = watchState.allLocal(baseUrl);
231
+ const candidates = groupCandidates(entries);
232
+ const tempIds = new Set(candidates.map((c) => c.tempProjectId));
233
+ const plain = plainRegistrations(entries, tempIds);
234
+ // Pins naming a temporary copy are deliberately left to `niro discard-temp-project`, which knows how
235
+ // to restore the previous pin rather than just deleting the file.
236
+ //
237
+ // The other two filters exist because a `.niro-project` is a file in the developer's REPOSITORY, and
238
+ // usually tracked in git. Deleting one is the most destructive thing this command does, so it gets
239
+ // the strictest entry conditions:
240
+ //
241
+ // - the id must be plausible. niroProjectFile takes the first non-comment line, so a file left in a
242
+ // merge conflict yields "<<<<<<< HEAD" as the "project id", which the server naturally reports as
243
+ // not found. Deleting a developer's unresolved conflict mid-merge is not a cleanup.
244
+ // - the folder must be tagged for THIS backend. watchState treats a missing apiUrl as matching any
245
+ // backend, which is right for a registration (the repo check backstops it) and wrong here: a
246
+ // legacy untagged entry swept while pointed at a local stack is judged against a different
247
+ // account's project list.
248
+ const pins = ownedPins(entries, baseUrl)
249
+ .filter((pn) => !tempIds.has(pn.projectId))
250
+ .filter((pn) => looksLikeProjectRef(pn.projectId))
251
+ .filter((pn) => pn.taggedForThisBackend);
252
+
253
+ // One probe per distinct project id, whatever named it. A folder, its pin and a sibling folder very
254
+ // often name the SAME project, and asking three times would be three chances to get three different
255
+ // answers about one thing — as well as three times the load.
256
+ // ORDER MATTERS, and pins come LAST on purpose. A probe of a registration returns the whole project
257
+ // record, so it teaches us that project's alias; a pin often holds exactly that alias. Probing pins
258
+ // after everything else means the alias is already known-alive by the time the pin is considered, and
259
+ // the pin is never asked about at all. Reorder this and a live project's pin gets deleted — which is
260
+ // what the alias test locks.
261
+ const projectIds = [...new Set([
262
+ ...candidates.map((c) => c.tempProjectId),
263
+ ...plain.map((r) => r.projectId),
264
+ ...pins.map((p) => p.projectId),
265
+ ])];
266
+
267
+ if (projectIds.length === 0) {
268
+ if (opts.json) {
269
+ console.log(JSON.stringify({ checked: 0, alive: [], gone: [], orphaned: [], unknown: [],
270
+ repaired: [], unregistered: [], stale_pins: [], kept_repo_alive: [],
271
+ repo_check_failed: [], untagged: [],
272
+ healed: [], failed: [],
273
+ dry_run: Boolean(opts.dryRun) }));
274
+ return;
275
+ }
276
+ say(`\n ${ok("✓")} Nothing registered on this machine — nothing to clean up.`);
277
+ return;
278
+ }
279
+
280
+ if (!opts.json) {
281
+ say(`\n Checking ${projectIds.length} project` +
282
+ `${projectIds.length === 1 ? "" : "s"} against the server...\n`);
283
+ }
284
+
285
+ // The account's own project list, fetched ONCE, and it is the authority on existence.
286
+ //
287
+ // Asking `/api/projects/{x}` directly is NOT enough, and a real dry run is what proved it. A
288
+ // `.niro-project` file may hold an ALIAS rather than an id — this repo's own pin says `niro-cli` —
289
+ // and that route answers `403 Project not found: niro-cli` for a project that is alive and sitting in
290
+ // the list under exactly that alias. Trusting the direct lookup would have deleted the pin of a live
291
+ // project, which is the one thing a pin sweep must never do.
292
+ //
293
+ // So: anything the list knows, by id OR by alias, is alive and is not probed at all. Only a reference
294
+ // the list has never heard of goes on to the direct check, where a definitive not-found is required.
295
+ const known = new Set();
296
+ let listed;
297
+ try {
298
+ listed = await api.get("/api/projects");
299
+ } catch (err) {
300
+ throw new Error(`Could not list your projects, so nothing was checked: ${err.message}`);
301
+ }
302
+ // FAIL CLOSED on a shape we do not recognise. The first cut only refused when the request THREW,
303
+ // and that is not the dangerous case. api.js returns the raw body for a 200 that is not JSON, so a
304
+ // proxy interstitial, an SSO sign-in page or a captcha challenge all arrive as a plain string —
305
+ // which parsed to zero rows, looked exactly like "you own no projects", and sent every reference to
306
+ // the direct lookup. There an ALIAS always answers "Project not found", so the sweep would have
307
+ // deleted the `.niro-project` of every live repo whose pin holds one. That file is tracked in git.
308
+ // Proven end to end over a real socket during review: the same live project keeps its pin when the
309
+ // list answers JSON and loses it when the list answers HTML.
310
+ if (!Array.isArray(listed) && !(listed && typeof listed === "object" && Array.isArray(listed.projects))) {
311
+ throw new Error("Could not read your project list — the server answered with something this " +
312
+ "version does not recognise, so nothing was checked. Nothing has been changed.");
313
+ }
314
+ for (const r of projectsToRows(listed)) {
315
+ known.add(r.project_id);
316
+ if (r.alias) known.add(r.alias);
317
+ }
318
+
319
+
320
+ // Sequential, one per project id. Answers are shared by every folder and pin naming that project.
321
+ const verdicts = new Map();
322
+ for (const id of projectIds) {
323
+ if (known.has(id)) { verdicts.set(id, { tempProjectId: id, folders: [], state: "alive" }); continue; }
324
+ const v = await probe({ tempProjectId: id, folders: [] });
325
+ verdicts.set(id, v);
326
+ // A probe that found a live project tells us its id AND its alias. Both are now known-alive, so a
327
+ // pin naming the alias is never separately probed and judged gone. Review found this evidence had
328
+ // been fetched and thrown away: the registration's probe returned the very project whose alias the
329
+ // pin held, and the pin was deleted anyway.
330
+ const proj = v.state === "alive" ? v.project : null;
331
+ if (proj) {
332
+ if (proj.project_id) known.add(proj.project_id);
333
+ if (proj.alias) known.add(proj.alias);
334
+ }
335
+ }
336
+ // Re-check every "gone" verdict against what the probes taught us.
337
+ //
338
+ // This was deleted once, on the claim that pins are probed last so an alias is always known first.
339
+ // That claim was wrong and the deletion was a mistake: the ordering only puts pins after
340
+ // REGISTRATIONS, and two pins are ordered against each other by registry order, so one pin holding
341
+ // another project's alias is judged before the probe that would have revealed it. A surviving
342
+ // mutation was evidence the tests were thin, not that the branch was dead.
343
+ for (const [id, v] of verdicts) {
344
+ if (v.state === "gone" && known.has(id)) verdicts.set(id, { ...v, state: "alive" });
345
+ }
346
+ const isGone = (id) => verdicts.get(id).state === "gone";
347
+
348
+ const results = candidates.map((c) => {
349
+ const v = verdicts.get(c.tempProjectId);
350
+ return { ...c, state: v.state, error: v.error, project: v.project };
351
+ });
352
+ const plainGone = plain.filter((r) => isGone(r.projectId));
353
+ const pinsGone = pins.filter((pn) => isGone(pn.projectId));
354
+
355
+ const alive = results.filter((r) => r.state === "alive");
356
+ const gone = results.filter((r) => r.state === "gone" && r.sourceProjectId);
357
+ // Gone, but no folder recorded which project it was a copy of. There is nothing to restore it to, so
358
+ // it is reported and left alone rather than guessed at.
359
+ const orphaned = results.filter((r) => r.state === "gone" && !r.sourceProjectId);
360
+ const unknown = results.filter((r) => r.state === "unknown");
361
+
362
+ if (!opts.json && !opts.quiet) {
363
+ for (const r of results) {
364
+ const paths = r.folders.map((f) => f.folderPath).join(", ");
365
+ if (r.state === "alive") {
366
+ console.log(` ${ok("✓")} ${color.bold(r.tempProjectId)} alive` +
367
+ color.dim(` (${r.folders.length} folder${r.folders.length === 1 ? "" : "s"})`));
368
+ } else if (r.state === "gone" && r.sourceProjectId) {
369
+ console.log(` ${warn("✗")} ${color.bold(r.tempProjectId)} gone` +
370
+ color.dim(` ${paths}`));
371
+ } else if (r.state === "gone") {
372
+ console.log(` ${warn("✗")} ${color.bold(r.tempProjectId)} gone, but no folder records its ` +
373
+ `source project` + color.dim(` ${paths} — left alone`));
374
+ } else {
375
+ console.log(` ${color.dim("?")} ${color.bold(r.tempProjectId)} could not check` +
376
+ color.dim(` (${r.error}) — left alone`));
377
+ }
378
+ }
379
+ for (const r of plainGone) {
380
+ console.log(` ${warn("✗")} ${color.bold(r.projectId)} gone (not a temporary copy)` +
381
+ color.dim(` ${r.folders.map((f) => f.folderPath).join(", ")}`));
382
+ }
383
+ for (const pn of pinsGone) {
384
+ console.log(` ${warn("?")} ${color.bold(pn.projectId)} named by a .niro-project file, and ` +
385
+ `nothing on this backend answers to it` + color.dim(` ${pn.folderPath}`));
386
+ }
387
+ }
388
+
389
+ const toRepair = gone.flatMap((r) => r.folders);
390
+ const toUnpin = pinsGone;
391
+
392
+ // FOLDERS THAT ARE GONE FROM DISK.
393
+ //
394
+ // A registration whose folder no longer exists is the case a customer actually reported: three
395
+ // deleted git worktrees, and the daemon erroring on every start for ever with nothing able to clear
396
+ // them. The sweep cannot help through the server, because the project and the repo are both perfectly
397
+ // healthy — it is the folder that went.
398
+ //
399
+ // BUT ABSENCE IS NOT PROOF. A folder can be missing for a night: an unmounted network share, an
400
+ // external drive unplugged, a mount that has not come back after a reboot. Dropping the registration
401
+ // then costs that folder its sync baseline and forces a full re-upload later. This codebase already
402
+ // learned the harder version of this lesson — watch.js queues file deletions and refuses to believe
403
+ // them until the folder goes quiet, because a `git checkout` makes files vanish for a moment, and
404
+ // believing it once cost a customer 494 files.
405
+ //
406
+ // So a missing folder is REMEMBERED, not acted on. The first sweep that cannot find it stamps
407
+ // `missingSince`; a later sweep that still cannot find it, and only after the window has passed,
408
+ // reports it. Any sweep that DOES find the folder clears the stamp, so an unplugged drive resets the
409
+ // clock simply by being plugged back in. At the daemon's six-hourly cadence the default window means
410
+ // a folder has to be missing across roughly four runs, spanning more than a day, before it is even
411
+ // mentioned — and it is only ever mentioned. Removing the entry stays a human decision.
412
+ const MISSING_CONFIRM_MS = Number(process.env.NIRO_MISSING_CONFIRM_MS) || 24 * 60 * 60 * 1000;
413
+ const now = Date.now();
414
+ const missingConfirmed = [];
415
+ const missingPending = [];
416
+ for (const e of entries) {
417
+ const there = fs.existsSync(e.folderPath);
418
+ if (there) {
419
+ if (e.missingSince) watchState.update(e.folderPath, { missingSince: null });
420
+ continue;
421
+ }
422
+ if (!e.missingSince) {
423
+ // First sighting. Record it and say nothing at all this run.
424
+ if (!opts.dryRun) watchState.update(e.folderPath, { missingSince: now });
425
+ missingPending.push(e.folderPath);
426
+ continue;
427
+ }
428
+ const since = Number(e.missingSince);
429
+ if (Number.isFinite(since) && now - since >= MISSING_CONFIRM_MS) missingConfirmed.push(e.folderPath);
430
+ else missingPending.push(e.folderPath);
431
+ }
432
+
433
+ // PINS ARE REPORTED. NOTHING MORE. That is the outcome of five review rounds, and the reasoning is
434
+ // worth keeping because the next person will want to automate this again.
435
+ //
436
+ // Every attempt to EDIT a pin automatically produced a new way to destroy a git-tracked file in
437
+ // somebody's repository. Deleting one, because a renamed project is indistinguishable from a deleted
438
+ // one when a pin holds an alias. Then repairing one, because the guard meant to spare shared files
439
+ // checked the wrong path, and because it read a missing `git` binary as "not shared", and because a
440
+ // single 500 from the resolve endpoint became permission to delete. Each fix passed the whole suite
441
+ // and a mutation pass, and the next round found the next hole.
442
+ //
443
+ // The value never justified it. A pin naming something unresolvable is ALREADY inert: Niro falls back
444
+ // to the git remote, so deleting or rewriting it changes no answer anybody gets. What has value is
445
+ // saying it out loud, because nothing else in the CLI does — and the repair (update it, or remove it)
446
+ // is one edit the developer can make, in a file they own, having seen it.
447
+ const stillStale = toUnpin;
448
+
449
+
450
+ // Decide the ordinary registrations HERE, before the dry-run gate, so a dry run reports what would
451
+ // actually happen rather than an upper bound. A gone project alone is not enough to unregister a
452
+ // folder: a project can be deleted while its repos are remapped onto another one, and that folder is
453
+ // still syncing perfectly well. Asking whether the REPO is gone too is the same question watch.js's
454
+ // self-heal asks before removing a poison entry, and it is read-only, so it is safe in a dry run.
455
+ const toUnregister = [];
456
+ const keptRepoAlive = [];
457
+ const untagged = [];
458
+ const repoCheckFailed = [];
459
+ for (const e of plainGone.flatMap((r) => r.folders)) {
460
+ // An entry with no apiUrl matches ANY backend (watchState.matchesBackend), so a registration
461
+ // written before backend tagging existed is judged against whichever stack the CLI happens to be
462
+ // pointed at — and BOTH guards then fail the same way, because both ask the wrong server. The repo
463
+ // check cannot backstop that, because a foreign gitId is indistinguishable from a deleted one.
464
+ if (!e.apiUrl) { untagged.push(e.folderPath); continue; }
465
+ try {
466
+ if (await agentApi.repoExists(e.gitId)) keptRepoAlive.push(e.folderPath);
467
+ else toUnregister.push(e);
468
+ } catch (err) {
469
+ // Could not ask. Changing nothing is the safe half, here as everywhere else in this command.
470
+ repoCheckFailed.push({ folder: e.folderPath, error: `could not check the repo: ${err.message}` });
471
+ }
472
+ }
473
+ // Pins are reported, not acted on, so they are not WORK — but see notes() below, which prints
474
+ // them even when there is no work at all. A run whose only finding is a stale pin must still say so.
475
+ const workCount = toRepair.length + toUnregister.length;
476
+
477
+ if (opts.json && opts.dryRun) {
478
+ console.log(JSON.stringify(summary({ results, alive, gone, orphaned, unknown },
479
+ { repinned: toRepair.map((f) => f.folderPath),
480
+ unregistered: toUnregister.map((f) => f.folderPath),
481
+ stalePins: stillStale.map((pn) => ({ folder: pn.folderPath, names: pn.projectId })),
482
+ keptRepoAlive, repoCheckFailed, untagged, healed: [], failed: [], dryRun: true })));
483
+ return;
484
+ }
485
+ // Said before any early return: a folder naming a deleted project whose repo is still alive is the
486
+ // most informative thing this command can find, and it produces no work. Reporting only work would
487
+ // print "nothing to clean up" over the top of it.
488
+ const notes = () => {
489
+ // NOTHING here in --json mode. These lines are prose, and printing them to the same stream as the
490
+ // payload made `JSON.parse` fail on the very first character. Everything notes() says also appears
491
+ // in the JSON, so a machine caller loses nothing.
492
+ if (opts.json) return;
493
+ if (keptRepoAlive.length) {
494
+ say(color.dim(` ${keptRepoAlive.length} folder(s) name a deleted project but their repo still ` +
495
+ "exists — left alone, it may have been remapped."));
496
+ }
497
+ // Printed with console.log, not say(): a folder the sweep can never judge is exactly the thing a
498
+ // background run must not swallow. Quiet mode silences routine narration, not unanswerable state.
499
+ for (const f of repoCheckFailed) console.log(warn(` ${f.folder} — ${f.error}`));
500
+ if (missingConfirmed.length) {
501
+ console.log(warn(`\n ⚠ ${missingConfirmed.length} folder(s) have been missing from disk for over a ` +
502
+ "day. Their project and repo are fine — it is the folder that is gone, so Niro keeps failing"));
503
+ console.log(warn(" to sync them. Delete the entry with `niro doctor sync-registry --forget <path>`:"));
504
+ for (const f of missingConfirmed) console.log(color.dim(` ${f}`));
505
+ }
506
+ if (stillStale.length) {
507
+ // One line when the daemon says it, the full list when a person asked. Fifteen paths four times a
508
+ // day, for ever, about something the run will not change, is how a log stops being read.
509
+ if (opts.quiet) {
510
+ console.log(warn(` ${stillStale.length} .niro-project file(s) name something unknown — ` +
511
+ "run `niro doctor sync-registry` to see them."));
512
+ return;
513
+ }
514
+ console.log(warn(`\n ⚠ ${stillStale.length} .niro-project file(s) name something this backend does ` +
515
+ "not recognise. They are NOT deleted — the project may have been renamed and its repo no"));
516
+ console.log(warn(" longer resolves either, so only you can say whether to update or remove them:"));
517
+ for (const pn of stillStale) console.log(color.dim(` ${pn.folderPath} -> ${pn.projectId}`));
518
+ }
519
+ };
520
+
521
+ if (opts.dryRun) {
522
+ // Same notes a real run prints. A dry run that lists stale pins per-project but omits the "these
523
+ // are NOT deleted, only you can decide" explanation reads as a threat to delete them.
524
+ notes();
525
+ console.log(workCount
526
+ ? color.dim(`\n --dry-run: would repair ${toRepair.length} and unregister ` +
527
+ `${toUnregister.length} folder(s). Nothing was changed.`)
528
+ : color.dim("\n --dry-run: nothing to clean up."));
529
+ return;
530
+ }
531
+
532
+
533
+ if (workCount === 0) {
534
+ notes();
535
+ if (opts.json) {
536
+ console.log(JSON.stringify(summary({ results, alive, gone, orphaned, unknown },
537
+ { repinned: [], unregistered: [],
538
+ stalePins: stillStale.map((pn) => ({ folder: pn.folderPath, names: pn.projectId })),
539
+ keptRepoAlive, repoCheckFailed, untagged, healed: [], failed: [], dryRun: false })));
540
+ return;
541
+ }
542
+ say(`\n ${ok("✓")} Nothing to clean up.`);
543
+ return;
544
+ }
545
+
546
+ // Confirm only where there is somebody to answer. Non-interactive runs go ahead: every project being
547
+ // acted on is already gone, so this repairs a broken pointer rather than destroying anything, and
548
+ // `niro discard-temp-project` takes the same view on its own already-gone path. Blocking here would
549
+ // strand exactly the unattended runs (an agent, the daemon's advice) that hit this state most.
550
+ if (!opts.yes && !opts.json && process.stdout.isTTY && !prompt.isNonInteractive()) {
551
+ // Every kind of work, named. It used to say "Repair 0 folders?" while going on to delete a
552
+ // `.niro-project` from a git repository — a person approving that was told nothing about the file.
553
+ const parts = [];
554
+ if (toRepair.length) parts.push(`repair ${toRepair.length} folder(s)`);
555
+ if (toUnregister.length) parts.push(`unregister ${toUnregister.length} folder(s)`);
556
+ const sure = await prompt.confirm(`\n ${parts.join(", ")}?`, false);
557
+ if (!sure) {
558
+ console.log("Aborted.");
559
+ return;
560
+ }
561
+ }
562
+
563
+ const repinned = [];
564
+ const healed = [];
565
+ const failed = [];
566
+ const skipped = [];
567
+ const untracked = [];
568
+ for (const r of gone) {
569
+ const res = takeoverRestore.restoreFolders(r.folders, {
570
+ tempProjectId: r.tempProjectId,
571
+ cloneOf: r.sourceProjectId,
572
+ baseUrl,
573
+ healPins: false, // never from here: see takeoverRestore. This runs unattended.
574
+ });
575
+ repinned.push(...res.repinned);
576
+ healed.push(...res.healed);
577
+ failed.push(...res.failed);
578
+ skipped.push(...res.skipped);
579
+ untracked.push(...res.untracked);
580
+ }
581
+
582
+ // NOT pushed into `failed` as well: notes() already prints these, and doing both printed the
583
+ // same folder and reason twice in one run.
584
+ const unregistered = [];
585
+ for (const e of toUnregister) {
586
+ // Confirm the entry is really there under the key `remove` will use. A registry key written with a
587
+ // trailing slash resolves to a DIFFERENT key, so remove deletes nothing, returns true because its
588
+ // compare-and-delete guard never fires on an absent entry, and the sweep reports a folder it did
589
+ // not unregister — every six hours, for ever.
590
+ if (!watchState.peek(e.folderPath)) {
591
+ failed.push({ folder: e.folderPath, error: "registry key does not resolve — edit ~/.niro/projects.json by hand" });
592
+ continue;
593
+ }
594
+ // Compare-and-delete on gitId, so a folder re-registered during the checks above keeps its fresh
595
+ // registration instead of being removed by a decision made about the old one.
596
+ if (watchState.remove(e.folderPath, e.gitId)) unregistered.push(e.folderPath);
597
+ else skipped.push({ folder: e.folderPath, nowGitId: null });
598
+ }
599
+
600
+
601
+ if (opts.json) {
602
+ console.log(JSON.stringify(summary({ results, alive, gone, orphaned, unknown },
603
+ { repinned, unregistered,
604
+ stalePins: stillStale.map((pn) => ({ folder: pn.folderPath, names: pn.projectId })),
605
+ keptRepoAlive, repoCheckFailed, untagged, healed, failed, skipped, untracked, dryRun: false })));
606
+ return;
607
+ }
608
+
609
+ if (unregistered.length) {
610
+ console.log(`\n ${ok("✓")} Unregistered ${unregistered.length} folder(s) whose project and repo ` +
611
+ "are both gone. Run `niro init` in one to start syncing it again:");
612
+ for (const f of unregistered) console.log(color.dim(` ${f}`));
613
+ }
614
+ notes();
615
+
616
+ const syncing = repinned.filter((f) => !untracked.includes(f));
617
+ if (repinned.length) console.log(`\n ${ok("✓")} Repaired ${repinned.length} folder registration` +
618
+ `${repinned.length === 1 ? "" : "s"}. Niro routes them back to the original project automatically.`);
619
+ if (untracked.length) {
620
+ // Not a failure, and not something to fix: these folders had no local registration before they were
621
+ // taken over, so they are back where they started. Said out loud because "repaired" would otherwise
622
+ // imply the daemon is still syncing them, and it is not.
623
+ console.log(color.dim(` ${syncing.length} of those are locally tracked and keep syncing. ` +
624
+ `The other ${untracked.length} had no local registration before the copy was made, so they go ` +
625
+ "back to not having one (run `niro init` in a folder you want synced):"));
626
+ for (const f of untracked) console.log(color.dim(` ${f}`));
627
+ }
628
+ if (skipped.length) {
629
+ console.log(warn(`\n ⚠ ${skipped.length} folder(s) were re-registered while this ran and were ` +
630
+ "left alone — run this again to pick them up:"));
631
+ for (const s of skipped) console.log(color.dim(` ${s.folder}`));
632
+ }
633
+ if (healed.length) {
634
+ console.log(color.dim(" Healed old-style pins:"));
635
+ for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
636
+ }
637
+ if (failed.length) {
638
+ console.log(warn(`\n ⚠ ${failed.length} folder(s) could not be repaired:`));
639
+ for (const f of failed) console.log(color.dim(` ${f.folder} — ${f.error}`));
640
+ }
641
+ if (orphaned.length || unknown.length) {
642
+ console.log(color.dim(` ${orphaned.length + unknown.length} temporary project(s) were left alone ` +
643
+ "— see above."));
644
+ }
645
+ }
646
+
647
+ /** Machine-readable shape for --json. Kept in one place so every exit path emits the same keys. */
648
+ function summary({ results, alive, gone, orphaned, unknown },
649
+ { repinned, unregistered = [], stalePins = [], keptRepoAlive = [], repoCheckFailed = [],
650
+ untagged = [], healed, failed, skipped = [],
651
+ untracked = [], dryRun }) {
652
+ const asRow = (r) => ({
653
+ project_id: r.tempProjectId,
654
+ source_project_id: r.sourceProjectId,
655
+ folders: r.folders.map((f) => f.folderPath),
656
+ });
657
+ return {
658
+ checked: results.length,
659
+ alive: alive.map(asRow),
660
+ gone: gone.map(asRow),
661
+ orphaned: orphaned.map(asRow),
662
+ unknown: unknown.map((r) => ({ ...asRow(r), error: r.error })),
663
+ // Under --dry-run this lists what WOULD be repaired and nothing has been written. Read `dry_run`
664
+ // before acting on it.
665
+ repaired: repinned,
666
+ // Ordinary registrations dropped: project AND repo both gone, so nothing to sync to.
667
+ unregistered,
668
+ // Pins are never deleted. This lists the ones the server does not recognise, so a caller can act.
669
+ stale_pins: stalePins,
670
+ // Folders naming a deleted project whose REPO is still alive, so they were left registered.
671
+ kept_repo_alive: keptRepoAlive,
672
+ // Folders the sweep could not judge, because the repo check itself failed.
673
+ repo_check_failed: repoCheckFailed,
674
+ // Legacy entries with no backend tag: never unregistered, because they would be judged against
675
+ // whichever backend the CLI is pointed at.
676
+ untagged,
677
+ // Repaired, but back to having no local repo id — the state they were in before the copy was made,
678
+ // so the daemon does not sync them. Not an error; a subset of `repaired`.
679
+ untracked,
680
+ // Re-registered by another process while the sweep ran, so deliberately left alone.
681
+ skipped,
682
+ healed,
683
+ failed,
684
+ dry_run: Boolean(dryRun),
685
+ };
686
+ }
687
+
688
+ module.exports = { cleanup, groupCandidates, plainRegistrations, ownedPins, probe,
689
+ looksLikeProjectRef };