@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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Which AI clients on this machine already have niro registered?
3
+ *
4
+ * `niro upgrade` needs this list. The hooks, skills and instruction files an install writes are
5
+ * fixed text in the user's home folder, not read out of the package as it runs, so replacing the
6
+ * package leaves them exactly as they were. Putting them back means re-running the installer, and
7
+ * the installer configures ONE client, named with `--client`. Nothing on disk records which
8
+ * clients were ever chosen, so the list has to be worked out from what is there.
9
+ *
10
+ * Every client in the CLIENTS map is covered, because the installer leaves a file this repo owns
11
+ * in every case. Eight are registered by writing a JSON config whose path setup.js builds. The
12
+ * other six are registered by shelling out to another tool's CLI, but each one also gets a marker
13
+ * appended to a markdown file this repo writes and teardown.js later strips: ~/.claude/CLAUDE.md,
14
+ * ~/.gemini/GEMINI.md, ~/.copilot/copilot-instructions.md, ~/.factory/AGENTS.md, plus codex's
15
+ * [mcp_servers.niro] table and goose's extensions entry, both written here too. The marker is
16
+ * appended only after the registration command succeeded, so its presence means the client really
17
+ * was configured.
18
+ *
19
+ * Two rules keep this honest, and both exist because of what got the serve-time self-heal reverted
20
+ * on 2026-07-30 (d3ff3d8):
21
+ *
22
+ * Every path must be absolute. That revert's worst finding was a settings path resolving against
23
+ * the current folder and a project's own git-tracked .claude/settings.json being written. It is
24
+ * not enough to check that a home folder exists: HOME can be set to the EMPTY string while
25
+ * USERPROFILE is set, and userHome() then returns USERPROFILE and looks fine, while four of the
26
+ * path builders in setup.js read process.env.HOME directly and hand back a relative path. So the
27
+ * home is rejected unless it is absolute, and each individual path is rejected the same way.
28
+ *
29
+ * Markers are matched on a whole line, never as a substring of prose. That revert also failed on
30
+ * substring tests over prose, which captured a user's unrelated hook. A sentence mentioning the
31
+ * marker is not an install.
32
+ *
33
+ * Everything here is read-only. Nothing in this file creates, writes or deletes anything.
34
+ */
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+
38
+ const setup = require("../mcp/setup");
39
+
40
+ const { CLIENTS, NIRO_MD_MARKER, userHome, gooseConfigPath } = setup;
41
+
42
+ /**
43
+ * Biggest file worth reading. None of these configs is more than a few kilobytes, and without a
44
+ * cap a config path that is a huge file, a FIFO or a symlink to /dev/zero makes `niro upgrade`
45
+ * hang or allocate without limit rather than reporting a client.
46
+ */
47
+ const MAX_CONFIG_BYTES = 4 * 1024 * 1024;
48
+
49
+ /**
50
+ * The markdown file each shell-registered client gets a niro marker appended to, written by
51
+ * setup.js after the registration command succeeds and removed by teardown.js. Relative to home.
52
+ */
53
+ const MARKER_FILES = {
54
+ "claude-code": [".claude", "CLAUDE.md"],
55
+ "gemini-cli": [".gemini", "GEMINI.md"],
56
+ "copilot-cli": [".copilot", "copilot-instructions.md"],
57
+ factory: [".factory", "AGENTS.md"],
58
+ };
59
+
60
+ /** The marker on a line of its own. A sentence that merely mentions it is not an install. */
61
+ const MARKER_LINE = new RegExp(
62
+ `^[ \\t]*${NIRO_MD_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`,
63
+ "m"
64
+ );
65
+
66
+ /**
67
+ * The home folder, or null when there is not one that can be trusted.
68
+ *
69
+ * Rejects an empty or relative home, and rejects a HOME that is set to the empty string even when
70
+ * USERPROFILE would fill in, because setup.js's claude-desktop, windsurf, vscode and cline path
71
+ * builders read process.env.HOME directly and would produce paths relative to the current folder.
72
+ */
73
+ function homeOrNull() {
74
+ if (process.env.HOME === "") return null;
75
+ const home = userHome();
76
+ return home && path.isAbsolute(home) ? home : null;
77
+ }
78
+
79
+ /** Read a config file, or null when it is missing, unreadable, not a regular file, or too big. */
80
+ function readConfig(file) {
81
+ try {
82
+ const stat = fs.statSync(file);
83
+ if (!stat.isFile()) return null; // a directory, a FIFO, a device
84
+ if (stat.size > MAX_CONFIG_BYTES) return null;
85
+ return fs.readFileSync(file, "utf-8");
86
+ } catch (err) {
87
+ return null; // missing or unreadable is not evidence of an install
88
+ }
89
+ }
90
+
91
+ /**
92
+ * True when `file` holds a real niro registration under `key`.
93
+ *
94
+ * Presence of the key is not enough. An empty object is not a working entry, and `enabled: false`
95
+ * (opencode) or `disabled: true` (cline) is a registration the user switched OFF. Reporting those
96
+ * would make `niro upgrade` re-run the installer, which rewrites the whole entry and turns niro
97
+ * back on without being asked.
98
+ */
99
+ function jsonHasNiro(file, key) {
100
+ const raw = readConfig(file);
101
+ if (raw === null) return false;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(raw);
105
+ } catch (err) {
106
+ return false;
107
+ }
108
+ if (!parsed || typeof parsed !== "object") return false;
109
+ const section = parsed[key];
110
+ if (!section || typeof section !== "object") return false;
111
+ const entry = section.niro;
112
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
113
+ if (entry.disabled === true) return false;
114
+ if (entry.enabled === false) return false;
115
+ return Object.keys(entry).length > 0;
116
+ }
117
+
118
+ /** True when `file` exists and matches `re`. */
119
+ function fileMatches(file, re) {
120
+ const raw = readConfig(file);
121
+ return raw === null ? false : re.test(raw);
122
+ }
123
+
124
+ /**
125
+ * The JSON config paths worth checking for a client. Antigravity has two: setup.js writes to the
126
+ * legacy file only while the unified one is absent, so once both exist the builder stops returning
127
+ * the legacy path and a registration living there becomes invisible. teardown.js already checks
128
+ * both unconditionally, and the reason is written up in test/mcpClientCoverage.test.js.
129
+ */
130
+ function jsonPathsFor(id, client, home) {
131
+ if (id === "antigravity") {
132
+ return [
133
+ path.join(home, ".gemini", "antigravity", "mcp_config.json"),
134
+ path.join(home, ".gemini", "config", "mcp_config.json"),
135
+ ];
136
+ }
137
+ try {
138
+ return [client.configPath()];
139
+ } catch (err) {
140
+ return []; // a path built from an env var this platform does not set
141
+ }
142
+ }
143
+
144
+ /**
145
+ * claude-code leaves two markers and either one is proof: the niro-skill file, and the marked
146
+ * section in ~/.claude/CLAUDE.md. There is no JSON config to read, because the MCP entry itself
147
+ * goes in through `claude mcp add`.
148
+ */
149
+ function claudeCodeConfigured(home) {
150
+ const skill = path.join(home, ".claude", "skills", "niro-skill", "SKILL.md");
151
+ try {
152
+ if (fs.statSync(skill).isFile()) return skill;
153
+ } catch (err) {
154
+ // fall through to the CLAUDE.md marker
155
+ }
156
+ const memory = path.join(home, MARKER_FILES["claude-code"][0], MARKER_FILES["claude-code"][1]);
157
+ return fileMatches(memory, MARKER_LINE) ? memory : null;
158
+ }
159
+
160
+ /** codex registers the [mcp_servers.niro] table in ~/.codex/config.toml. */
161
+ function codexConfigured(home) {
162
+ const toml = path.join(home, ".codex", "config.toml");
163
+ return fileMatches(toml, /^\s*\[mcp_servers\.niro\]\s*$/m) ? toml : null;
164
+ }
165
+
166
+ /**
167
+ * goose: the same two-space-indented `niro:` line the installer writes and the uninstaller
168
+ * removes, but only when it sits under the top-level `extensions:` key. The installer's own guard
169
+ * matches that line anywhere in the file, so a `niro:` nested under some other top-level key reads
170
+ * as installed; here that would cost a wizard run that rewrites a config the user did not ask us
171
+ * to touch, so the parent key is checked as well.
172
+ */
173
+ function gooseConfigured() {
174
+ let yaml;
175
+ try {
176
+ yaml = gooseConfigPath();
177
+ } catch (err) {
178
+ return null;
179
+ }
180
+ if (!yaml || !path.isAbsolute(yaml)) return null;
181
+ const raw = readConfig(yaml);
182
+ if (raw === null) return null;
183
+
184
+ let underExtensions = false;
185
+ for (const line of raw.split(/\r?\n/)) {
186
+ if (/^\S/.test(line)) underExtensions = /^extensions:\s*$/.test(line);
187
+ else if (underExtensions && /^ {2}niro:\s*$/.test(line)) return yaml;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Returns { home, configured }.
194
+ *
195
+ * home the home folder used, or null when there was no usable one
196
+ * configured [{ id, name, where }] for every client proved to have niro registered
197
+ */
198
+ function configuredClients() {
199
+ const home = homeOrNull();
200
+ // Without a trustworthy home every path below could resolve against the current folder, so a
201
+ // repo that happens to hold a .claude or .cursor folder would be read as the user's own
202
+ // settings and then written to. Check nothing rather than check the wrong thing.
203
+ if (!home) return { home: null, configured: [] };
204
+
205
+ const configured = [];
206
+ const add = (id, where) => {
207
+ if (!where || !path.isAbsolute(where)) return;
208
+ configured.push({ id, name: CLIENTS[id] ? CLIENTS[id].name : id, where });
209
+ };
210
+
211
+ for (const [id, client] of Object.entries(CLIENTS)) {
212
+ if (id === "claude-code") {
213
+ add(id, claudeCodeConfigured(home));
214
+ } else if (id === "codex") {
215
+ add(id, codexConfigured(home));
216
+ } else if (id === "goose") {
217
+ add(id, gooseConfigured());
218
+ } else if (client.configPath || id === "antigravity") {
219
+ const key = client.topLevelKey || "mcpServers";
220
+ for (const file of jsonPathsFor(id, client, home)) {
221
+ if (file && path.isAbsolute(file) && jsonHasNiro(file, key)) {
222
+ add(id, file);
223
+ break;
224
+ }
225
+ }
226
+ } else if (MARKER_FILES[id]) {
227
+ add(id, fileMatches(path.join(home, ...MARKER_FILES[id]), MARKER_LINE)
228
+ ? path.join(home, ...MARKER_FILES[id])
229
+ : null);
230
+ }
231
+ }
232
+
233
+ return { home, configured };
234
+ }
235
+
236
+ /** Every client id in the CLIENTS map, so a caller can prove none is silently uncovered. */
237
+ function allClientIds() {
238
+ return Object.keys(CLIENTS);
239
+ }
240
+
241
+ module.exports = { configuredClients, allClientIds, MARKER_FILES, MAX_CONFIG_BYTES };
@@ -227,16 +227,25 @@ function ensureOnboardable(targetDir, opts = {}) {
227
227
  ));
228
228
  return true;
229
229
  }
