@niroai/niro 0.12.0 → 0.15.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 +190 -0
- package/README.md +62 -0
- package/package.json +2 -2
- package/src/commands/init.js +179 -5
- package/src/commands/mcp.js +16 -7
- package/src/commands/start.js +1 -0
- package/src/commands/upgrade.js +400 -0
- package/src/commands/watch.js +39 -7
- package/src/index.js +11 -0
- package/src/lib/folderSync.js +14 -0
- package/src/lib/mcpClients.js +241 -0
- package/src/lib/onboardTarget.js +13 -4
- package/src/lib/service.js +18 -1
- package/src/mcp/setup.js +3 -0
|
@@ -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/commands/watch.js
CHANGED
|
@@ -878,13 +878,42 @@ function matchesPatterns(relativePath, includePatterns, excludePatterns) {
|
|
|
878
878
|
return !excludePatterns.some(p => minimatch(relativePath, p));
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
-
|
|
881
|
+
// Every character that is special inside a regular expression EXCEPT `*`, which is the
|
|
882
|
+
// only wildcard this glob syntax has. Escaping `.` alone was the defect, because a pattern
|
|
883
|
+
// is not ours to choose: the server names a ROOT-LEVEL file by its own filename
|
|
884
|
+
// (ai-orchestrator SourceFileFilterService.derivePatterns, the `else` branch) and the new
|
|
885
|
+
// directory prompt above names a directory by its own path, so a real name reaches this
|
|
886
|
+
// function verbatim. `main.c++` at a repo root became /^main\.c++$/, "Nothing to repeat",
|
|
887
|
+
// and that throw killed EVERY sync pass for the folder: the file event gate, the delta and
|
|
888
|
+
// backstop walk, and the rescue check that runs before a delete. The quieter half of the
|
|
889
|
+
// same defect never threw at all: `app (copy).js` built a valid regex with a capture group,
|
|
890
|
+
// so the file silently stopped matching its own pattern and was never synced again.
|
|
891
|
+
// No /g flag: `test` on a global regex advances lastIndex between calls.
|
|
892
|
+
const REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/;
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Simple glob matching without a dependency: handles ** and * patterns.
|
|
896
|
+
*
|
|
897
|
+
* One pass over the pattern, so every character that is not a wildcard is escaped as a
|
|
898
|
+
* literal and the result is always a valid regular expression. The previous version
|
|
899
|
+
* escaped `.` and then routed `**` through a `§§` placeholder, which also corrupted any
|
|
900
|
+
* pattern that genuinely contained `§§`.
|
|
901
|
+
*/
|
|
882
902
|
function minimatch(filePath, pattern) {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
903
|
+
let regexStr = "";
|
|
904
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
905
|
+
const ch = pattern[i];
|
|
906
|
+
if (ch === "*") {
|
|
907
|
+
if (pattern[i + 1] === "*") {
|
|
908
|
+
regexStr += ".*"; // ** crosses directory separators
|
|
909
|
+
i++;
|
|
910
|
+
} else {
|
|
911
|
+
regexStr += "[^/]*"; // * stays inside one path segment
|
|
912
|
+
}
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
regexStr += REGEX_SPECIAL_CHARS.test(ch) ? "\\" + ch : ch;
|
|
916
|
+
}
|
|
888
917
|
return new RegExp("^" + regexStr + "$").test(filePath);
|
|
889
918
|
}
|
|
890
919
|
|
|
@@ -944,8 +973,11 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
|
944
973
|
}
|
|
945
974
|
|
|
946
975
|
// handleFileEvent + deltaSyncOnResume are exported for unit testing the
|
|
947
|
-
// gitignore/fail-closed guards without a live server or fs.watch loop.
|
|
976
|
+
// gitignore/fail-closed guards without a live server or fs.watch loop. matchesPatterns is
|
|
977
|
+
// exported for the same reason: it is the gate all three sync paths share, and a pattern that
|
|
978
|
+
// cannot be compiled stops every one of them, so it is worth testing on its own.
|
|
948
979
|
module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
980
|
+
matchesPatterns,
|
|
949
981
|
queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
|
|
950
982
|
unreadableRoots,
|
|
951
983
|
nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
|
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
|
@@ -34,11 +34,25 @@ const prompt = require("./prompt");
|
|
|
34
34
|
const color = require("./color");
|
|
35
35
|
|
|
36
36
|
const BATCH_SIZE = 50;
|
|
37
|
+
// MUST stay identical to ai-orchestrator SourceFileFilterService.SOURCE_EXTENSIONS.
|
|
38
|
+
// Both are hand-maintained and nothing enforced that until test/sourceExtensionDrift.test.js
|
|
39
|
+
// here, which reads the Java file, and ai-orchestrator SourceExtensionDriftTest, which reads
|
|
40
|
+
// THIS file. The two repositories build separately, so the guard is on both sides. The
|
|
41
|
+
// server's /filter is the authoritative gate, so an extension missing here is never
|
|
42
|
+
// offered and never uploaded, whatever the server would have accepted.
|
|
37
43
|
const SOURCE_EXTENSIONS = new Set([
|
|
38
44
|
".java", ".py", ".go", ".js", ".ts", ".tsx", ".jsx",
|
|
39
45
|
".cjs", ".mjs", ".cts", ".mts",
|
|
40
46
|
".kt", ".scala", ".rs", ".rb", ".php", ".cs", ".swift",
|
|
41
47
|
".c", ".cpp", ".h", ".hpp", ".dart",
|
|
48
|
+
// The remaining C++ extensions ast-parser routes to its C++ grammar and CppMapper.
|
|
49
|
+
".cc", ".cxx", ".hxx", ".hh", ".h++", ".c++", ".tcc", ".ipp", ".inl", ".tpp",
|
|
50
|
+
// Ten more the parser routes to a real mapper and that this list did not offer, so the
|
|
51
|
+
// user's Kotlin build scripts, C# scripts, Rakefiles, gemspecs, legacy PHP files and
|
|
52
|
+
// Python stubs were never uploaded. Added 2026-09-14 alongside .jsx.
|
|
53
|
+
".kts", ".csx", ".rake", ".gemspec",
|
|
54
|
+
".phtml", ".php3", ".php4", ".php5",
|
|
55
|
+
".pyi", ".pyw",
|
|
42
56
|
]);
|
|
43
57
|
|
|
44
58
|
// Build/config files the server's project-structure resolver needs to detect
|