@niroai/niro 0.9.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 +69 -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 +7 -7
- package/src/commands/remove.js +5 -5
- package/src/commands/start.js +567 -0
- package/src/commands/takeover.js +19 -19
- package/src/commands/watch.js +35 -35
- package/src/index.js +57 -3
- package/src/lib/folderSync.js +13 -8
- 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/watchDaemon.js +9 -9
- package/src/mcp/setup.js +198 -160
- package/src/mcp/teardown.js +53 -53
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.
|
|
@@ -23,7 +23,7 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
|
|
|
23
23
|
}
|
|
24
24
|
if (!Array.isArray(suggestions) || suggestions.length === 0) return;
|
|
25
25
|
|
|
26
|
-
console.log(`\n
|
|
26
|
+
console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
|
|
27
27
|
const accepted = [];
|
|
28
28
|
for (const s of suggestions) {
|
|
29
29
|
const count = s.module_count || s.moduleCount || 0;
|
|
@@ -33,24 +33,24 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
|
|
|
33
33
|
` ${color.dim(s.pattern.padEnd(28))} ${String(count).padStart(3)} module${count === 1 ? "" : "s"} ${color.dim(label)}`
|
|
34
34
|
);
|
|
35
35
|
if (samples.length > 0) {
|
|
36
|
-
console.log(`
|
|
36
|
+
console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
|
|
37
37
|
}
|
|
38
38
|
const yes = await prompt.confirm(` Apply ${color.cyan(s.pattern)}?`, true);
|
|
39
39
|
if (yes) accepted.push(s.pattern);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (accepted.length === 0) {
|
|
43
|
-
console.log(`
|
|
43
|
+
console.log(` ${color.dim("No exclusions applied.")}`);
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
48
|
await agentApi.updateModuleExcludePatterns(gitId, accepted);
|
|
49
49
|
console.log(
|
|
50
|
-
`
|
|
50
|
+
` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
|
|
51
51
|
);
|
|
52
52
|
} catch (err) {
|
|
53
|
-
console.log(`
|
|
53
|
+
console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run something and shift everything it prints one rung deeper.
|
|
3
|
+
*
|
|
4
|
+
* `niro start` is a shell around commands that also run on their own. `niro init` printing at
|
|
5
|
+
* the top level is right when you typed `niro init`; inside the wizard the same lines belong
|
|
6
|
+
* UNDER "4/5 Onboarding and building the index", and printing them flush with the step markers
|
|
7
|
+
* is what made step 4 look like a different program had taken over.
|
|
8
|
+
*
|
|
9
|
+
* Wrapping the call is the only approach that does not require every helper it reaches
|
|
10
|
+
* (folderSync, onboardTarget, moduleExcludeSuggestions, watchDaemon, service) to know how deep
|
|
11
|
+
* it happens to be running. They keep printing at their own level; this adds the offset.
|
|
12
|
+
*
|
|
13
|
+
* Blank lines are left blank rather than padded with spaces, so nothing trails whitespace.
|
|
14
|
+
* process.stdout.write is untouched: the upload progress bar repaints one line with \r, and
|
|
15
|
+
* prefixing every repaint would smear it across the terminal.
|
|
16
|
+
*/
|
|
17
|
+
let current = 0;
|
|
18
|
+
|
|
19
|
+
/** How deep the output is nested right now. prompt.js prefixes its questions with this. */
|
|
20
|
+
function currentIndent() {
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function withIndent(spaces, fn) {
|
|
25
|
+
const pad = " ".repeat(spaces);
|
|
26
|
+
const shift = (orig) => (...args) => {
|
|
27
|
+
const text = args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
|
|
28
|
+
// A line with nothing but spaces is emitted empty. Some callers print a whitespace-only
|
|
29
|
+
// string as a spacer, and padding it left a visible run of trailing whitespace.
|
|
30
|
+
orig(text.split("\n").map((l) => (l.trim() ? pad + l : "")).join("\n"));
|
|
31
|
+
};
|
|
32
|
+
const log = console.log;
|
|
33
|
+
const error = console.error;
|
|
34
|
+
const warn = console.warn;
|
|
35
|
+
current += spaces;
|
|
36
|
+
console.log = shift(log);
|
|
37
|
+
console.error = shift(error);
|
|
38
|
+
console.warn = shift(warn);
|
|
39
|
+
return Promise.resolve()
|
|
40
|
+
.then(fn)
|
|
41
|
+
.finally(() => {
|
|
42
|
+
current -= spaces;
|
|
43
|
+
console.log = log;
|
|
44
|
+
console.error = error;
|
|
45
|
+
console.warn = warn;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { withIndent, currentIndent };
|
package/src/lib/onboardTarget.js
CHANGED
|
@@ -208,7 +208,7 @@ function ensureOnboardable(targetDir, opts = {}) {
|
|
|
208
208
|
|
|
209
209
|
const reason = protectedRootReason(resolved);
|
|
210
210
|
if (reason) {
|
|
211
|
-
console.error(color.red(`\n
|
|
211
|
+
console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
|
|
212
212
|
console.error(" That would upload source from every project underneath it, pin this folder");
|
|
213
213
|
console.error(" with a .niro-project file, and watch the whole tree for changes.");
|
|
214
214
|
console.error(` Change into the project folder you meant, then run the command there.`);
|
|
@@ -223,17 +223,17 @@ function ensureOnboardable(targetDir, opts = {}) {
|
|
|
223
223
|
// The protected-root rule above still applied, and still refuses.
|
|
224
224
|
if (opts.alreadyRegistered) {
|
|
225
225
|
console.log(color.dim(
|
|
226
|
-
`
|
|
226
|
+
` No build file in ${resolved}, but it is already registered here — continuing.`
|
|
227
227
|
));
|
|
228
228
|
return true;
|
|
229
229
|
}
|
|
230
230
|
if (opts.force) {
|
|
231
231
|
console.log(color.yellow(
|
|
232
|
-
`\n
|
|
232
|
+
`\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
|
|
233
233
|
));
|
|
234
234
|
return true;
|
|
235
235
|
}
|
|
236
|
-
console.error(color.red(`\n
|
|
236
|
+
console.error(color.red(`\n ✗ No project found in ${resolved}.`));
|
|
237
237
|
console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
|
|
238
238
|
console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
|
|
239
239
|
console.error(" Pass --force if you really mean to upload this folder.");
|
|
@@ -241,7 +241,7 @@ function ensureOnboardable(targetDir, opts = {}) {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
console.log(
|
|
244
|
-
`
|
|
244
|
+
` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
|
|
245
245
|
`(${describeIndicators(indicators)}).`
|
|
246
246
|
);
|
|
247
247
|
return true;
|
package/src/lib/prompt.js
CHANGED
|
@@ -18,6 +18,9 @@ function rl() {
|
|
|
18
18
|
* Also shared by the MCP setup/teardown wizards' own ask() helpers.
|
|
19
19
|
*/
|
|
20
20
|
function formatPrompt(question, defaultValue) {
|
|
21
|
+
// Questions asked from inside a nested step sit at that step's depth. readline writes
|
|
22
|
+
// straight to stdout, so withIndent's console wrappers never see them.
|
|
23
|
+
question = " ".repeat(require("./nestedOutput").currentIndent()) + question;
|
|
21
24
|
return defaultValue != null && defaultValue !== ""
|
|
22
25
|
? `${question} ${color.dim(`(default: ${defaultValue})`)}: `
|
|
23
26
|
: `${question}: `;
|
package/src/lib/service.js
CHANGED
|
@@ -68,26 +68,30 @@ function install() {
|
|
|
68
68
|
// Stop any existing instance so we can re-register cleanly
|
|
69
69
|
spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "pipe" });
|
|
70
70
|
|
|
71
|
-
//
|
|
71
|
+
// PM2's own output is captured, not inherited. Inheriting it put ~55 lines into the middle
|
|
72
|
+
// of setup: a box-drawing process table, the entire generated systemd unit file, and the
|
|
73
|
+
// user's PATH twice. None of it is ours and none of it is actionable while it is working.
|
|
74
|
+
// It IS printed on failure, where it is the only diagnostic there is.
|
|
72
75
|
const startResult = spawnSync(
|
|
73
76
|
"pm2",
|
|
74
77
|
["start", niroBin, "--name", PROCESS_NAME, "--", "watch"],
|
|
75
|
-
{ shell: true,
|
|
78
|
+
{ shell: true, encoding: "utf8" }
|
|
76
79
|
);
|
|
77
80
|
if (startResult.status !== 0) {
|
|
81
|
+
console.error(color.dim(`${startResult.stdout || ""}${startResult.stderr || ""}`));
|
|
78
82
|
throw new Error("pm2 start failed");
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// Persist the process list so PM2 restores it after a manual `pm2 resurrect`
|
|
82
|
-
spawnSync("pm2", ["save"], { shell: true,
|
|
86
|
+
spawnSync("pm2", ["save"], { shell: true, encoding: "utf8" });
|
|
83
87
|
|
|
84
88
|
// Wire into OS startup — pm2 startup detects systemd / launchd / etc.
|
|
85
89
|
// It prints the exact privileged command we need to run.
|
|
86
90
|
console.log(
|
|
87
|
-
`\n
|
|
91
|
+
`\n ${color.bold("Setting up auto-start on login")} — PM2 will ask for your password to register`
|
|
88
92
|
);
|
|
89
93
|
console.log(
|
|
90
|
-
`
|
|
94
|
+
` with the system startup manager (${getInitSystem()}). This only needs to happen once.\n`
|
|
91
95
|
);
|
|
92
96
|
|
|
93
97
|
const startupProbe = spawnSync("pm2", ["startup"], {
|
|
@@ -97,21 +101,25 @@ function install() {
|
|
|
97
101
|
|
|
98
102
|
const setupCmd = extractSetupCommand(startupProbe.stdout + startupProbe.stderr);
|
|
99
103
|
if (setupCmd) {
|
|
100
|
-
console.log(`
|
|
104
|
+
console.log(` Running: ${color.dim(setupCmd)}\n`);
|
|
105
|
+
// stdio is split: stdin stays connected so sudo can still prompt for the password, but
|
|
106
|
+
// PM2's forty lines of generated unit file are captured and only shown if it fails.
|
|
101
107
|
const r = spawnSync(setupCmd, {
|
|
102
108
|
shell: true,
|
|
103
|
-
stdio: "inherit",
|
|
109
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
110
|
+
encoding: "utf8",
|
|
104
111
|
});
|
|
105
112
|
if (r.status !== 0) {
|
|
113
|
+
console.error(color.dim(`${r.stdout || ""}${r.stderr || ""}`));
|
|
106
114
|
console.log(
|
|
107
115
|
color.yellow(
|
|
108
|
-
`
|
|
116
|
+
` ⚠ Startup hook failed (exit ${r.status}). You can run it manually:\n ${setupCmd}`
|
|
109
117
|
)
|
|
110
118
|
);
|
|
111
119
|
}
|
|
112
120
|
} else {
|
|
113
121
|
// PM2 may print nothing if startup is already configured
|
|
114
|
-
console.log(`
|
|
122
|
+
console.log(` ${color.green("✓")} Startup already configured.`);
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
return true;
|
|
@@ -127,7 +135,7 @@ function uninstall() {
|
|
|
127
135
|
spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
|
|
128
136
|
|
|
129
137
|
console.log(
|
|
130
|
-
`\n
|
|
138
|
+
`\n ${color.bold("Removing auto-start hook")} — PM2 may ask for your password.\n`
|
|
131
139
|
);
|
|
132
140
|
const unstartupResult = spawnSync("pm2", ["unstartup"], {
|
|
133
141
|
shell: true,
|
|
@@ -137,7 +145,7 @@ function uninstall() {
|
|
|
137
145
|
unstartupResult.stdout + unstartupResult.stderr
|
|
138
146
|
);
|
|
139
147
|
if (setupCmd) {
|
|
140
|
-
console.log(`
|
|
148
|
+
console.log(` Running: ${color.dim(setupCmd)}\n`);
|
|
141
149
|
spawnSync(setupCmd, { shell: true, stdio: "inherit" });
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -149,7 +157,7 @@ function uninstall() {
|
|
|
149
157
|
*/
|
|
150
158
|
function status() {
|
|
151
159
|
if (!pm2Available()) {
|
|
152
|
-
console.log(color.yellow("
|
|
160
|
+
console.log(color.yellow(" PM2 is not installed. Run: npm install -g pm2"));
|
|
153
161
|
return;
|
|
154
162
|
}
|
|
155
163
|
spawnSync("pm2", ["show", PROCESS_NAME], { shell: true, stdio: "inherit" });
|