230
+ // A folder holding git repos declares its projects with .git rather than with a build file.
231
+ // Onboarding handles that case by indexing each repo separately, so refusing here would send the
232
+ // user to --force, which uploads the whole tree as one repo instead.
233
+ if (typeof opts.hasGitReposBeneath === "function" && opts.hasGitReposBeneath()) {
234
+ return true;
235
+ }
230
236
  if (opts.force) {
231
237
  console.log(color.yellow(
232
238
  `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
239
  ));
234
240
  return true;
235
241
  }
236
- console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
- console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
- console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
- console.error(" Pass --force if you really mean to upload this folder.");
242
+ console.error(color.red(`\n ✗ Nothing to index in ${resolved}.`));
243
+ console.error(" Niro indexes either a git repository, or a folder holding a build file");
244
+ console.error(` (package.json, pom.xml, go.mod, ...) at it or just below it${truncated ? ", though the scan stopped early because the tree is very large" : ""}.`);
245
+ console.error(" This folder has neither, so there is nothing to build a graph from.");
246
+ console.error("");
247
+ console.error(" If the code you meant is deeper down, run niro init from that folder.");
248
+ console.error(" Pass --force to upload this folder anyway.");
240
249
  return false;
241
250
  }
242
251
 
@@ -152,6 +152,23 @@ function uninstall() {
152
152
  return true;
153
153
  }
154
154
 
155
+ /**
156
+ * Restart niro-watch so it re-execs its script and picks up a replaced package.
157
+ *
158
+ * `npm install -g` swaps the files on disk. A process that is already running keeps the code it
159
+ * loaded when it started, so after an upgrade the watcher is still on the old version until this
160
+ * runs. Returns false when PM2 is missing or the restart failed; the caller decides what to say.
161
+ */
162
+ function restart() {
163
+ if (!pm2Available()) return false;
164
+ const r = spawnSync("pm2", ["restart", PROCESS_NAME], {
165
+ shell: true,
166
+ stdio: "pipe",
167
+ encoding: "utf8",
168
+ });
169
+ return r.status === 0;
170
+ }
171
+
155
172
  /**
156
173
  * Print PM2 process status for niro-watch.
157
174
  */
@@ -226,4 +243,4 @@ function getInitSystem() {
226
243
  }
227
244
  }
228
245
 
229
- module.exports = { install, uninstall, status, processState, pm2Available, installPm2 };
246
+ module.exports = { install, uninstall, restart, status, processState, pm2Available, installPm2 };
package/src/mcp/setup.js CHANGED
@@ -2783,6 +2783,9 @@ module.exports = {
2783
2783
  addAgentsSkill,
2784
2784
  addAgentsCliSkill,
2785
2785
  CLIENTS,
2786
+ // Exported so `niro upgrade` can detect a configured Goose install by reading the same
2787
+ // file setupGoose writes, instead of rebuilding that platform-specific path a second time.
2788
+ gooseConfigPath,
2786
2789
  NIRO_MD_MARKER,
2787
2790
  NIRO_MD_SECTIONS,
2788
2791
  isNiroPostCompactHook,