@niroai/niro 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +55 -1
- package/package.json +1 -1
- package/src/commands/ab.js +198 -0
- package/src/commands/add-project.js +11 -11
- package/src/commands/add-repo.js +23 -23
- package/src/commands/build.js +5 -5
- package/src/commands/delete.js +3 -3
- package/src/commands/edit.js +11 -11
- package/src/commands/info.js +2 -2
- package/src/commands/init.js +108 -87
- package/src/commands/login.js +25 -5
- package/src/commands/projects.js +1 -1
- package/src/commands/release.js +81 -15
- package/src/commands/remove.js +5 -5
- package/src/commands/start.js +567 -0
- package/src/commands/takeover.js +105 -23
- package/src/commands/watch.js +35 -35
- package/src/index.js +57 -3
- package/src/lib/folderSync.js +13 -8
- package/src/lib/git.js +21 -3
- package/src/lib/moduleExcludeSuggestions.js +5 -5
- package/src/lib/nestedOutput.js +49 -0
- package/src/lib/onboardTarget.js +5 -5
- package/src/lib/prompt.js +3 -0
- package/src/lib/service.js +20 -12
- package/src/lib/spinner.js +28 -7
- package/src/lib/tempProject.js +25 -1
- package/src/lib/watchDaemon.js +9 -9
- package/src/mcp/server.js +7 -2
- package/src/mcp/setup.js +198 -160
- package/src/mcp/teardown.js +53 -53
package/src/commands/takeover.js
CHANGED
|
@@ -22,6 +22,7 @@ const git = require("../lib/git");
|
|
|
22
22
|
const prompt = require("../lib/prompt");
|
|
23
23
|
const watchState = require("../lib/watchState");
|
|
24
24
|
const resolveCtxLib = require("../lib/resolveContext");
|
|
25
|
+
const spinner = require("../lib/spinner");
|
|
25
26
|
|
|
26
27
|
const ok = (s) => color.green(s);
|
|
27
28
|
const warn = (s) => color.yellow(s);
|
|
@@ -72,9 +73,9 @@ async function takeover(pathArg, opts = {}) {
|
|
|
72
73
|
const list = ctx.projects.map((p) => ` ${p.project_id} (${p.alias})`).join("\n");
|
|
73
74
|
throw new Error(`Multiple projects match this repo — pass --project:\n${list}`);
|
|
74
75
|
}
|
|
75
|
-
console.log("\n
|
|
76
|
+
console.log("\n Multiple projects match this repo:");
|
|
76
77
|
ctx.projects.forEach((p, i) => console.log(` ${i + 1}. ${p.alias || "(no alias)"} ${color.dim(p.project_id)}`));
|
|
77
|
-
const answer = await prompt.ask(`
|
|
78
|
+
const answer = await prompt.ask(` Take over which one? [1-${ctx.projects.length}]`, "1");
|
|
78
79
|
const idx = parseInt(answer, 10) - 1;
|
|
79
80
|
if (isNaN(idx) || idx < 0 || idx >= ctx.projects.length) {
|
|
80
81
|
throw new Error(`Invalid choice: ${answer}`);
|
|
@@ -120,11 +121,11 @@ async function takeover(pathArg, opts = {}) {
|
|
|
120
121
|
&& path.resolve(e.folderPath) !== here);
|
|
121
122
|
if (holder) {
|
|
122
123
|
const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
|
|
123
|
-
console.log(warn("\n
|
|
124
|
-
console.log(warn("
|
|
124
|
+
console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
|
|
125
|
+
console.log(warn(" held by another folder:"));
|
|
125
126
|
console.log(` ${holder.folderPath}${onBranch}`);
|
|
126
|
-
console.log(color.dim("
|
|
127
|
-
console.log(color.dim("
|
|
127
|
+
console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
|
|
128
|
+
console.log(color.dim(" project until `niro new-temp-project` is run there again."));
|
|
128
129
|
if (prompt.isNonInteractive()) {
|
|
129
130
|
// Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
|
|
130
131
|
// would break any agent or script that runs this unattended. The way forward is two explicit
|
|
@@ -135,20 +136,18 @@ async function takeover(pathArg, opts = {}) {
|
|
|
135
136
|
+ holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
|
|
136
137
|
+ ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
|
|
137
138
|
}
|
|
138
|
-
if (!(await prompt.confirm("
|
|
139
|
+
if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
|
|
139
140
|
throw new Error("Cancelled — the temporary project stays where it is.");
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
console.log(`\n
|
|
144
|
-
console.log(color.dim("
|
|
144
|
+
console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
|
|
145
|
+
console.log(color.dim(" Your original project is not changed."));
|
|
145
146
|
|
|
146
147
|
// 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
|
|
147
148
|
// the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
|
|
148
149
|
// cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
|
|
149
|
-
const outcome = await
|
|
150
|
-
`/api/projects/${encodeURIComponent(source.project_id)}/clone`,
|
|
151
|
-
{ local_git_ids: [source.git_id], branch });
|
|
150
|
+
const outcome = await requestClone(source.project_id, source.git_id, branch);
|
|
152
151
|
const newProjectId = outcome.new_project_id || outcome.newProjectId;
|
|
153
152
|
const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
|
|
154
153
|
const newGitId = gitIdMap[source.git_id];
|
|
@@ -164,16 +163,16 @@ async function takeover(pathArg, opts = {}) {
|
|
|
164
163
|
// route; its absence means a version mismatch the user must hear about NOW, not discover via wrong answers.
|
|
165
164
|
const serverRoutes = outcome.routing_supported === true || outcome.routingSupported === true;
|
|
166
165
|
if (!serverRoutes) {
|
|
167
|
-
console.log(warn("\n
|
|
166
|
+
console.log(warn("\n ⚠ Your backend did NOT confirm automatic routing (pre-ADR-018 server?)."));
|
|
168
167
|
console.log(warn(" This CLI no longer re-pins .niro-project, so against this backend Niro's answers"));
|
|
169
168
|
console.log(warn(" will KEEP coming from the original project — NOT this temporary project."));
|
|
170
169
|
console.log(warn(" Fix: upgrade the backend (ai-assistant + ai-orchestrator), or use the previous CLI."));
|
|
171
170
|
}
|
|
172
171
|
if (wasExtended) {
|
|
173
|
-
console.log(`
|
|
172
|
+
console.log(` ${step("●")} Added this repo to your temporary project: ${color.bold(outcome.alias || newProjectId)} ` +
|
|
174
173
|
color.dim("(existing copy extended — your other repos are untouched)"));
|
|
175
174
|
} else {
|
|
176
|
-
console.log(`
|
|
175
|
+
console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
|
|
177
176
|
color.dim(`(${outcome.nodes_copied ?? "?"} nodes copied, no re-index needed)`));
|
|
178
177
|
}
|
|
179
178
|
|
|
@@ -191,7 +190,7 @@ async function takeover(pathArg, opts = {}) {
|
|
|
191
190
|
folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
|
|
192
191
|
const sizes = {};
|
|
193
192
|
for (const m of manifest) sizes[m.path] = m.size_bytes;
|
|
194
|
-
console.log(`
|
|
193
|
+
console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
|
|
195
194
|
`(branch ${color.cyan(branch || "detached HEAD")})...`);
|
|
196
195
|
// Capture the uploaded hashes: takeover registers its watchState entry AFTER this upload (below), so
|
|
197
196
|
// doInitialSync's own watchState.update no-ops (no entry yet). Seeding these into the final set means
|
|
@@ -204,9 +203,9 @@ async function takeover(pathArg, opts = {}) {
|
|
|
204
203
|
await api.post(`/api/graph/${encodeURIComponent(newProjectId)}/build`, {}, {
|
|
205
204
|
query: { force_full_rebuild: "false" },
|
|
206
205
|
});
|
|
207
|
-
console.log(`
|
|
206
|
+
console.log(` ${step("●")} Incremental index of your working tree triggered.`);
|
|
208
207
|
} catch (buildErr) {
|
|
209
|
-
console.log(warn(`
|
|
208
|
+
console.log(warn(` ⚠ Could not trigger the index build automatically (${buildErr.message}). ` +
|
|
210
209
|
"Run `niro build` to index your uploaded tree."));
|
|
211
210
|
}
|
|
212
211
|
|
|
@@ -266,11 +265,94 @@ async function takeover(pathArg, opts = {}) {
|
|
|
266
265
|
}
|
|
267
266
|
}
|
|
268
267
|
|
|
269
|
-
console.log(`\n
|
|
268
|
+
console.log(`\n ${ok("✓")} Niro now answers from your temporary project whenever you are on this branch`);
|
|
270
269
|
console.log(color.dim(" (routing is automatic per branch — your .niro-project file was NOT changed)."));
|
|
271
|
-
console.log(`
|
|
272
|
-
console.log(color.dim("\n
|
|
273
|
-
console.log(color.dim("
|
|
270
|
+
console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
|
|
271
|
+
console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
|
|
272
|
+
console.log(color.dim(" routing falls back to the original project by itself."));
|
|
274
273
|
}
|
|
275
274
|
|
|
276
|
-
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ask the server for a working copy, and wait for it without holding the request open.
|
|
278
|
+
*
|
|
279
|
+
* WHY. The copy takes 44 to 60 seconds. It used to run inside the POST, and the gateway in front of
|
|
280
|
+
* the server gives up at 60, so three requests at once on 2026-08-26 all came back as HTML 504 pages.
|
|
281
|
+
* A 504 also says nothing about whether the work finished: the real error underneath, a duplicate key
|
|
282
|
+
* from a migration that had not been run, went unread for an afternoon because of it.
|
|
283
|
+
*
|
|
284
|
+
* WORKS AGAINST BOTH SERVERS, and that is why it branches on the RESPONSE rather than on a version.
|
|
285
|
+
* A server that understands `async` returns 202 and a job id to poll. An older one ignores the unknown
|
|
286
|
+
* field and returns the outcome inline exactly as before, which is the shape this already handled. So
|
|
287
|
+
* there is no capability check to keep in step, and no flag day.
|
|
288
|
+
*/
|
|
289
|
+
async function requestClone(projectId, gitId, branch, opts = {}) {
|
|
290
|
+
const started = await api.post(
|
|
291
|
+
`/api/projects/${encodeURIComponent(projectId)}/clone`,
|
|
292
|
+
{ local_git_ids: [gitId], branch, async: true });
|
|
293
|
+
|
|
294
|
+
const jobId = started && (started.job_id || started.jobId);
|
|
295
|
+
if (!jobId) {
|
|
296
|
+
return started; // older server: it did the work inline and this IS the outcome
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Poll until it finishes. The interval is deliberately not tight: nothing is waiting on the answer
|
|
300
|
+
// faster than a person reads, and a shorter one only adds load to the server doing the copy.
|
|
301
|
+
// A seam, not a knob: tests pass a small value so they do not sit through real polls.
|
|
302
|
+
const POLL_MS = opts.pollMs || 2000;
|
|
303
|
+
const GIVE_UP_MS = opts.giveUpMs || 15 * 60 * 1000;
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
const deadline = startedAt + GIVE_UP_MS;
|
|
306
|
+
const sp = spinner.start("Building your temporary project...");
|
|
307
|
+
try {
|
|
308
|
+
for (;;) {
|
|
309
|
+
await sleep(POLL_MS);
|
|
310
|
+
let job;
|
|
311
|
+
try {
|
|
312
|
+
job = await api.get(`/api/projects/clone/job/${encodeURIComponent(jobId)}`);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
// A blip while polling is not a failed clone. The copy is running on the server regardless of
|
|
315
|
+
// whether this one request reached it, so keep waiting rather than reporting a failure that
|
|
316
|
+
// has not happened. A 404 is different: the job is gone, and waiting cannot bring it back.
|
|
317
|
+
// Waiting cannot fix an expired token or a revoked account, and 15 minutes of silent retrying
|
|
318
|
+
// before finally reporting it is worse than saying so at once.
|
|
319
|
+
if (e && (e.status === 401 || e.status === 403)) {
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
if (e && e.status === 404) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`The server has no record of clone job ${jobId}. It may have expired, or the server was `
|
|
325
|
+
+ "restarted mid-copy. Run `niro new-temp-project` again.");
|
|
326
|
+
}
|
|
327
|
+
if (Date.now() > deadline) throw e;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const state = job && job.state;
|
|
331
|
+
if (state === "SUCCEEDED") {
|
|
332
|
+
sp.stop();
|
|
333
|
+
return job.outcome;
|
|
334
|
+
}
|
|
335
|
+
if (state === "FAILED") {
|
|
336
|
+
sp.stop();
|
|
337
|
+
throw new Error(job.error || "The server could not create the temporary project.");
|
|
338
|
+
}
|
|
339
|
+
if (Date.now() > deadline) {
|
|
340
|
+
sp.stop();
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Still building after ${Math.round(GIVE_UP_MS / 60000)} minutes. The server may still finish; `
|
|
343
|
+
+ `check with \`niro status\` before running this again.`);
|
|
344
|
+
}
|
|
345
|
+
// Deliberately NOT re-labelled with the elapsed time. Without a TTY the spinner prints a fresh
|
|
346
|
+
// line every time the label CHANGES, so putting the seconds in it produced one line per poll:
|
|
347
|
+
// measured at 10 lines in 11 seconds. The spinner already shows elapsed time where it can.
|
|
348
|
+
}
|
|
349
|
+
} finally {
|
|
350
|
+
sp.stop();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sleep(ms) {
|
|
355
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = { takeover, requestClone};
|
package/src/commands/watch.js
CHANGED
|
@@ -157,7 +157,7 @@ async function watch(pathArg, opts = {}) {
|
|
|
157
157
|
const resolved = path.resolve(entry.folderPath);
|
|
158
158
|
if (!watched.has(resolved)) {
|
|
159
159
|
watched.add(resolved);
|
|
160
|
-
console.log(`
|
|
160
|
+
console.log(` ${color.green("↻")} Now watching new folder: ${entry.folderPath}`);
|
|
161
161
|
startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
|
|
162
162
|
}
|
|
163
163
|
}
|
|
@@ -173,9 +173,9 @@ async function watch(pathArg, opts = {}) {
|
|
|
173
173
|
fs.watch(stateDir, { persistent: false }, (_evt, filename) => {
|
|
174
174
|
if (!filename || filename === stateName) reconcile();
|
|
175
175
|
}).on("error", (err) =>
|
|
176
|
-
console.error(color.yellow(`
|
|
176
|
+
console.error(color.yellow(` State-file watcher error (recovered): ${err.message}`)));
|
|
177
177
|
} catch (err) {
|
|
178
|
-
console.error(color.yellow(`
|
|
178
|
+
console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
|
|
179
179
|
}
|
|
180
180
|
// Keep process alive
|
|
181
181
|
await new Promise(() => {});
|
|
@@ -203,7 +203,7 @@ async function handleEntryFailure(entry, err, watched) {
|
|
|
203
203
|
const removed = watchState.remove(entry.folderPath, entry.gitId);
|
|
204
204
|
if (!removed) {
|
|
205
205
|
console.error(color.yellow(
|
|
206
|
-
`
|
|
206
|
+
` Self-heal skipped for ${entry.folderPath}: it was re-pointed to a new repo mid-check — kept.`));
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
// CRITICAL: also drop the folder from the daemon's live `watched` set. Otherwise, when the same
|
|
@@ -212,7 +212,7 @@ async function handleEntryFailure(entry, err, watched) {
|
|
|
212
212
|
// silently stop syncing until a full daemon restart. Pruning here lets the reconcile re-adopt it.
|
|
213
213
|
if (watched) watched.delete(path.resolve(entry.folderPath));
|
|
214
214
|
console.error(color.yellow(
|
|
215
|
-
`
|
|
215
|
+
` Self-healed: ${entry.folderPath} pointed at a repo that no longer exists on this ` +
|
|
216
216
|
`backend (${entry.gitId}) — entry removed; other folders unaffected.`));
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
@@ -238,8 +238,8 @@ async function startWatch(targetDir, opts = {}) {
|
|
|
238
238
|
|
|
239
239
|
const { gitId } = state;
|
|
240
240
|
|
|
241
|
-
console.log(`\n
|
|
242
|
-
console.log(`
|
|
241
|
+
console.log(`\n ${color.cyan("Watching")} ${targetDir}`);
|
|
242
|
+
console.log(` gitId: ${color.dim(gitId)}`);
|
|
243
243
|
|
|
244
244
|
// Delta sync on startup. A repo-not-found error must still propagate so the caller's handleEntryFailure
|
|
245
245
|
// can self-heal a poison entry. But a TRANSIENT failure (network blip at daemon start) must NOT prevent
|
|
@@ -251,7 +251,7 @@ async function startWatch(targetDir, opts = {}) {
|
|
|
251
251
|
} catch (err) {
|
|
252
252
|
if (agentApi.isRepoNotFound(err)) throw err; // poison entry — let handleEntryFailure remove it
|
|
253
253
|
console.error(color.yellow(
|
|
254
|
-
`
|
|
254
|
+
` Initial sync failed for ${targetDir} (${err.message}) — watching anyway; the backstop will retry.`));
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// Start heartbeat. Re-read fresh state each tick exactly like the backstop below: a `niro
|
|
@@ -294,16 +294,16 @@ async function startWatch(targetDir, opts = {}) {
|
|
|
294
294
|
// A rejection here would be an unhandled promise rejection = process death in
|
|
295
295
|
// modern Node. One file's failure must never take the daemon down.
|
|
296
296
|
handleFileEvent(gitId, targetDir, relativePath, state).catch(err =>
|
|
297
|
-
console.error(color.red(`
|
|
297
|
+
console.error(color.red(` Watch event failed for ${relativePath}: ${err.message}`)));
|
|
298
298
|
}, DEBOUNCE_MS));
|
|
299
299
|
});
|
|
300
300
|
|
|
301
301
|
// fs.watch emits 'error' events (EMFILE, watched dir removed, …); with no listener
|
|
302
302
|
// an EventEmitter 'error' THROWS and kills the process.
|
|
303
303
|
watcher.on("error", (err) =>
|
|
304
|
-
console.error(color.red(`
|
|
304
|
+
console.error(color.red(` Watcher error for ${targetDir}: ${err.message}`)));
|
|
305
305
|
|
|
306
|
-
console.log(`
|
|
306
|
+
console.log(` ${color.green("✓")} Watching for changes. Press Ctrl+C to stop.\n`);
|
|
307
307
|
|
|
308
308
|
// Graceful shutdown
|
|
309
309
|
process.on("SIGINT", () => {
|
|
@@ -311,7 +311,7 @@ async function startWatch(targetDir, opts = {}) {
|
|
|
311
311
|
clearInterval(rescanTimer);
|
|
312
312
|
watcher.close();
|
|
313
313
|
for (const t of debounceTimers.values()) clearTimeout(t);
|
|
314
|
-
console.log("\n
|
|
314
|
+
console.log("\n Watch stopped.");
|
|
315
315
|
process.exit(0);
|
|
316
316
|
});
|
|
317
317
|
|
|
@@ -366,7 +366,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
366
366
|
return { notHolder: true };
|
|
367
367
|
}
|
|
368
368
|
if (notHolding.delete(inFlightKey)) {
|
|
369
|
-
console.log(color.green(`
|
|
369
|
+
console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
const storedHashes = state.fileHashes || {};
|
|
@@ -387,14 +387,14 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
387
387
|
if (!unreadableRoots.has(path.resolve(targetDir))) {
|
|
388
388
|
unreadableRoots.add(path.resolve(targetDir));
|
|
389
389
|
console.error(color.red(
|
|
390
|
-
`
|
|
390
|
+
` Cannot read ${targetDir} — skipping this sync entirely rather than reporting every file ` +
|
|
391
391
|
`deleted. Check the folder still exists and is readable. ` +
|
|
392
392
|
`This will not be repeated until it can be read again.`));
|
|
393
393
|
}
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
396
|
if (unreadableRoots.delete(path.resolve(targetDir))) {
|
|
397
|
-
console.log(color.green(`
|
|
397
|
+
console.log(color.green(` ${targetDir} is readable again — syncing resumed.`));
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
const unreadable = [];
|
|
@@ -423,7 +423,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
423
423
|
|
|
424
424
|
if (unreadable.length > 0) {
|
|
425
425
|
console.warn(color.yellow(
|
|
426
|
-
`
|
|
426
|
+
` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
|
|
427
427
|
`reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
|
|
428
428
|
}
|
|
429
429
|
|
|
@@ -446,13 +446,13 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
446
446
|
|
|
447
447
|
if (unreadableDirs.length > 0) {
|
|
448
448
|
console.warn(color.yellow(
|
|
449
|
-
`
|
|
449
|
+
` ${failedDirs.length} directory(ies) could not be read this pass, so ${unreadableDirs.length} ` +
|
|
450
450
|
`file(s) under them are being left alone rather than reported deleted. ` +
|
|
451
451
|
`Sample: ${failedDirs.slice(0, 3).join(", ")}`));
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
if (changed.length === 0 && deleted.length === 0) {
|
|
455
|
-
if (!quiet) console.log(`
|
|
455
|
+
if (!quiet) console.log(` No changes since last sync.`);
|
|
456
456
|
watchState.mergeFileHashes(targetDir, { set: currentHashes, expectedGitId: gitId });
|
|
457
457
|
// Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
|
|
458
458
|
// This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
|
|
@@ -461,7 +461,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
// A quiet backstop run only speaks up when it actually found something fs.watch missed.
|
|
464
|
-
console.log(`
|
|
464
|
+
console.log(` ${quiet ? color.yellow("↻ backstop: ") : ""}Delta: ${changed.length} changed, ${deleted.length} deleted — syncing...`);
|
|
465
465
|
|
|
466
466
|
// Honor .gitignore (+ fail-closed-for-config) on the changed set BEFORE upload,
|
|
467
467
|
// using the same helper as init/add-repo. A gitignored secret edited after
|
|
@@ -525,12 +525,12 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
525
525
|
// only never throws, so without this the caller saw a clean resolve while every single delete was
|
|
526
526
|
// being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
|
|
527
527
|
failures++;
|
|
528
|
-
console.warn(color.yellow(`
|
|
528
|
+
console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
|
|
529
529
|
}
|
|
530
530
|
}
|
|
531
531
|
if (reappeared.length > 0) {
|
|
532
532
|
console.warn(color.yellow(
|
|
533
|
-
`
|
|
533
|
+
` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
|
|
534
534
|
`Sample: ${reappeared.slice(0, 5).join(", ")}`));
|
|
535
535
|
// Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
|
|
536
536
|
// (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
|
|
@@ -550,9 +550,9 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
550
550
|
patch: { lastSyncedAt: new Date().toISOString() },
|
|
551
551
|
expectedGitId: gitId,
|
|
552
552
|
})) {
|
|
553
|
-
console.log(color.dim("
|
|
553
|
+
console.log(color.dim(" Folder was re-pointed during this sync — its results were not recorded."));
|
|
554
554
|
}
|
|
555
|
-
console.log(`
|
|
555
|
+
console.log(` ${color.green("✓")} Delta sync complete.`);
|
|
556
556
|
return { failures };
|
|
557
557
|
} finally {
|
|
558
558
|
syncInFlight.delete(inFlightKey);
|
|
@@ -573,7 +573,7 @@ function queueDelete(targetDir, relativePath, settleMs = DELETE_SETTLE_MS) {
|
|
|
573
573
|
if (entry.timer) clearTimeout(entry.timer);
|
|
574
574
|
entry.timer = setTimeout(() => {
|
|
575
575
|
flushPendingDeletes(targetDir).catch(err =>
|
|
576
|
-
console.error(color.red(`
|
|
576
|
+
console.error(color.red(` Delete flush failed for ${targetDir}: ${err.message}`)));
|
|
577
577
|
}, settleMs);
|
|
578
578
|
if (entry.timer.unref) entry.timer.unref(); // must never hold the daemon open
|
|
579
579
|
}
|
|
@@ -609,7 +609,7 @@ async function flushPendingDeletes(targetDir) {
|
|
|
609
609
|
|
|
610
610
|
if (returned.length > 0) {
|
|
611
611
|
console.log(color.dim(
|
|
612
|
-
`
|
|
612
|
+
` ${returned.length} file(s) reappeared while settling — not deleted` +
|
|
613
613
|
(returned.length <= 3 ? `: ${returned.join(", ")}` : "")));
|
|
614
614
|
}
|
|
615
615
|
|
|
@@ -621,11 +621,11 @@ async function flushPendingDeletes(targetDir) {
|
|
|
621
621
|
patch: { lastSyncedAt: new Date().toISOString() },
|
|
622
622
|
expectedGitId: fresh.gitId,
|
|
623
623
|
});
|
|
624
|
-
console.log(`
|
|
624
|
+
console.log(` ${color.yellow("−")} ${rp}`);
|
|
625
625
|
} catch (err) {
|
|
626
626
|
// Keep the baseline entry so a later pass retries. Dropping it here is how a refused delete
|
|
627
627
|
// became permanent (see fb3ff0a).
|
|
628
|
-
console.error(color.red(`
|
|
628
|
+
console.error(color.red(` Failed to delete ${rp}: ${err.message}`));
|
|
629
629
|
}
|
|
630
630
|
}
|
|
631
631
|
}
|
|
@@ -661,18 +661,18 @@ function backstopTick(targetDir) {
|
|
|
661
661
|
if (result.failures > 0) {
|
|
662
662
|
const delayMs = recordBackstopFailure(backoffKey, Date.now());
|
|
663
663
|
console.error(color.red(
|
|
664
|
-
`
|
|
664
|
+
` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
|
|
665
665
|
`${Math.round(delayMs / 60_000)} min.`));
|
|
666
666
|
return;
|
|
667
667
|
}
|
|
668
668
|
if (recordBackstopSuccess(backoffKey)) {
|
|
669
|
-
console.log(color.green(`
|
|
669
|
+
console.log(color.green(` Sync recovered for ${targetDir}.`));
|
|
670
670
|
}
|
|
671
671
|
})
|
|
672
672
|
.catch(err => {
|
|
673
673
|
const delayMs = recordBackstopFailure(backoffKey, Date.now());
|
|
674
674
|
console.error(color.red(
|
|
675
|
-
`
|
|
675
|
+
` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
|
|
676
676
|
`${Math.round(delayMs / 60_000)} min.`));
|
|
677
677
|
});
|
|
678
678
|
}
|
|
@@ -714,19 +714,19 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
|
714
714
|
// A daemon has no TTY — never block on (or crash in) a prompt. Leave the
|
|
715
715
|
// directory unsynced and tell the user how to include it.
|
|
716
716
|
console.log(color.yellow(
|
|
717
|
-
`
|
|
717
|
+
` New directory '${relativePath}' detected — not synced. ` +
|
|
718
718
|
`Run \`niro watch\` interactively (or \`niro edit\`) to include it.`));
|
|
719
719
|
return;
|
|
720
720
|
}
|
|
721
721
|
const add = await prompt.confirm(
|
|
722
|
-
`\n
|
|
722
|
+
`\n New directory '${relativePath}' detected. Add to sync?`,
|
|
723
723
|
false
|
|
724
724
|
);
|
|
725
725
|
if (add) {
|
|
726
726
|
const newPattern = relativePath + "/**";
|
|
727
727
|
state.includePatterns.push(newPattern);
|
|
728
728
|
watchState.update(targetDir, { includePatterns: state.includePatterns });
|
|
729
|
-
console.log(`
|
|
729
|
+
console.log(` Added include pattern: ${color.cyan(newPattern)}`);
|
|
730
730
|
} else {
|
|
731
731
|
state.excludePatterns.push(relativePath + "/**");
|
|
732
732
|
watchState.update(targetDir, { excludePatterns: state.excludePatterns });
|
|
@@ -784,9 +784,9 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
|
784
784
|
patch: { lastSyncedAt: new Date().toISOString() },
|
|
785
785
|
expectedGitId: gitId,
|
|
786
786
|
});
|
|
787
|
-
console.log(`
|
|
787
|
+
console.log(` ${color.green("+")} ${relativePath}`);
|
|
788
788
|
} catch (err) {
|
|
789
|
-
console.error(color.red(`
|
|
789
|
+
console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
|
|
790
790
|
}
|
|
791
791
|
}
|
|
792
792
|
|
package/src/index.js
CHANGED
|
@@ -31,13 +31,21 @@ function main() {
|
|
|
31
31
|
if (!program.opts().apiUrl) return;
|
|
32
32
|
if (actionCommand.name() === "login") return;
|
|
33
33
|
console.error("`--api-url` only applies to `niro login`.");
|
|
34
|
-
console.error("
|
|
35
|
-
console.error("
|
|
34
|
+
console.error(" It was ignored by the upload path, so using it here would read from one");
|
|
35
|
+
console.error(" backend and write to another. Pick the backend once instead:");
|
|
36
36
|
console.error(" niro login --api-url <url> log in to that backend");
|
|
37
37
|
console.error(" NIRO_API_URL=<url> niro <cmd> or set it for every command");
|
|
38
38
|
process.exit(1);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
program
|
|
42
|
+
.command("start [path]")
|
|
43
|
+
.description("First-time setup, end to end: sign in, onboard this folder, connect your AI assistant.")
|
|
44
|
+
.action((pathArg) => run(async () => {
|
|
45
|
+
const startCmd = require("./commands/start");
|
|
46
|
+
await startCmd.start(pathArg, {});
|
|
47
|
+
}));
|
|
48
|
+
|
|
41
49
|
program
|
|
42
50
|
.command("login")
|
|
43
51
|
.description("Log in to Niro. Default: device authorization in your browser. (--api-key or --password for alternatives.)")
|
|
@@ -134,6 +142,44 @@ function main() {
|
|
|
134
142
|
await cmd.addProject({ ...opts });
|
|
135
143
|
}));
|
|
136
144
|
|
|
145
|
+
// `niro ab` subcommand group
|
|
146
|
+
const ab = program
|
|
147
|
+
.command("ab")
|
|
148
|
+
.description("Measure what Niro changes: run it off for half your sessions, then compare.");
|
|
149
|
+
|
|
150
|
+
ab
|
|
151
|
+
.command("status", { isDefault: true })
|
|
152
|
+
.description("Is a measurement window open, and until when?")
|
|
153
|
+
.action(() => run(async () => {
|
|
154
|
+
await require("./commands/ab").status();
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
ab
|
|
158
|
+
.command("enable")
|
|
159
|
+
.description("Start a measurement window. Account-wide; asks first.")
|
|
160
|
+
.option("--days <n>", "window length in days (default: 14)")
|
|
161
|
+
.option("--name <name>", "label for this run")
|
|
162
|
+
.option("-y, --yes", "skip the confirmation (for scripts)")
|
|
163
|
+
.action((opts) => run(async () => {
|
|
164
|
+
await require("./commands/ab").enable({ ...opts });
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
ab
|
|
168
|
+
.command("disable")
|
|
169
|
+
.description("Stop the current window early. Every session uses Niro again.")
|
|
170
|
+
.action(() => run(async () => {
|
|
171
|
+
await require("./commands/ab").disable();
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
ab
|
|
175
|
+
.command("report")
|
|
176
|
+
.description("Show the with-Niro and without-Niro comparison.")
|
|
177
|
+
.option("--run-id <id>", "a specific past run (default: the latest)")
|
|
178
|
+
.option("--json", "emit machine-readable JSON")
|
|
179
|
+
.action((opts) => run(async () => {
|
|
180
|
+
await require("./commands/ab").report({ ...opts });
|
|
181
|
+
}));
|
|
182
|
+
|
|
137
183
|
program
|
|
138
184
|
.command("info [path]")
|
|
139
185
|
.description("Show what Niro knows about this folder: git info + which project(s) it maps to and their status.")
|
|
@@ -300,6 +346,14 @@ function main() {
|
|
|
300
346
|
.option("--off", "Stop showing the end-of-task line.")
|
|
301
347
|
.action((opts) => { require("./commands/mcp").receipt(opts); });
|
|
302
348
|
|
|
349
|
+
// Bare `niro` used to print an 18-command help listing, which is the worst possible
|
|
350
|
+
// first screen for someone who does not yet know what any of them do. With no stored
|
|
351
|
+
// credentials that person is, by definition, new: send them to the wizard instead.
|
|
352
|
+
// Once they have credentials the help listing is what they asked for, so it stays.
|
|
353
|
+
if (process.argv.length <= 2 && !require("./commands/start").hasCredentials()) {
|
|
354
|
+
process.argv.push("start");
|
|
355
|
+
}
|
|
356
|
+
|
|
303
357
|
program.parseAsync(process.argv).catch((err) => {
|
|
304
358
|
console.error(err && err.message ? err.message : err);
|
|
305
359
|
process.exit(1);
|
|
@@ -319,7 +373,7 @@ function run(fn) {
|
|
|
319
373
|
process.exit(1);
|
|
320
374
|
}
|
|
321
375
|
console.error(err && err.stack ? err.stack.split("\n")[0] : String(err));
|
|
322
|
-
if (err && err.status) console.error(`
|
|
376
|
+
if (err && err.status) console.error(` (HTTP ${err.status})`);
|
|
323
377
|
process.exit(1);
|
|
324
378
|
});
|
|
325
379
|
}
|
package/src/lib/folderSync.js
CHANGED
|
@@ -165,6 +165,11 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
165
165
|
* candidate that git ignores. Pass { honorGitignore: false } to skip the git
|
|
166
166
|
* pass (used by tests that assert the raw walk).
|
|
167
167
|
*/
|
|
168
|
+
/** Current nesting depth as spaces. \r-repainted lines bypass withIndent's console wrappers. */
|
|
169
|
+
function nestPad() {
|
|
170
|
+
return " ".repeat(require("./nestedOutput").currentIndent());
|
|
171
|
+
}
|
|
172
|
+
|
|
168
173
|
function buildManifest(rootDir, opts = {}) {
|
|
169
174
|
const manifest = [];
|
|
170
175
|
walkDir(rootDir, rootDir, manifest);
|
|
@@ -244,7 +249,7 @@ function gitignoreDrops(rootDir, relPaths) {
|
|
|
244
249
|
const { ok, ignored } = git.checkIgnored(repoRoot, repoRelPaths);
|
|
245
250
|
if (!ok) {
|
|
246
251
|
console.log(color.yellow(
|
|
247
|
-
"
|
|
252
|
+
" ⚠ WARNING: could not run `git check-ignore`, so .gitignore can't be " +
|
|
248
253
|
"honored — dropping config/env files from this upload to avoid leaking " +
|
|
249
254
|
"secrets (source files are still uploaded)."
|
|
250
255
|
));
|
|
@@ -358,10 +363,10 @@ async function aggregateGuard(targetDir, includedPaths, sizes) {
|
|
|
358
363
|
const msg = `Large upload — ${reasons.join("; ")}.`;
|
|
359
364
|
|
|
360
365
|
if (prompt.isNonInteractive()) {
|
|
361
|
-
console.log(color.yellow(`
|
|
366
|
+
console.log(color.yellow(` ⚠ WARNING: ${msg} Proceeding (non-interactive).`));
|
|
362
367
|
return;
|
|
363
368
|
}
|
|
364
|
-
const proceed = await prompt.confirm(`
|
|
369
|
+
const proceed = await prompt.confirm(` ⚠ ${msg} Continue?`, false);
|
|
365
370
|
if (!proceed) {
|
|
366
371
|
throw new Error("Upload cancelled by user.");
|
|
367
372
|
}
|
|
@@ -404,14 +409,14 @@ async function uploadBatchWithRetry(gitId, sessionId, fileBatch) {
|
|
|
404
409
|
*/
|
|
405
410
|
async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
|
|
406
411
|
if (!includedPaths || includedPaths.length === 0) {
|
|
407
|
-
console.log(`
|
|
412
|
+
console.log(` ${color.yellow("No files to sync.")} Check your include/exclude patterns.`);
|
|
408
413
|
return;
|
|
409
414
|
}
|
|
410
415
|
|
|
411
416
|
await aggregateGuard(targetDir, includedPaths, sizes);
|
|
412
417
|
|
|
413
418
|
const total = includedPaths.length;
|
|
414
|
-
console.log(`\n
|
|
419
|
+
console.log(`\n Starting initial sync (${total} files)...`);
|
|
415
420
|
const sessionId = await agentApi.startSync(gitId);
|
|
416
421
|
|
|
417
422
|
const batches = chunk(includedPaths, BATCH_SIZE);
|
|
@@ -442,7 +447,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
|
|
|
442
447
|
uploaded += fileBatch.length;
|
|
443
448
|
// Denominator excludes files that disappeared, so progress can still reach 100%.
|
|
444
449
|
const pct = Math.round((uploaded / Math.max(1, total - dropped)) * 100);
|
|
445
|
-
process.stdout.write(`\r
|
|
450
|
+
process.stdout.write(`\r${nestPad()} Uploading... ${pct}% (${uploaded}/${total - dropped})`);
|
|
446
451
|
}
|
|
447
452
|
}
|
|
448
453
|
|
|
@@ -454,7 +459,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
|
|
|
454
459
|
|
|
455
460
|
// Never drop files silently — surface the count (no names/content).
|
|
456
461
|
if (!firstError && dropped > 0) {
|
|
457
|
-
console.log(color.yellow(`
|
|
462
|
+
console.log(color.yellow(` ⚠ ${dropped} file(s) disappeared during upload and were skipped.`));
|
|
458
463
|
}
|
|
459
464
|
|
|
460
465
|
if (firstError) {
|
|
@@ -476,7 +481,7 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
|
|
|
476
481
|
}
|
|
477
482
|
watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
|
|
478
483
|
|
|
479
|
-
console.log(`
|
|
484
|
+
console.log(` ${color.green("✓")} Initial sync complete.`);
|
|
480
485
|
// Return the computed hashes so a caller that registers its watchState entry AFTER the upload (takeover)
|
|
481
486
|
// can seed fileHashes directly — watchState.update above no-ops when no entry exists yet, so without this
|
|
482
487
|
// the daemon would re-upload the whole tree on its first pass.
|