@niroai/niro 0.10.0 → 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.
- package/CHANGELOG.md +117 -0
- package/README.md +185 -249
- package/docs/working-on-branches.md +55 -0
- package/package.json +1 -1
- package/src/commands/cleanup.js +689 -0
- package/src/commands/info.js +51 -2
- package/src/commands/release.js +13 -60
- package/src/commands/takeover.js +52 -2
- package/src/commands/watch.js +93 -25
- package/src/index.js +23 -3
- package/src/lib/folderSync.js +6 -0
- package/src/lib/periodicClaim.js +100 -0
- package/src/lib/printError.js +46 -0
- package/src/lib/takeoverRestore.js +156 -0
- package/src/mcp/setup.js +178 -13
|
@@ -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 };
|