@niroai/niro 0.11.0 → 0.13.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 +128 -0
- package/README.md +72 -3
- package/docs/working-on-branches.md +8 -0
- package/package.json +1 -1
- package/src/commands/_resolveProject.js +59 -3
- package/src/commands/delete.js +16 -1
- package/src/commands/mcp.js +16 -7
- package/src/commands/remove.js +13 -0
- package/src/commands/start.js +1 -0
- package/src/commands/upgrade.js +400 -0
- package/src/index.js +11 -0
- package/src/lib/folderSync.js +1 -0
- package/src/lib/impactLog.js +251 -0
- package/src/lib/mcpClients.js +241 -0
- package/src/lib/receiptStore.js +1 -1
- package/src/lib/service.js +18 -1
- package/src/mcp/server.js +30 -10
- package/src/mcp/setup.js +273 -17
- package/src/mcp/teardown.js +7 -2
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro upgrade` — replace this install with the latest published package, then put back the
|
|
3
|
+
* things replacing the package cannot put back for itself.
|
|
4
|
+
*
|
|
5
|
+
* Why this runs as two processes.
|
|
6
|
+
*
|
|
7
|
+
* Every command in this CLI is loaded lazily, inside its own action handler (src/index.js), and
|
|
8
|
+
* `npm install -g` replaces the folder those files are loaded from. A single process that ran npm
|
|
9
|
+
* and then carried on would be running the modules it loaded before the swap alongside modules
|
|
10
|
+
* loaded after it. Nothing tests that mixture. start.js has always avoided the same trap the same
|
|
11
|
+
* way: bootstrapIfNpx installs globally only when it is running from the npx cache, so it never
|
|
12
|
+
* overwrites the tree it is executing, and then it hands over.
|
|
13
|
+
*
|
|
14
|
+
* phase one, this copy run npm, then exec the NEW global binary with --finish and stop.
|
|
15
|
+
* phase two, the new copy a fresh process reading the new tree: restart the watcher, refresh
|
|
16
|
+
* each configured client's hooks and skills, ask for an assistant
|
|
17
|
+
* restart.
|
|
18
|
+
*
|
|
19
|
+
* Phase one requires nothing between the npm call and the hand-over, and it also catches its own
|
|
20
|
+
* errors rather than letting them reach src/index.js `run()`, whose catch does
|
|
21
|
+
* `require("./lib/printError")` — a lazy load out of the folder npm has just replaced. That is the
|
|
22
|
+
* one path where the swap could still bite, so it is closed here rather than described.
|
|
23
|
+
*
|
|
24
|
+
* What it deliberately does not do is write a settings file itself. Refreshing hooks means
|
|
25
|
+
* spawning the existing installer, once per client. A second write path into
|
|
26
|
+
* ~/.claude/settings.json was built once, reviewed three times and reverted on 2026-07-30
|
|
27
|
+
* (d3ff3d8) after it was found deleting users' own hooks, changing that file from mode 0600 to
|
|
28
|
+
* 0644 while it can hold API keys, and, with HOME unset, writing into a project's git-tracked
|
|
29
|
+
* .claude/settings.json. The installer already gets those cases right, and here it runs because
|
|
30
|
+
* the user asked for it.
|
|
31
|
+
*/
|
|
32
|
+
const childProcess = require("child_process");
|
|
33
|
+
const fs = require("fs");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
|
|
36
|
+
const color = require("../lib/color");
|
|
37
|
+
const service = require("../lib/service");
|
|
38
|
+
const mcpClients = require("../lib/mcpClients");
|
|
39
|
+
const mcpCmd = require("./mcp");
|
|
40
|
+
const startCmd = require("./start");
|
|
41
|
+
|
|
42
|
+
const PACKAGE_NAME = "@niroai/niro";
|
|
43
|
+
const VERSION = require("../../package.json").version;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set on the process phase one hands over to. If phase one ever sees it already set, it is itself
|
|
47
|
+
* a hand-over child that failed to reach phase two, which means the newly installed version does
|
|
48
|
+
* not understand `--finish`. Without this, that version would run npm and hand over again, without
|
|
49
|
+
* bound. start.js guards its own hand-over with BOOTSTRAP_ENV for the same reason.
|
|
50
|
+
*/
|
|
51
|
+
const HANDOVER_ENV = "NIRO_UPGRADE_HANDOVER";
|
|
52
|
+
|
|
53
|
+
/** Exit codes, so a script can tell the failures apart. */
|
|
54
|
+
const EXIT_NPM_FAILED = 1;
|
|
55
|
+
const EXIT_NO_BINARY = 2;
|
|
56
|
+
const EXIT_HANDOVER_FAILED = 3;
|
|
57
|
+
const EXIT_REFRESH_INCOMPLETE = 4;
|
|
58
|
+
const EXIT_WRONG_COPY = 5;
|
|
59
|
+
|
|
60
|
+
/** `niro upgrade` (phase one) and `niro upgrade --finish` (phase two). */
|
|
61
|
+
async function upgrade(opts = {}) {
|
|
62
|
+
if (opts.finish) return finish(opts.from);
|
|
63
|
+
return replaceAndHandOver(Boolean(opts.force));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Phase one. Replace the package, then hand over to the copy that replaced it.
|
|
68
|
+
*
|
|
69
|
+
* Everything this function touches is already loaded by the requires at the top of this file, and
|
|
70
|
+
* every failure is reported here rather than thrown, because a throw would reach a catch that
|
|
71
|
+
* lazily loads a module out of the folder npm has just swapped.
|
|
72
|
+
*/
|
|
73
|
+
function replaceAndHandOver(force) {
|
|
74
|
+
if (process.env[HANDOVER_ENV]) {
|
|
75
|
+
console.error(color.red("\n The version just installed does not support `niro upgrade --finish`."));
|
|
76
|
+
console.error(" The package itself is upgraded. Your hooks, skills and watcher are not.");
|
|
77
|
+
console.error(` Finish by hand: ${color.cyan("niro mcp install")} for each assistant you use,`);
|
|
78
|
+
console.error(` then ${color.cyan("niro service install")} if you run the background watcher.\n`);
|
|
79
|
+
process.exitCode = EXIT_HANDOVER_FAILED;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const running = describeRunningCopy();
|
|
84
|
+
if (running.kind === "linked" && !force) {
|
|
85
|
+
console.error(color.yellow("\n Not upgrading: niro is npm-linked to a working copy."));
|
|
86
|
+
console.error(` ${color.cyan("npm prefix -g")} says the global package folder is a link:`);
|
|
87
|
+
console.error(` ${running.pkgDir}`);
|
|
88
|
+
console.error(` -> ${running.linkTarget}`);
|
|
89
|
+
console.error(" `npm install -g` would replace that link with the published package, and");
|
|
90
|
+
console.error(" `niro` would stop running your checkout. Your files would be untouched, but");
|
|
91
|
+
console.error(` you would have to run ${color.cyan("npm link")} in the checkout to get it back.`);
|
|
92
|
+
console.error(` Upgrade anyway with ${color.cyan("niro upgrade --force")}.\n`);
|
|
93
|
+
process.exitCode = EXIT_WRONG_COPY;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (running.kind === "elsewhere" && !force) {
|
|
97
|
+
console.error(color.yellow("\n Not upgrading: you are not running the globally installed niro."));
|
|
98
|
+
console.error(` running: ${running.self || "unknown"}`);
|
|
99
|
+
console.error(` would upgrade: ${running.pkgDir || "the npm global folder"}`);
|
|
100
|
+
console.error(" Those are different copies, so this would replace one you are not using and");
|
|
101
|
+
console.error(" leave the one you are.");
|
|
102
|
+
console.error(` Upgrade the global copy anyway with ${color.cyan("niro upgrade --force")}.\n`);
|
|
103
|
+
process.exitCode = EXIT_WRONG_COPY;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`\n Upgrading niro. Installed now: ${VERSION}\n`);
|
|
108
|
+
|
|
109
|
+
const installed = childProcess.spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
|
|
110
|
+
shell: true,
|
|
111
|
+
stdio: "inherit",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (installed.signal) {
|
|
115
|
+
// Ctrl+C reaches the whole process group, so npm dies with this process. Whether it left the
|
|
116
|
+
// global folder half-written is npm's business and not something this can know, so it is not
|
|
117
|
+
// claimed either way.
|
|
118
|
+
console.error(color.yellow(`\n Interrupted (${installed.signal}) while npm was running.`));
|
|
119
|
+
console.error(" Whether the package was replaced is unknown. Check with `niro --version`,");
|
|
120
|
+
console.error(` then run ${color.cyan("niro upgrade")} again.\n`);
|
|
121
|
+
process.exitCode = EXIT_NPM_FAILED;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (installed.status !== 0) {
|
|
126
|
+
console.error(color.red("\n npm could not replace the package."));
|
|
127
|
+
console.error(" The usual cause is that your npm global folder needs root. Find it with");
|
|
128
|
+
console.error(` ${color.cyan("npm prefix -g")}, then either fix its ownership or re-run the install`);
|
|
129
|
+
console.error(" with the privileges it needs:");
|
|
130
|
+
console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}`);
|
|
131
|
+
console.error(` Then run ${color.cyan("niro upgrade --finish")} to refresh your assistant files.\n`);
|
|
132
|
+
process.exitCode = EXIT_NPM_FAILED;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const bin = startCmd.globalNiroPath();
|
|
137
|
+
if (!bin) {
|
|
138
|
+
console.error(color.red("\n npm reported success, but there is no niro in your npm global folder."));
|
|
139
|
+
console.error(` Check ${color.cyan("npm prefix -g")} and that its bin folder is on your PATH.`);
|
|
140
|
+
console.error(" The package is upgraded; only the refresh step was skipped. Run it with");
|
|
141
|
+
console.error(` ${color.cyan("niro upgrade --finish")} once niro is back on PATH.\n`);
|
|
142
|
+
process.exitCode = EXIT_NO_BINARY;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!supportsFinish(bin)) {
|
|
147
|
+
// The version just installed has no `upgrade` command, which is what happens when the newest
|
|
148
|
+
// published version predates this one. Saying "re-run niro upgrade --finish" here would send
|
|
149
|
+
// the user to a command that cannot exist.
|
|
150
|
+
console.error(color.yellow("\n The package is upgraded, but the version now installed has no"));
|
|
151
|
+
console.error(" `niro upgrade` command, so it cannot refresh your assistant files itself.");
|
|
152
|
+
console.error(" Finish by hand:");
|
|
153
|
+
console.error(` ${color.cyan("niro mcp install")} once per assistant you use`);
|
|
154
|
+
console.error(` ${color.cyan("niro service install")} if you run the background watcher`);
|
|
155
|
+
console.error(" Then restart your assistant.\n");
|
|
156
|
+
process.exitCode = EXIT_HANDOVER_FAILED;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The hand-over. A fresh process reads the new tree cleanly; this one cannot. VERSION comes from
|
|
161
|
+
// package.json, so it is a semver string and safe on a Windows command line. The Windows branch
|
|
162
|
+
// is untested: spawning a .cmd shim needs a shell on current Node, and a global prefix
|
|
163
|
+
// containing spaces is why the path is quoted.
|
|
164
|
+
const env = { ...process.env, [HANDOVER_ENV]: "1" };
|
|
165
|
+
const handover = process.platform === "win32"
|
|
166
|
+
? childProcess.spawnSync(`"${bin}" upgrade --finish --from ${VERSION}`, { stdio: "inherit", shell: true, env })
|
|
167
|
+
: childProcess.spawnSync(bin, ["upgrade", "--finish", "--from", VERSION], { stdio: "inherit", env });
|
|
168
|
+
|
|
169
|
+
if (handover.error) {
|
|
170
|
+
// spawnSync could not start the child at all: a non-executable bin link, a directory where the
|
|
171
|
+
// binary should be, a broken shim. Without this the command exits silently.
|
|
172
|
+
console.error(color.red(`\n Could not run the upgraded niro at ${bin}:`));
|
|
173
|
+
console.error(` ${handover.error.message}`);
|
|
174
|
+
console.error(" The package is upgraded; your hooks, skills and watcher are not.");
|
|
175
|
+
console.error(` Try ${color.cyan("niro upgrade --finish")}, or reinstall with`);
|
|
176
|
+
console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}.\n`);
|
|
177
|
+
process.exitCode = EXIT_HANDOVER_FAILED;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (handover.signal) {
|
|
182
|
+
console.error(color.yellow(`\n Interrupted (${handover.signal}) while refreshing.`));
|
|
183
|
+
console.error(` The package is upgraded. Finish with ${color.cyan("niro upgrade --finish")}.\n`);
|
|
184
|
+
process.exitCode = EXIT_HANDOVER_FAILED;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (handover.status !== 0) {
|
|
189
|
+
console.error(`\n The upgraded niro exited with ${handover.status} while refreshing.`);
|
|
190
|
+
console.error(` The package is upgraded. Re-run ${color.cyan("niro upgrade --finish")} to try again.\n`);
|
|
191
|
+
}
|
|
192
|
+
process.exitCode = handover.status === null ? EXIT_HANDOVER_FAILED : handover.status;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Where npm would install this package globally. */
|
|
196
|
+
function globalPackageDir() {
|
|
197
|
+
const r = childProcess.spawnSync("npm", ["prefix", "-g"], { shell: true, encoding: "utf8" });
|
|
198
|
+
if (r.status !== 0) return null;
|
|
199
|
+
const prefix = String(r.stdout || "").trim();
|
|
200
|
+
if (!prefix) return null;
|
|
201
|
+
return process.platform === "win32"
|
|
202
|
+
? path.join(prefix, "node_modules", PACKAGE_NAME.replace("/", path.sep))
|
|
203
|
+
: path.join(prefix, "lib", "node_modules", PACKAGE_NAME.replace("/", path.sep));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Which copy of niro is running this, checked BEFORE npm is allowed to touch anything.
|
|
208
|
+
*
|
|
209
|
+
* "global" this process is the globally installed package, so replacing it is the job
|
|
210
|
+
* "linked" the global package folder is a symlink, which is what `npm link` leaves behind.
|
|
211
|
+
* `npm install -g` deletes that link and puts the published package in its place,
|
|
212
|
+
* so a developer working on niro loses the wiring between `niro` and their checkout.
|
|
213
|
+
* "elsewhere" running from somewhere else entirely: a checkout invoked directly, or the npx
|
|
214
|
+
* cache. Upgrading then replaces a copy the user is not running.
|
|
215
|
+
*
|
|
216
|
+
* This exists because the first real run of `niro upgrade` was from a linked checkout and silently
|
|
217
|
+
* replaced it with the published package.
|
|
218
|
+
*/
|
|
219
|
+
function describeRunningCopy(pkgDirOverride) {
|
|
220
|
+
// The directory is a parameter so a unit test can exercise every branch without shelling out to
|
|
221
|
+
// npm at all. An end-to-end rehearsal takes the other route and sets npm_config_prefix, which
|
|
222
|
+
// npm does honour (scripts/rehearse-upgrade.sh).
|
|
223
|
+
const pkgDir = pkgDirOverride === undefined ? globalPackageDir() : pkgDirOverride;
|
|
224
|
+
|
|
225
|
+
let self = null;
|
|
226
|
+
try {
|
|
227
|
+
self = fs.realpathSync(process.argv[1]);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
self = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (pkgDir) {
|
|
233
|
+
try {
|
|
234
|
+
if (fs.lstatSync(pkgDir).isSymbolicLink()) {
|
|
235
|
+
return { kind: "linked", pkgDir, linkTarget: fs.realpathSync(pkgDir), self };
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Not installed globally at all. Falls through to "elsewhere".
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let pkgReal = null;
|
|
243
|
+
try {
|
|
244
|
+
pkgReal = pkgDir ? fs.realpathSync(pkgDir) : null;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
pkgReal = null;
|
|
247
|
+
}
|
|
248
|
+
if (pkgReal && self && (self === pkgReal || self.startsWith(pkgReal + path.sep))) {
|
|
249
|
+
return { kind: "global", pkgDir, linkTarget: null, self };
|
|
250
|
+
}
|
|
251
|
+
return { kind: "elsewhere", pkgDir, linkTarget: null, self };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Does the version now installed actually have `niro upgrade --finish`?
|
|
256
|
+
*
|
|
257
|
+
* Asked rather than assumed, because the newest published version can predate this one: a machine
|
|
258
|
+
* running an unreleased build upgrades "up" to a package with no `upgrade` command at all. Without
|
|
259
|
+
* this, the hand-over fails with commander's bare `error: unknown command 'upgrade'` and the user
|
|
260
|
+
* is told to re-run a command that cannot exist.
|
|
261
|
+
*/
|
|
262
|
+
function supportsFinish(bin) {
|
|
263
|
+
const probe = process.platform === "win32"
|
|
264
|
+
? childProcess.spawnSync(`"${bin}" upgrade --help`, { shell: true, encoding: "utf8", stdio: "pipe" })
|
|
265
|
+
: childProcess.spawnSync(bin, ["upgrade", "--help"], { encoding: "utf8", stdio: "pipe" });
|
|
266
|
+
if (probe.error || probe.status !== 0) return false;
|
|
267
|
+
return String(probe.stdout || "").includes("--finish");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Phase two, running from the new package: put back what npm could not. */
|
|
271
|
+
async function finish(fromVersion) {
|
|
272
|
+
console.log(fromVersion && fromVersion !== VERSION
|
|
273
|
+
? `\n niro ${fromVersion} to ${VERSION}\n`
|
|
274
|
+
: `\n niro ${VERSION}\n`);
|
|
275
|
+
|
|
276
|
+
// The watcher first. It is one command and cannot block, whereas a client refresh can sit on a
|
|
277
|
+
// prompt for as long as the user is away, and until the restart happens the watcher is running
|
|
278
|
+
// code from a package that no longer exists on disk.
|
|
279
|
+
restartWatcher();
|
|
280
|
+
|
|
281
|
+
const complete = await refreshAssistantFiles();
|
|
282
|
+
if (!complete) process.exitCode = EXIT_REFRESH_INCOMPLETE;
|
|
283
|
+
|
|
284
|
+
console.log(` ${color.yellow("One thing left:")} restart your AI assistant.`);
|
|
285
|
+
console.log(" Hooks and skills are read when it starts, so an assistant that is already open");
|
|
286
|
+
console.log(" is still using the old ones.\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Re-run the installer once per configured client, so their hooks, skills and instruction files
|
|
291
|
+
* match the package that is now installed. Returns false when anything was skipped or failed, so
|
|
292
|
+
* the caller can set a non-zero exit code: a script driving this needs to know that half of it
|
|
293
|
+
* did nothing.
|
|
294
|
+
*
|
|
295
|
+
* One client failing does not stop the others. They are separate files owned by separate tools,
|
|
296
|
+
* and a missing editor CLI is an ordinary reason for one to fail.
|
|
297
|
+
*/
|
|
298
|
+
async function refreshAssistantFiles() {
|
|
299
|
+
const { home, configured } = mcpClients.configuredClients();
|
|
300
|
+
|
|
301
|
+
if (!home) {
|
|
302
|
+
console.log(` ${color.yellow("Skipped the assistant files:")} there is no usable home folder.`);
|
|
303
|
+
console.log(" HOME is unset or empty, so every path would have been read relative to the");
|
|
304
|
+
console.log(" folder you are standing in, which is not yours to read or write.");
|
|
305
|
+
console.log(` Set HOME, then run ${color.cyan("niro upgrade --finish")}.\n`);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (configured.length === 0) {
|
|
310
|
+
console.log(" No configured assistant found, so there were no hooks or skills to refresh.");
|
|
311
|
+
console.log(` Connect one with ${color.cyan("niro mcp install")}.\n`);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!startCmd.hasCredentials()) {
|
|
316
|
+
console.log(` ${color.yellow("Heads up:")} you are not signed in, so each client's installer will ask`);
|
|
317
|
+
console.log(` for an API key. ${color.cyan("niro login")} first if you would rather it did not.\n`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Cursor's installer writes a project-scoped rule to <cwd>/.cursor/rules/niro.mdc, so refreshing
|
|
321
|
+
// it leaves a file in whatever folder the upgrade was run from. That is the installer's design,
|
|
322
|
+
// not something to change here, but it is a surprise when you only asked to upgrade.
|
|
323
|
+
if (configured.some((c) => c.id === "cursor")) {
|
|
324
|
+
console.log(` ${color.yellow("Note:")} refreshing Cursor also writes a project rule to`);
|
|
325
|
+
console.log(` ${path.join(process.cwd(), ".cursor", "rules", "niro.mdc")}`);
|
|
326
|
+
console.log(" because Cursor rules are per-folder. Delete it if you did not want it here.\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const total = configured.length;
|
|
330
|
+
console.log(` Refreshing hooks and skills for ${total} client${total === 1 ? "" : "s"}:`);
|
|
331
|
+
for (const client of configured) {
|
|
332
|
+
console.log(` ${client.name} (${client.where})`);
|
|
333
|
+
}
|
|
334
|
+
console.log("");
|
|
335
|
+
|
|
336
|
+
let done = 0;
|
|
337
|
+
let failed = 0;
|
|
338
|
+
for (const client of configured) {
|
|
339
|
+
console.log(color.dim(` [${done + failed + 1} of ${total}] ${client.name}`));
|
|
340
|
+
try {
|
|
341
|
+
await mcpCmd.install(["--client", client.id]);
|
|
342
|
+
done += 1;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
failed += 1;
|
|
345
|
+
console.error(` ${color.yellow("!")} ${client.name} was not refreshed: ${err.message}`);
|
|
346
|
+
console.error(` Run ${color.cyan(`niro mcp install --client ${client.id}`)} to see why.\n`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (failed > 0) {
|
|
351
|
+
console.log(` ${done} of ${total} refreshed, ${failed} failed.`);
|
|
352
|
+
console.log(` Re-run ${color.cyan("niro upgrade --finish")} once the failures above are sorted.\n`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
console.log(` ${color.green("Done.")} ${done} of ${total} refreshed.\n`);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Restart the background watcher, which is a long-running process still holding the code it loaded
|
|
361
|
+
* when it started. Replacing files on disk cannot change a process that is already running.
|
|
362
|
+
*/
|
|
363
|
+
function restartWatcher() {
|
|
364
|
+
if (!service.pm2Available()) {
|
|
365
|
+
console.log(" Background watcher: PM2 is not installed, so there is nothing to restart.\n");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const state = service.processState();
|
|
370
|
+
if (state !== "online") {
|
|
371
|
+
const what = state === "stopped" ? "registered but not running" : "not installed";
|
|
372
|
+
console.log(` Background watcher: ${what}, so it was left alone.\n`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
console.log(" Restarting the background watcher, which is still running the old code...");
|
|
377
|
+
if (service.restart()) {
|
|
378
|
+
// Deliberately not "now on <version>": pm2 re-execs the script path recorded when the daemon
|
|
379
|
+
// was installed (service.js records process.argv[1]), which is not necessarily the copy that
|
|
380
|
+
// was just upgraded. All that is known here is that pm2 restarted it.
|
|
381
|
+
console.log(` ${color.green("Done.")} niro-watch restarted. Check it with ${color.cyan("niro service status")}.\n`);
|
|
382
|
+
} else {
|
|
383
|
+
console.log(` ${color.yellow("!")} Could not restart it. Run ${color.cyan("niro service install")}.\n`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
upgrade,
|
|
389
|
+
// exported for tests
|
|
390
|
+
refreshAssistantFiles,
|
|
391
|
+
restartWatcher,
|
|
392
|
+
describeRunningCopy,
|
|
393
|
+
supportsFinish,
|
|
394
|
+
HANDOVER_ENV,
|
|
395
|
+
EXIT_NPM_FAILED,
|
|
396
|
+
EXIT_NO_BINARY,
|
|
397
|
+
EXIT_HANDOVER_FAILED,
|
|
398
|
+
EXIT_REFRESH_INCOMPLETE,
|
|
399
|
+
EXIT_WRONG_COPY,
|
|
400
|
+
};
|
package/src/index.js
CHANGED
|
@@ -309,6 +309,17 @@ function main() {
|
|
|
309
309
|
await deleteCmd.deleteProject(projectId, { ...opts });
|
|
310
310
|
}));
|
|
311
311
|
|
|
312
|
+
program
|
|
313
|
+
.command("upgrade")
|
|
314
|
+
.description("Upgrade niro to the latest published version, then refresh the hooks, skills and background watcher that replacing the package leaves untouched.")
|
|
315
|
+
.option("--finish", "Only refresh hooks, skills and the watcher; do not run npm. `niro upgrade` runs this for you in the new version once the package is replaced.")
|
|
316
|
+
.option("--from <version>", "The version upgraded from, for the summary line. Set automatically by `niro upgrade`.")
|
|
317
|
+
.option("--force", "Upgrade the global copy even when you are running a linked or local checkout, which `npm install -g` would replace.")
|
|
318
|
+
.action((opts) => run(async () => {
|
|
319
|
+
const upgradeCmd = require("./commands/upgrade");
|
|
320
|
+
await upgradeCmd.upgrade(opts);
|
|
321
|
+
}));
|
|
322
|
+
|
|
312
323
|
const svc = program
|
|
313
324
|
.command("service")
|
|
314
325
|
.description("Manage the niro-watch background daemon (auto-sync on login).");
|
package/src/lib/folderSync.js
CHANGED
|
@@ -36,6 +36,7 @@ const color = require("./color");
|
|
|
36
36
|
const BATCH_SIZE = 50;
|
|
37
37
|
const SOURCE_EXTENSIONS = new Set([
|
|
38
38
|
".java", ".py", ".go", ".js", ".ts", ".tsx", ".jsx",
|
|
39
|
+
".cjs", ".mjs", ".cts", ".mts",
|
|
39
40
|
".kt", ".scala", ".rs", ".rb", ".php", ".cs", ".swift",
|
|
40
41
|
".c", ".cpp", ".h", ".hpp", ".dart",
|
|
41
42
|
]);
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record of the impact checks an agent made through the bridge, read by the Edit|Write impact gate.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: CLAUDE.md, the niro skill and the UserPromptSubmit hook all tell the agent to map
|
|
5
|
+
* who depends on code before changing it (get_blast_radius, get_impact_analysis). Prose decays over a
|
|
6
|
+
* session: the agent obeys early, then reads a method with sed and edits it. Niraj, 2026-09-04: "I
|
|
7
|
+
* always felt that before you make any code changes in any method you would want to check what's
|
|
8
|
+
* the impact of that but you hardly ever care to check." The only thing that survives a long session
|
|
9
|
+
* is a check at the moment of the edit. The PreToolUse hook in setup.js (NIRO_IMPACT_COMMAND) is that
|
|
10
|
+
* check, and it needs evidence of what the agent actually called. The bridge sees every tools/call and
|
|
11
|
+
* every answer, so it keeps that evidence here.
|
|
12
|
+
*
|
|
13
|
+
* What is written: one JSON line per answered impact call under ~/.niro/impact/<key>.jsonl, where
|
|
14
|
+
* <key> is the bridge's working directory, symlinks resolved, with every byte that is not an ASCII
|
|
15
|
+
* letter or digit replaced by "-". The hook derives the same key from the payload's cwd with
|
|
16
|
+
* `cd -P` and `tr -c 'A-Za-z0-9' '-'`, so the two sides agree without sharing code (tested in
|
|
17
|
+
* test/impactGateHook.test.js). A line carries the tool name, the names the agent asked about
|
|
18
|
+
* (method_fqn, class_fqn and friends) and every source path the answer mentioned. Lines older than
|
|
19
|
+
* WINDOW_MS are pruned on every write, so the file cannot grow past one hour of checks.
|
|
20
|
+
*
|
|
21
|
+
* Liveness: <key>.alive is touched whenever the server answers. The hook denies an unchecked edit only
|
|
22
|
+
* while that file is fresh; with the bridge gone it can only advise, so Niro being unreachable never
|
|
23
|
+
* blocks an edit. Fail-open by design.
|
|
24
|
+
*
|
|
25
|
+
* Every function here swallows filesystem errors: this sits on the message path between the host and
|
|
26
|
+
* the server, and a full disk or an unwritable home must cost the gate and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const { niroHome } = require("./receiptStore");
|
|
31
|
+
|
|
32
|
+
/** The calls that count as "checked who depends on it". Downstream-only walks (get_call_chain) do not. */
|
|
33
|
+
const IMPACT_TOOLS = new Set(["get_blast_radius", "get_impact_analysis", "get_reverse_call_chain"]);
|
|
34
|
+
/** How long a recorded check keeps a file unlocked. */
|
|
35
|
+
const WINDOW_MS = 60 * 60 * 1000;
|
|
36
|
+
const MAX_LINES = 500;
|
|
37
|
+
const MAX_NAMES = 50;
|
|
38
|
+
const MAX_FILES = 200;
|
|
39
|
+
const MAX_IN_FLIGHT = 100;
|
|
40
|
+
/** The alive file is rewritten at most this often; answers arrive far more frequently than that. */
|
|
41
|
+
const ALIVE_TOUCH_MIN_MS = 10 * 1000;
|
|
42
|
+
/** Argument names across the impact tools that carry what the agent asked about. */
|
|
43
|
+
const NAME_ARGS = [
|
|
44
|
+
"method_fqn", "method_fqns", "class_fqn", "entity_fqn", "entity_fqns", "fqn", "fqns", "targets",
|
|
45
|
+
"http_path", "event_fqn",
|
|
46
|
+
];
|
|
47
|
+
const SOURCE_EXT = "java|js|mjs|cjs|ts|tsx|jsx|py|go|rb|kt|kts|cs|php|rs|scala|swift|c|cc|cpp|h|hpp";
|
|
48
|
+
/**
|
|
49
|
+
* A source path inside an answer: optional leading "/", slash-separated segments, a known extension,
|
|
50
|
+
* bounded by whitespace, quotes, brackets, ":" or ",". The ":" lets `repo::src/a/B.java` yield the
|
|
51
|
+
* repo-relative path, which is what the hook compares against the edited file's path.
|
|
52
|
+
*/
|
|
53
|
+
const PATH_RE = new RegExp(
|
|
54
|
+
"(?:^|[\\s\"'(:,])(/?(?:[A-Za-z0-9_.\\-]+/)*[A-Za-z0-9_.\\-]+\\.(?:" + SOURCE_EXT + "))(?=$|[\\s\"'),:])",
|
|
55
|
+
"g",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function impactDir() {
|
|
59
|
+
return path.join(niroHome(), "impact");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Presence of this file switches the gate off for every session on this machine. */
|
|
63
|
+
function offSwitchPath() {
|
|
64
|
+
return path.join(niroHome(), "impact-gate-off");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The per-folder key, byte for byte what `printf '%s' "$(cd -P dir && pwd -P)" | tr -c 'A-Za-z0-9' '-'`
|
|
69
|
+
* produces. Byte-based on purpose: tr works on bytes, so a non-ASCII character becomes several dashes
|
|
70
|
+
* there and must here too, or the hook looks for a file the bridge never wrote.
|
|
71
|
+
*/
|
|
72
|
+
function keyForCwd(cwd) {
|
|
73
|
+
let real = String(cwd || "");
|
|
74
|
+
try {
|
|
75
|
+
real = fs.realpathSync(real);
|
|
76
|
+
} catch (_) {
|
|
77
|
+
/* a folder that does not exist keeps its lexical path, as the hook's `cd -P || printf` fallback does */
|
|
78
|
+
}
|
|
79
|
+
let out = "";
|
|
80
|
+
for (const b of Buffer.from(real, "utf8")) {
|
|
81
|
+
const alnum = (b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122);
|
|
82
|
+
out += alnum ? String.fromCharCode(b) : "-";
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function logPathFor(cwd) {
|
|
88
|
+
return path.join(impactDir(), keyForCwd(cwd) + ".jsonl");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function alivePathFor(cwd) {
|
|
92
|
+
return path.join(impactDir(), keyForCwd(cwd) + ".alive");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toolNameOf(message) {
|
|
96
|
+
if (!message || message.method !== "tools/call" || !message.params) return null;
|
|
97
|
+
return typeof message.params.name === "string" ? message.params.name : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isImpactCall(message) {
|
|
101
|
+
const name = toolNameOf(message);
|
|
102
|
+
return !!name && IMPACT_TOOLS.has(name);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The strings the agent asked about, deduplicated and bounded; routing extras like _niroPin are ignored. */
|
|
106
|
+
function namesFromArgs(args) {
|
|
107
|
+
const out = [];
|
|
108
|
+
const push = (v) => {
|
|
109
|
+
if (typeof v !== "string") return;
|
|
110
|
+
const s = v.trim().slice(0, 300);
|
|
111
|
+
if (s && !out.includes(s)) out.push(s);
|
|
112
|
+
};
|
|
113
|
+
if (args && typeof args === "object") {
|
|
114
|
+
for (const key of NAME_ARGS) {
|
|
115
|
+
const v = args[key];
|
|
116
|
+
if (Array.isArray(v)) v.forEach(push);
|
|
117
|
+
else push(v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out.slice(0, MAX_NAMES);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** All text parts of a tool result, joined; "" for anything that is not a text result. */
|
|
124
|
+
function resultText(msg) {
|
|
125
|
+
const content = msg && msg.result && msg.result.content;
|
|
126
|
+
if (!Array.isArray(content)) return "";
|
|
127
|
+
return content.map((c) => (c && typeof c.text === "string" ? c.text : "")).join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every distinct source path an answer mentions, in order of appearance, bounded. */
|
|
131
|
+
function filesFromResultText(text) {
|
|
132
|
+
const out = [];
|
|
133
|
+
if (typeof text !== "string" || !text) return out;
|
|
134
|
+
PATH_RE.lastIndex = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = PATH_RE.exec(text)) && out.length < MAX_FILES) {
|
|
137
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drop lines older than the window, then cap the count; the newest lines win. */
|
|
143
|
+
function pruneLines(lines, now) {
|
|
144
|
+
const cutoff = now - WINDOW_MS;
|
|
145
|
+
const kept = lines.filter((line) => {
|
|
146
|
+
const m = /"ts":(\d+)/.exec(line);
|
|
147
|
+
return !!m && Number(m[1]) >= cutoff;
|
|
148
|
+
});
|
|
149
|
+
return kept.length > MAX_LINES ? kept.slice(kept.length - MAX_LINES) : kept;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Append one record, pruning first. Returns false, never throws, when the file cannot be written. */
|
|
153
|
+
function appendRecord(file, record, now) {
|
|
154
|
+
try {
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
156
|
+
let lines = [];
|
|
157
|
+
try {
|
|
158
|
+
lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
159
|
+
} catch (_) {
|
|
160
|
+
/* first record for this folder */
|
|
161
|
+
}
|
|
162
|
+
lines = pruneLines(lines, now);
|
|
163
|
+
lines.push(JSON.stringify(record));
|
|
164
|
+
fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
165
|
+
return true;
|
|
166
|
+
} catch (_) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One recorder per bridge process. `noteRequest` on every outgoing message, `noteResult` and
|
|
173
|
+
* `touchAlive` on every incoming one; each returns true only when it wrote something, which the
|
|
174
|
+
* tests use and the bridge ignores.
|
|
175
|
+
*/
|
|
176
|
+
function createImpactRecorder({ cwd = process.cwd(), now = Date.now } = {}) {
|
|
177
|
+
const inFlight = new Map();
|
|
178
|
+
let lastAlive = 0;
|
|
179
|
+
return {
|
|
180
|
+
noteRequest(message) {
|
|
181
|
+
if (!isImpactCall(message) || message.id === undefined || message.id === null) return false;
|
|
182
|
+
const args = message.params.arguments || {};
|
|
183
|
+
inFlight.set(message.id, { tool: message.params.name, names: namesFromArgs(args), ts: now() });
|
|
184
|
+
if (inFlight.size > MAX_IN_FLIGHT) inFlight.delete(inFlight.keys().next().value);
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
noteResult(msg) {
|
|
188
|
+
if (!msg || msg.id === undefined || msg.id === null) return false;
|
|
189
|
+
const meta = inFlight.get(msg.id);
|
|
190
|
+
if (!meta) return false;
|
|
191
|
+
inFlight.delete(msg.id);
|
|
192
|
+
// An error answer is still recorded: the agent asked, which is the behaviour the gate exists to
|
|
193
|
+
// produce, and a name the server could not resolve must not leave the file locked.
|
|
194
|
+
const files = filesFromResultText(resultText(msg));
|
|
195
|
+
return appendRecord(logPathFor(cwd), { ts: meta.ts, tool: meta.tool, names: meta.names, files }, now());
|
|
196
|
+
},
|
|
197
|
+
clearAlive() {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmSync(alivePathFor(cwd), { force: true });
|
|
200
|
+
return true;
|
|
201
|
+
} catch (_) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
touchAlive() {
|
|
206
|
+
const t = now();
|
|
207
|
+
if (t - lastAlive < ALIVE_TOUCH_MIN_MS) return false;
|
|
208
|
+
lastAlive = t;
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(impactDir(), { recursive: true, mode: 0o700 });
|
|
211
|
+
fs.writeFileSync(alivePathFor(cwd), String(t), { mode: 0o600 });
|
|
212
|
+
return true;
|
|
213
|
+
} catch (_) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Remove the evidence files and the off switch. Used by `niro mcp uninstall`. Best effort. */
|
|
221
|
+
function purge() {
|
|
222
|
+
try {
|
|
223
|
+
fs.rmSync(impactDir(), { recursive: true, force: true });
|
|
224
|
+
} catch (_) {
|
|
225
|
+
/* best effort */
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
fs.rmSync(offSwitchPath(), { force: true });
|
|
229
|
+
} catch (_) {
|
|
230
|
+
/* best effort */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
purge,
|
|
236
|
+
IMPACT_TOOLS,
|
|
237
|
+
WINDOW_MS,
|
|
238
|
+
MAX_LINES,
|
|
239
|
+
keyForCwd,
|
|
240
|
+
impactDir,
|
|
241
|
+
offSwitchPath,
|
|
242
|
+
logPathFor,
|
|
243
|
+
alivePathFor,
|
|
244
|
+
isImpactCall,
|
|
245
|
+
namesFromArgs,
|
|
246
|
+
resultText,
|
|
247
|
+
filesFromResultText,
|
|
248
|
+
pruneLines,
|
|
249
|
+
appendRecord,
|
|
250
|
+
createImpactRecorder,
|
|
251
|
+
};
|