@niroai/niro 0.16.0 → 0.18.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.
Files changed (177) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +16 -7
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
@@ -13,6 +13,7 @@ const fs = require("fs");
13
13
  const os = require("os");
14
14
  const path = require("path");
15
15
  const color = require("./color");
16
+ const { system32 } = require("./windowsPaths");
16
17
 
17
18
  const PROCESS_NAME = "niro-watch";
18
19
 
@@ -23,6 +24,9 @@ const PROCESS_NAME = "niro-watch";
23
24
  * `niro status` must never do that; flows that go on to run pm2 anyway
24
25
  * (install/uninstall) don't care either way.
25
26
  */
27
+ /** pm2 is pm2.cmd on Windows, which needs a shell; elsewhere a shell plus an args array trips DEP0190. */
28
+ const PM2_SHELL = process.platform === "win32";
29
+
26
30
  function pm2Available() {
27
31
  const probe = process.platform === "win32" ? "where pm2" : "command -v pm2";
28
32
  const r = spawnSync(probe, { encoding: "utf8", shell: true, stdio: "pipe" });
@@ -45,111 +49,378 @@ function pm2DaemonAlive() {
45
49
  }
46
50
  }
47
51
 
48
- /** True when PM2's saved process list (pm2 save) includes niro-watch. */
49
- function dumpHasProcess() {
52
+ // ─── the watcher's environment ───────────────────────────────────────────────
53
+ //
54
+ // `pm2 start` copies the environment of the pm2 command into the app, and `pm2 save` writes that
55
+ // copy into ~/.pm2/dump.pm2. Run from a developer's shell, that was the whole shell: 291 names in
56
+ // one test home, 79 of them shaped like secrets (cloud keys, tokens, passwords), in a file other
57
+ // users could read. So the pm2 commands that create or save niro-watch are given only the names
58
+ // below. They are what `niro watch`, the engine it starts and the two runtimes under them read:
59
+ //
60
+ // - the NIRO_ settings niro-cli reads (the four NIRO_*_URL, NIRO_MCP_MODE, the timeouts and
61
+ // upload limits), named one by one, and every engine setting (NIRO_LOCAL_HOME, NIRO_LOCAL_BIN,
62
+ // NIRO_LOCAL_*). Not every NIRO_ name: a developer's shell can hold the Niro services' own
63
+ // settings (a JWT secret, a database password), and in one test home 5 of those got through a
64
+ // plain NIRO_ prefix. test/serviceWatcherEnv.test.js fails when niro-cli reads a NIRO_ name
65
+ // that is missing here. NIRO_API_KEY and NIRO_AUDIT_API_KEY are left out on purpose (see
66
+ // WATCHER_ENV_EXCLUDED): the watcher signs its uploads with the `niro login` token instead.
67
+ // - HOME and its Windows forms: where ~/.niro, ~/.niro-local and ~/.pm2 are.
68
+ // - PATH: how the engine and node are found. PM2_HOME: which PM2 this is.
69
+ // - USER, LOGNAME, USERNAME: who the user is, read by PM2 and by Python.
70
+ // - LANG, LANGUAGE, LC_*, TZ: how text is decoded and times are shown.
71
+ // - TMPDIR, TMP, TEMP: where the engine binary unpacks itself and writes temporary files.
72
+ // - NO_COLOR, FORCE_COLOR: whether the watcher's log has colour codes.
73
+ // - NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, SSL_CERT_DIR, the proxy names and NODE_USE_ENV_PROXY:
74
+ // without them a watcher behind a company proxy or private certificate cannot upload.
75
+ // - Windows system names, without which node and Python do not start there, and
76
+ // NoDefaultCurrentDirectoryInExePath (windowsPaths.harden), so cmd.exe never runs a pm2.cmd or
77
+ // node.exe from the current folder.
78
+ const WATCHER_ENV_NAMES = new Set([
79
+ "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
80
+ "PATH", "PM2_HOME",
81
+ "USER", "LOGNAME", "USERNAME",
82
+ "LANG", "LANGUAGE", "TZ",
83
+ "TMPDIR", "TMP", "TEMP",
84
+ "NO_COLOR", "FORCE_COLOR",
85
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
86
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY",
87
+ "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT", "PROGRAMDATA",
88
+ "NODEFAULTCURRENTDIRECTORYINEXEPATH",
89
+ "NIRO_API_URL", "NIRO_MCP_SERVER_URL", "NIRO_BILLING_URL", "NIRO_AUDIT_URL",
90
+ "NIRO_MCP_MODE", "NIRO_MCP_TRANSPORT", "NIRO_DISABLE_ROUTING",
91
+ "NIRO_ENGINE_HOME", "NIRO_ENGINE_LOCK_STALE_MS", "NIRO_ENGINE_LOCK_WAIT_MS",
92
+ "NIRO_LEASE_MIN_RENEW_MS", "NIRO_LEASE_RETRY_MS", "NIRO_LEASE_TIMEOUT_MS",
93
+ "NIRO_DOCS_TIMEOUT_MS", "NIRO_DOCS_MODEL_TIMEOUT_MS", "NIRO_DELETE_SETTLE_MS",
94
+ "NIRO_MISSING_CONFIRM_MS", "NIRO_SSE_LIVENESS_MS",
95
+ "NIRO_UPLOAD_BACKOFF_BASE_MS", "NIRO_UPLOAD_CONCURRENCY", "NIRO_UPLOAD_MAX_FILES",
96
+ "NIRO_UPLOAD_MAX_TOTAL_BYTES", "NIRO_UPLOAD_TIMEOUT_MS", "NIRO_SHARE_BATCH_FILES",
97
+ ]);
98
+ const WATCHER_ENV_PREFIXES = ["NIRO_LOCAL_", "LC_"];
99
+ // NIRO_ names niro-cli reads that hand something from one process to the next, not settings: the
100
+ // bootstrap and upgrade markers of `niro start` / `niro upgrade`, the output indent, and the lease
101
+ // pipe numbers the engine is given by whoever starts it. A watcher that inherited them would act
102
+ // on a handover that was never meant for it.
103
+ // Also the two API keys. When one is set, the coverage audit signs with it instead of the login
104
+ // token (auditUpload.js authHeaders); in a developer's shell it is usually a key for Niro's own
105
+ // services, and it would be saved in dump.pm2. Without them the watcher's audit and journal
106
+ // uploads sign with the `niro login` token (Niraj, 2026-11-06: "yes drop it").
107
+ const WATCHER_ENV_EXCLUDED = new Set([
108
+ "NIRO_START_BOOTSTRAPPED", "NIRO_UPGRADE_HANDOVER", "NIRO_OUTPUT_PREFIX",
109
+ "NIRO_LOCAL_LEASE_FD_IN", "NIRO_LOCAL_LEASE_FD_OUT",
110
+ "NIRO_API_KEY", "NIRO_AUDIT_API_KEY",
111
+ ]);
112
+
113
+ /**
114
+ * True when the watcher is given this variable. Names are compared in capitals: Windows names are
115
+ * not case sensitive (`Path`, `SystemRoot`), and proxies are often written in lower case.
116
+ */
117
+ function watcherNeeds(name) {
118
+ const upper = String(name).toUpperCase();
119
+ if (WATCHER_ENV_EXCLUDED.has(upper)) return false;
120
+ return WATCHER_ENV_NAMES.has(upper) || WATCHER_ENV_PREFIXES.some((p) => upper.startsWith(p));
121
+ }
122
+
123
+ /** The part of `source` the watcher is started with: the names above, values unchanged. */
124
+ function watcherEnv(source = process.env) {
125
+ const env = {};
126
+ for (const [name, value] of Object.entries(source)) {
127
+ if (value !== undefined && watcherNeeds(name)) env[name] = value;
128
+ }
129
+ return env;
130
+ }
131
+
132
+ /**
133
+ * Make PM2's saved process list readable by this user only.
134
+ *
135
+ * PM2 creates dump.pm2 and dump.pm2.bak with the default file mode (664 under the usual umask)
136
+ * and afterwards rewrites them in place, which keeps whatever mode they have; so setting 600 once
137
+ * holds through later `pm2 save` and restart (checked with PM2 7.0.4). After a save niro-cli made,
138
+ * the backup is also replaced by the dump just written: PM2 fills the backup with the previous
139
+ * dump, and on an install made before this change that one holds the whole shell environment.
140
+ */
141
+ function secureDump({ refreshBackup = false } = {}) {
142
+ const dump = path.join(pm2Home(), "dump.pm2");
143
+ const backup = path.join(pm2Home(), "dump.pm2.bak");
50
144
  try {
51
- return fs.readFileSync(path.join(pm2Home(), "dump.pm2"), "utf8").includes(`"${PROCESS_NAME}"`);
145
+ if (refreshBackup && fs.existsSync(dump)) fs.copyFileSync(dump, backup);
146
+ } catch {
147
+ // keep going: the modes below still matter
148
+ }
149
+ for (const file of [dump, backup]) {
150
+ try {
151
+ if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
152
+ } catch {
153
+ // nothing to stop for: niro-watch runs either way
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * True when PM2's saved niro-watch entry holds environment names the watcher is no longer given,
160
+ * which means a niro-cli from before this change saved it with the whole shell environment.
161
+ * PM2 adds names of its own (PM2_HOME, PM2_USAGE, unique_id, the app's name): those start with
162
+ * PM2_ or contain lower case, and are not counted. Neither are the names a shell sets by itself
163
+ * (PWD, OLDPWD, SHLVL, _): when `pm2` is a shell script (a pnpm global install, an asdf shim),
164
+ * the shell adds them to the short list on its way to PM2, and counting them restarted the
165
+ * watcher on every `niro start` (3 of 3 in the check of 2026-11-06). A whole-shell entry still
166
+ * has plenty of other names (SHELL, TERM, the cloud keys), so it is still caught.
167
+ */
168
+ const SHELL_OWN_NAMES = new Set(["PWD", "OLDPWD", "SHLVL", "_"]);
169
+
170
+ function savedEnvIsWide() {
171
+ let list;
172
+ try {
173
+ list = JSON.parse(fs.readFileSync(path.join(pm2Home(), "dump.pm2"), "utf8"));
52
174
  } catch {
53
175
  return false;
54
176
  }
177
+ if (!Array.isArray(list)) return false;
178
+ const entry = list.find((p) => p && p.name === PROCESS_NAME);
179
+ if (!entry || !entry.env || typeof entry.env !== "object") return false;
180
+ return Object.keys(entry.env).some((name) =>
181
+ !watcherNeeds(name) && !/^PM2_/i.test(name) && name !== "NODE_APP_INSTANCE" && !SHELL_OWN_NAMES.has(name)
182
+ && name === name.toUpperCase());
55
183
  }
56
184
 
57
185
  /**
58
- * Install and start the niro-watch PM2 process, then wire it into the OS
59
- * startup system. Prints an explanation before each privileged step.
60
- *
61
- * Returns true on success, false if PM2 is not installed.
186
+ * `pm2 start` for niro-watch. On Windows PM2's daemon starts a bare "node" without a shell, which
187
+ * looks in the folder niro ran from before the PATH, so it is given this node by full path. pm2 runs
188
+ * through cmd.exe there, and Node joins a shell command's words unquoted, so a path with a space
189
+ * ("C:\Program Files", a user folder) is quoted. Elsewhere the PATH's node, which keeps working
190
+ * after a node upgrade.
62
191
  */
63
- function install() {
64
- if (!pm2Available()) return false;
192
+ function pm2StartArgs(niroBin, platform = process.platform, node = process.execPath) {
193
+ if (platform !== "win32") return ["start", niroBin, "--name", PROCESS_NAME, "--", "watch"];
194
+ const word = (p) => (/\s/.test(p) ? `"${p}"` : p);
195
+ return ["start", word(niroBin), "--name", PROCESS_NAME, "--interpreter", word(node), "--", "watch"];
196
+ }
65
197
 
198
+ /**
199
+ * Delete, start and save niro-watch with only the environment it needs, then make the saved list
200
+ * private. Throws when `pm2 start` fails. Never touches the start-on-login hook.
201
+ */
202
+ function startWatcher() {
66
203
  const niroBin = process.argv[1];
204
+ const env = watcherEnv();
67
205
 
68
206
  // Stop any existing instance so we can re-register cleanly
69
- spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "pipe" });
207
+ spawnSync("pm2", ["delete", PROCESS_NAME], { shell: PM2_SHELL, stdio: "pipe", env });
70
208
 
71
209
  // PM2's own output is captured, not inherited. Inheriting it put ~55 lines into the middle
72
210
  // of setup: a box-drawing process table, the entire generated systemd unit file, and the
73
211
  // user's PATH twice. None of it is ours and none of it is actionable while it is working.
74
212
  // It IS printed on failure, where it is the only diagnostic there is.
75
- const startResult = spawnSync(
76
- "pm2",
77
- ["start", niroBin, "--name", PROCESS_NAME, "--", "watch"],
78
- { shell: true, encoding: "utf8" }
79
- );
213
+ const startResult = spawnSync("pm2", pm2StartArgs(niroBin), { shell: PM2_SHELL, encoding: "utf8", env });
80
214
  if (startResult.status !== 0) {
81
215
  console.error(color.dim(`${startResult.stdout || ""}${startResult.stderr || ""}`));
82
216
  throw new Error("pm2 start failed");
83
217
  }
84
218
 
85
219
  // Persist the process list so PM2 restores it after a manual `pm2 resurrect`
86
- spawnSync("pm2", ["save"], { shell: true, encoding: "utf8" });
220
+ const saved = spawnSync("pm2", ["save"], { shell: PM2_SHELL, encoding: "utf8", env });
221
+ secureDump({ refreshBackup: saved.status === 0 });
222
+ }
223
+
224
+ /**
225
+ * For an install made before the watcher's environment was limited: when the saved niro-watch
226
+ * entry still holds the whole shell environment, start it again with the limited one, which
227
+ * rewrites dump.pm2 and its backup; otherwise only make the saved list private. Returns true when
228
+ * it started the watcher again. Asks for no password and leaves the start-on-login hook alone.
229
+ */
230
+ function refreshSavedEnv() {
231
+ if (!pm2Available()) return false;
232
+ if (!savedEnvIsWide()) {
233
+ secureDump();
234
+ return false;
235
+ }
236
+ startWatcher();
237
+ return true;
238
+ }
239
+
240
+ /** True when PM2's saved process list (pm2 save) includes niro-watch. */
241
+ function dumpHasProcess() {
242
+ try {
243
+ return fs.readFileSync(path.join(pm2Home(), "dump.pm2"), "utf8").includes(`"${PROCESS_NAME}"`);
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ // ─── start on login, Windows ─────────────────────────────────────────────────
250
+ //
251
+ // `pm2 startup` knows systemd and launchd, not Windows: there it prints no command, which the
252
+ // install below used to read as "already configured" while nothing would start at login. So on
253
+ // Windows, and only there, one value under the user's own Run key starts PM2's saved list
254
+ // (`pm2 resurrect`) at every login: per user, no administrator rights, removed by uninstall.
255
+ const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
256
+
257
+ /**
258
+ * The Run-key value that starts niro-watch at login on Windows: its name and the command it runs.
259
+ *
260
+ * `pm2Cmd`: pm2.cmd's full path, found on the PATH when the entry is written. At sign-in Windows uses the saved PATH,
261
+ * which may not hold npm's folder (a PATH set only in a shell profile, some nvm, Volta or Scoop set-ups), so a bare
262
+ * name could go unfound and the watcher silently not start. Only when none is found is the bare name used.
263
+ */
264
+ function windowsLoginEntry(pm2Cmd = require("./engine").onThePath("pm2.cmd")) {
265
+ // ponytail: a hidden PowerShell may flash for a moment at login; a VBScript launcher would hide
266
+ // it completely, add it if people notice.
267
+ // pm2.cmd, not pm2: PowerShell would pick npm's pm2.ps1, and Windows 10 and 11 refuse to run
268
+ // scripts by default (Restricted), so nothing would start and nothing would say so.
269
+ // A full path is called with PowerShell's & on a single-quoted string, where a quote is doubled.
270
+ const run = pm2Cmd ? `"& '${pm2Cmd.replace(/'/g, "''")}' resurrect"` : "pm2.cmd resurrect";
271
+ return {
272
+ key: WINDOWS_RUN_KEY,
273
+ name: PROCESS_NAME,
274
+ command: `powershell.exe -NoProfile -WindowStyle Hidden -Command ${run}`,
275
+ };
276
+ }
277
+
278
+ /** Add the Run-key value. True on success, "no_autostart" (the watcher still runs) otherwise. */
279
+ function startOnWindowsLogin(run = spawnSync) {
280
+ const entry = windowsLoginEntry();
281
+ const r = run(system32("reg.exe"), ["add", entry.key, "/v", entry.name, "/t", "REG_SZ", "/d", entry.command, "/f"], {
282
+ stdio: "pipe", encoding: "utf8",
283
+ });
284
+ if (r.status !== 0) {
285
+ console.log(color.yellow(` ⚠ Could not set niro-watch to start on login: ${(r.stderr || r.stdout || "").trim()}`));
286
+ return "no_autostart";
287
+ }
288
+ console.log(` ${color.green("✓")} Starts again at every login.`);
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Remove the Run-key value: `{ hook: "none" }` when it is not there, `"removed"`, or `"manual"` with
294
+ * the command when reg refuses (asked first, so a refusal is never reported as nothing there).
295
+ */
296
+ function stopOnWindowsLogin(run = spawnSync) {
297
+ const entry = windowsLoginEntry();
298
+ const reg = system32("reg.exe");
299
+ if (run(reg, ["query", entry.key, "/v", entry.name], { stdio: "pipe" }).status !== 0) return { hook: "none" };
300
+ if (run(reg, ["delete", entry.key, "/v", entry.name, "/f"], { stdio: "pipe" }).status === 0) return { hook: "removed" };
301
+ return { hook: "manual", command: `reg delete "${entry.key}" /v ${entry.name} /f` };
302
+ }
303
+
304
+ /**
305
+ * What to show before sudo runs, and its `-p` prompt as one single-quoted shell word. sudo itself
306
+ * names the account whose password it wants (%p): the user's own, or root's where sudo is set to
307
+ * ask for that (targetpw, rootpw), and no user lookup is needed (os.userInfo throws in a container
308
+ * with no passwd entry).
309
+ *
310
+ * `sameLineAs`: the heading printed just above (niro start), with nothing after it yet. niro goes
311
+ * back up a line and rewrites it ending " · ", so heading and question share a line. The prompt
312
+ * itself never moves the cursor: sudo repeats it after a wrong password, and a cursor-up in it
313
+ * erased sudo's "Sorry, try again." When sudo still remembers the password it prints no prompt,
314
+ * so the heading is left alone (`sudo -n true` asks without prompting).
315
+ */
316
+ function sudoLead(sameLineAs, pad, run = spawnSync) {
317
+ const quoted = (words) => `'${words.replace(/'/g, "'\\''")}'`;
318
+ const ask = "Password for %p: ";
319
+ if (sameLineAs && run("sudo", ["-n", "true"], { stdio: "ignore" }).status !== 0) {
320
+ return { before: `\x1b[1A\x1b[2K${sameLineAs} · `, prompt: quoted(ask) };
321
+ }
322
+ return { before: "", prompt: quoted(`${pad}${ask}`) };
323
+ }
324
+
325
+ /**
326
+ * Install and start the niro-watch PM2 process, then wire it into the OS
327
+ * startup system. Prints an explanation before each privileged step.
328
+ *
329
+ * Returns true on success, "no_autostart" when niro-watch runs but could not be added to the
330
+ * OS startup (still truthy: the daemon is up), false if PM2 is not installed.
331
+ */
332
+ function install({ sameLineAs = null } = {}) {
333
+ if (!pm2Available()) return false;
334
+
335
+ startWatcher();
336
+
337
+ if (process.platform === "win32") return startOnWindowsLogin();
87
338
 
88
339
  // Wire into OS startup — pm2 startup detects systemd / launchd / etc.
89
340
  // It prints the exact privileged command we need to run.
90
- console.log(
91
- `\n ${color.bold("Setting up auto-start on login")} — PM2 will ask for your password to register`
92
- );
93
- console.log(
94
- ` with the system startup manager (${getInitSystem()}). This only needs to happen once.\n`
95
- );
96
-
97
341
  const startupProbe = spawnSync("pm2", ["startup"], {
98
- shell: true,
342
+ shell: PM2_SHELL,
99
343
  encoding: "utf8",
100
344
  });
101
345
 
102
346
  const setupCmd = extractSetupCommand(startupProbe.stdout + startupProbe.stderr);
103
347
  if (setupCmd) {
104
- console.log(` Running: ${color.dim(setupCmd)}\n`);
348
+ // sudo's own prompt says what the password is for, instead of a paragraph and the command.
349
+ // Spaces, not the rail: sudo mangles the multi-byte "│" in its prompt.
350
+ const pad = " ".repeat(require("./nestedOutput").currentPad().length + 2);
351
+ const lead = sudoLead(sameLineAs, pad);
352
+ const cmd = setupCmd.replace(/^sudo /, `sudo -p ${lead.prompt} `);
353
+ // pm2 writes its launchd plist before it runs its own `mkdir -p`, so a home without
354
+ // ~/Library/LaunchAgents (a new account) fails with ENOENT. Make the folder first.
355
+ if (process.platform === "darwin") {
356
+ fs.mkdirSync(path.join(os.homedir(), "Library", "LaunchAgents"), { recursive: true });
357
+ }
105
358
  // stdio is split: stdin stays connected so sudo can still prompt for the password, but
106
359
  // PM2's forty lines of generated unit file are captured and only shown if it fails.
107
- const r = spawnSync(setupCmd, {
360
+ if (lead.before) process.stdout.write(lead.before);
361
+ const r = spawnSync(cmd, {
108
362
  shell: true,
109
363
  stdio: ["inherit", "pipe", "pipe"],
110
364
  encoding: "utf8",
111
365
  });
112
366
  if (r.status !== 0) {
113
- console.error(color.dim(`${r.stdout || ""}${r.stderr || ""}`));
114
- console.log(
115
- color.yellow(
116
- ` ⚠ Startup hook failed (exit ${r.status}). You can run it manually:\n ${setupCmd}`
117
- )
118
- );
367
+ // PM2's output here is its whole generated plist/unit file; the last line is the reason.
368
+ const reason = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").pop();
369
+ console.log(color.yellow(` ⚠ Could not set niro-watch to start on login${reason ? `: ${reason}` : ""}`));
370
+ console.log(color.dim(` To retry: ${setupCmd}`));
371
+ return "no_autostart";
119
372
  }
120
373
  } else {
121
374
  // PM2 may print nothing if startup is already configured
122
- console.log(` ${color.green("✓")} Startup already configured.`);
375
+ console.log(` ${color.green("✓")} Startup already configured.`);
123
376
  }
124
377
 
125
378
  return true;
126
379
  }
127
380
 
128
381
  /**
129
- * Stop niro-watch and remove it from PM2 + OS startup hooks.
382
+ * Stop niro-watch and take it out of PM2, quietly, without asking for a password.
383
+ *
384
+ * PM2's start-on-login entry belongs to PM2, not to Niro: it starts every app in PM2's saved
385
+ * list. Once niro-watch is out of that list (`pm2 save --force`, so an empty list is saved too),
386
+ * nothing of Niro's starts on login, so the entry is left alone while other PM2 apps use it. When
387
+ * niro-watch was the only one, the entry goes too: on macOS by unloading and deleting the user's
388
+ * own launchd file, which needs no password; elsewhere it needs root, so the command is returned
389
+ * for the caller to show rather than prompting for a password in the middle of an uninstall.
390
+ *
391
+ * Returns false when PM2 is not installed (nothing to remove), else
392
+ * `{ hook: "removed" | "kept" | "none" | "manual", command? }`.
130
393
  */
131
394
  function uninstall() {
132
395
  if (!pm2Available()) return false;
396
+ const quiet = { shell: PM2_SHELL, stdio: "pipe", encoding: "utf8" };
397
+ spawnSync("pm2", ["delete", PROCESS_NAME], quiet);
398
+ const saved = spawnSync("pm2", ["save", "--force"], quiet);
399
+ // The backup PM2 just made is the list from before, niro-watch's environment included.
400
+ secureDump({ refreshBackup: saved.status === 0 });
133
401
 
134
- spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "inherit" });
135
- spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
402
+ const listed = spawnSync("pm2", ["jlist"], quiet);
403
+ let others = 0;
404
+ try { others = JSON.parse(listed.stdout || "[]").length; } catch { others = 1; /* unsure: keep it */ }
405
+ if (others > 0) return { hook: "kept" };
136
406
 
137
- console.log(
138
- `\n ${color.bold("Removing auto-start hook")} — PM2 may ask for your password.\n`
139
- );
140
- const unstartupResult = spawnSync("pm2", ["unstartup"], {
141
- shell: true,
142
- encoding: "utf8",
143
- });
144
- const setupCmd = extractSetupCommand(
145
- unstartupResult.stdout + unstartupResult.stderr
146
- );
147
- if (setupCmd) {
148
- console.log(` Running: ${color.dim(setupCmd)}\n`);
149
- spawnSync(setupCmd, { shell: true, stdio: "inherit" });
150
- }
407
+ if (process.platform === "win32") return stopOnWindowsLogin();
151
408
 
152
- return true;
409
+ if (process.platform === "darwin") {
410
+ const user = os.userInfo().username;
411
+ const plist = path.join(os.homedir(), "Library", "LaunchAgents", `pm2.${user}.plist`);
412
+ if (!fs.existsSync(plist)) return { hook: "none" };
413
+ spawnSync("launchctl", ["unload", "-w", plist], { stdio: "pipe" });
414
+ try {
415
+ fs.unlinkSync(plist);
416
+ return { hook: "removed" };
417
+ } catch {
418
+ // fall through to the command
419
+ }
420
+ }
421
+ const probe = spawnSync("pm2", ["unstartup"], quiet);
422
+ const command = extractSetupCommand(`${probe.stdout || ""}${probe.stderr || ""}`);
423
+ return command ? { hook: "manual", command } : { hook: "none" };
153
424
  }
154
425
 
155
426
  /**
@@ -157,16 +428,38 @@ function uninstall() {
157
428
  *
158
429
  * `npm install -g` swaps the files on disk. A process that is already running keeps the code it
159
430
  * 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.
431
+ * runs. It registers the script of the niro running this (process.argv[1]); during
432
+ * `niro upgrade --finish` that is the copy just installed. Returns false when PM2 is missing or
433
+ * the start failed; the caller decides what to say.
161
434
  */
162
435
  function restart() {
163
436
  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;
437
+ // Not `pm2 restart`: that keeps the environment saved when the watcher was first started, which
438
+ // on an install from before 2026-11-06 is the whole shell. Delete, start and save instead, with
439
+ // the short list, then make the saved list private. No `pm2 startup`, so no password prompt.
440
+ try {
441
+ startWatcher();
442
+ return true;
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * When the running niro-watch process started, in ms since the epoch, or null when PM2 cannot
450
+ * say. Compared with the install time of this copy to tell a watcher still on old code.
451
+ */
452
+ function startedAt() {
453
+ if (!pm2Available() || !pm2DaemonAlive()) return null;
454
+ const r = spawnSync("pm2", ["jlist"], { shell: PM2_SHELL, stdio: "pipe", encoding: "utf8" });
455
+ if (r.status !== 0) return null;
456
+ try {
457
+ const proc = JSON.parse(r.stdout).find((p) => p.name === PROCESS_NAME);
458
+ const at = proc && proc.pm2_env && proc.pm2_env.pm_uptime;
459
+ return typeof at === "number" ? at : null;
460
+ } catch {
461
+ return null;
462
+ }
170
463
  }
171
464
 
172
465
  /**
@@ -177,7 +470,7 @@ function status() {
177
470
  console.log(color.yellow(" PM2 is not installed. Run: npm install -g pm2"));
178
471
  return;
179
472
  }
180
- spawnSync("pm2", ["show", PROCESS_NAME], { shell: true, stdio: "inherit" });
473
+ spawnSync("pm2", ["show", PROCESS_NAME], { shell: PM2_SHELL, stdio: "inherit" });
181
474
  }
182
475
 
183
476
  /**
@@ -196,7 +489,7 @@ function processState() {
196
489
  return dumpHasProcess() ? "stopped" : "missing";
197
490
  }
198
491
  const r = spawnSync("pm2", ["show", PROCESS_NAME], {
199
- shell: true,
492
+ shell: PM2_SHELL,
200
493
  encoding: "utf8",
201
494
  stdio: "pipe",
202
495
  });
@@ -209,8 +502,14 @@ function processState() {
209
502
  * the user sees progress and any permission error verbatim. Returns true on
210
503
  * success. shell:true so npm.cmd resolves on Windows.
211
504
  */
212
- function installPm2() {
213
- const r = spawnSync("npm", ["install", "-g", "pm2"], { shell: true, stdio: "inherit" });
505
+ function installPm2({ quiet = false } = {}) {
506
+ const r = spawnSync("npm", ["install", "-g", "pm2"], {
507
+ shell: process.platform === "win32",
508
+ stdio: quiet ? "pipe" : "inherit",
509
+ encoding: "utf8",
510
+ });
511
+ // quiet: npm's lines are shown only when it failed, where they are the only diagnostic.
512
+ if (quiet && r.status !== 0) console.log(color.dim(`${r.stdout || ""}${r.stderr || ""}`.trim()));
214
513
  return r.status === 0;
215
514
  }
216
515
 
@@ -243,4 +542,14 @@ function getInitSystem() {
243
542
  }
244
543
  }
245
544
 
246
- module.exports = { install, uninstall, restart, status, processState, pm2Available, installPm2 };
545
+ module.exports = {
546
+ windowsLoginEntry,
547
+ startOnWindowsLogin,
548
+ stopOnWindowsLogin,
549
+ pm2StartArgs,
550
+ sudoLead,
551
+ install, uninstall, restart, startedAt, status, processState, pm2Available, installPm2,
552
+ refreshSavedEnv,
553
+ // exported for tests
554
+ watcherEnv, savedEnvIsWide, secureDump,
555
+ };