@niroai/niro 0.17.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 +136 -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 +6 -2
  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
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tell a developer when one project's folders have drifted onto different branches.
3
+ *
4
+ * **What a local project is.** The engine (`niro-local`) indexes one or more folders as one
5
+ * project, and every folder's nodes live in one graph. That is what makes a cross-service
6
+ * question possible: service A's call can be linked to service B's handler.
7
+ *
8
+ * **What goes wrong after they are attached.** Attaching is guarded: the engine reads each
9
+ * folder's branch from `.git/HEAD` and refuses a folder whose branch differs from the project's.
10
+ * Since 2026-11-04 the engine also keeps one index per folder AND branch, and on every call
11
+ * answers from the index of the branch the folder is on now, or offers to index it (Niraj:
12
+ * "when branch changes, we don't warn but we can offer to index the same folder that's on a
13
+ * different branch or then switch to the respective project"). So the mixed graph this notice
14
+ * was written for now arises only for a project attached before branches were read, whose
15
+ * folders have since gone separate ways. The notice stays for those.
16
+ *
17
+ * **Why the engine is asked and git is read here as well.** One folder can now be listed in two
18
+ * projects, one per branch, so the project holding this folder is the one whose row is for the
19
+ * branch git reports now (`projectHolding`).
20
+ *
21
+ * Nothing here changes an answer. It adds a line to one, once per session.
22
+ */
23
+ const path = require("path");
24
+
25
+ const git = require("./git");
26
+ const localEngine = require("./localEngine");
27
+
28
+ /**
29
+ * The project the engine says holds this folder, with the folders it holds, or null.
30
+ *
31
+ * Null for every reason that is not this notice's business: no engine on the machine, an
32
+ * engine that cannot answer, a folder in no project, or output that does not parse. A missing
33
+ * answer here must never stop a tool call.
34
+ */
35
+ async function projectHolding(dir, { run = localEngine.run, inspect = git.inspect } = {}) {
36
+ let out;
37
+ try {
38
+ out = await run(["projects", "--json"]);
39
+ } catch {
40
+ return null;
41
+ }
42
+ let parsed = null;
43
+ try {
44
+ parsed = JSON.parse(out);
45
+ } catch {
46
+ return null;
47
+ }
48
+ const projects = (parsed && Array.isArray(parsed.projects) && parsed.projects) || [];
49
+ const resolved = path.resolve(dir);
50
+ let best = null;
51
+ for (const project of projects) {
52
+ for (const worktree of (project && project.worktrees) || []) {
53
+ if (!worktree || typeof worktree.path !== "string") continue;
54
+ const folder = path.resolve(worktree.path);
55
+ // The folder itself, or an ancestor of it: a session can start in a subdirectory, and
56
+ // the engine resolves a worktree the same way. The longest match wins, so a worktree
57
+ // nested inside another is attributed to the nearer one.
58
+ const inside = resolved === folder || resolved.startsWith(folder + path.sep);
59
+ if (!inside) continue;
60
+ if (!best || folder.length > best.folder.length) {
61
+ best = { folder, rows: [{ project, branch: worktree.branch || null, used: worktree.last_used_at || "" }] };
62
+ } else if (folder.length === best.folder.length) {
63
+ best.rows.push({ project, branch: worktree.branch || null, used: worktree.last_used_at || "" });
64
+ }
65
+ }
66
+ }
67
+ if (!best) return null;
68
+ // The project whose row is for the branch the folder is on now, as the engine chooses
69
+ // (`registry.choose_row`), whether the folder has one row or several. None when no row is for
70
+ // that branch: the engine then answers with the offer to index it and from no project, so a
71
+ // notice about a project's branches would describe a project that is not answering (independent
72
+ // check, verify/offer/drift.js). A row with no branch, or a folder git cannot name a branch for
73
+ // (outside git, detached), answers from the engine's last used row.
74
+ let now = null;
75
+ try {
76
+ now = (inspect(best.folder) || {}).branch || null;
77
+ } catch {
78
+ now = null;
79
+ }
80
+ // Several projects can hold one folder on one branch (a shared library, a copy per project): the
81
+ // one used last answers, as the engine chooses.
82
+ const lastUsed = (rows) => rows.reduce((best2, row) => (!best2 || row.used > best2.used ? row : best2), null);
83
+ if (now === null) return lastUsed(best.rows).project;
84
+ const match = lastUsed(best.rows.filter((row) => row.branch === now))
85
+ || (best.rows.length === 1 && best.rows[0].branch === null ? best.rows[0] : null);
86
+ return match ? match.project : null;
87
+ }
88
+
89
+ /**
90
+ * Which branch each of this project's folders was indexed on, and whether they disagree.
91
+ *
92
+ * The branch a row records is what its part of the graph holds, whichever branch the folder has
93
+ * moved to since: a folder that moved answers from another project, or a temporary copy, and this
94
+ * project's graph is still one branch's. So only a row recorded with no branch (attached before
95
+ * branches were read) is looked up from git (independent check, finding 1).
96
+ *
97
+ * Returns null when there is nothing to say: no project, one folder, git cannot answer for
98
+ * enough of them, or they all agree. A folder git cannot answer for (no repository, a detached
99
+ * HEAD) is left out rather than reported as a disagreement, because "no branch" is not a
100
+ * different branch and guessing would make this noise.
101
+ */
102
+ async function check(dir, { run = localEngine.run, inspect = git.inspect } = {}) {
103
+ const project = await projectHolding(dir, { run, inspect });
104
+ if (!project) return null;
105
+ const folders = [];
106
+ for (const worktree of project.worktrees || []) {
107
+ if (!worktree || typeof worktree.path !== "string") continue;
108
+ let branch = worktree.branch || null;
109
+ if (!branch) {
110
+ try {
111
+ branch = (inspect(worktree.path) || {}).branch || null;
112
+ } catch {
113
+ branch = null;
114
+ }
115
+ }
116
+ if (branch) folders.push({ path: worktree.path, branch, recorded: worktree.branch || null });
117
+ }
118
+ // One set of branch names decides it, and it covers the small cases too: a project with
119
+ // one folder, or one folder git could answer for, has one name in that set and therefore
120
+ // nothing to disagree with. A separate count of the folders read the same way and could not
121
+ // be made to fail a test, so it is not here.
122
+ const branches = [...new Set(folders.map((entry) => entry.branch))].sort();
123
+ if (branches.length < 2) return null;
124
+ return { name: project.name, folders, branches };
125
+ }
126
+
127
+ /** The notice, as one block of text, or null when `check` found nothing. */
128
+ function notice(found) {
129
+ if (!found) return null;
130
+ const lines = [
131
+ `NOTE FROM niro: the folders of project '${found.name}' are on different branches ` +
132
+ `(${found.branches.join(", ")}), so an answer that crosses them links one branch's ` +
133
+ "calls to another branch's code.",
134
+ ];
135
+ for (const entry of found.folders) {
136
+ lines.push(` ${entry.path} is on ${entry.branch}`);
137
+ }
138
+ lines.push(
139
+ "Put them on one branch, or ask only about the folder this session is in."
140
+ );
141
+ return lines.join("\n");
142
+ }
143
+
144
+ /** `check` and `notice` in one call, which is how the bridge uses it. */
145
+ async function noticeFor(dir, options = {}) {
146
+ return notice(await check(dir, options));
147
+ }
148
+
149
+ module.exports = { check, notice, noticeFor, projectHolding };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Arrow-key pickers for the wizards: ↑/↓ (or j/k) move, space ticks (multi), a ticks all, enter confirms.
3
+ *
4
+ * No dependency: stdin raw mode plus readline's keypress events. `reduce` is the whole
5
+ * behaviour as a pure function, so it is tested without a terminal. Without a TTY the
6
+ * picker answers with its defaults instead of hanging.
7
+ */
8
+ const readline = require("readline");
9
+ const color = require("./color");
10
+
11
+ /** One key applied to the picker state. Returns the next state; `done` holds the answer. */
12
+ function reduce(state, key) {
13
+ const { cursor, picked, count, multi } = state;
14
+ const name = key && key.name;
15
+ if (name === "up" || name === "k") return { ...state, cursor: (cursor - 1 + count) % count };
16
+ if (name === "down" || name === "j") return { ...state, cursor: (cursor + 1) % count };
17
+ if (multi && name === "space") {
18
+ const next = new Set(picked);
19
+ if (next.has(cursor)) next.delete(cursor);
20
+ else next.add(cursor);
21
+ return { ...state, picked: next };
22
+ }
23
+ if (multi && name === "a") {
24
+ // Everything, or nothing when everything is already ticked.
25
+ const all = picked.size === count;
26
+ return { ...state, picked: new Set(all ? [] : Array.from({ length: count }, (_, i) => i)) };
27
+ }
28
+ if (name === "return" || name === "enter") {
29
+ if (!multi) return { ...state, done: [cursor] };
30
+ // Enter with nothing ticked means "this one", the way a single picker reads.
31
+ return { ...state, done: picked.size ? [...picked].sort((a, b) => a - b) : [cursor] };
32
+ }
33
+ return state;
34
+ }
35
+
36
+ function render(items, state, pad) {
37
+ const width = Math.max(...items.map((it) => it.label.length));
38
+ return items.map((it, i) => {
39
+ const here = i === state.cursor;
40
+ const pointer = here ? color.cyan("❯") : " ";
41
+ const box = state.multi ? `${state.picked.has(i) ? color.green("◉") : "◯"} ` : "";
42
+ const name = it.detail ? it.label.padEnd(width) : it.label;
43
+ const label = here ? color.bold(name) : name;
44
+ return `${pad}${pointer} ${box}${label}${it.detail ? ` ${color.dim(it.detail)}` : ""}`;
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Ask once. `items` are { value, label, detail? }; `initial` the values ticked (multi) or
50
+ * under the cursor (single) to begin with; `echo: false` when the caller prints the answer itself.
51
+ * Resolves to an array of values (multi) or one value.
52
+ */
53
+ function choose({ items, multi = false, initial = [], prefix = " ", hint, echo = true } = {}) {
54
+ const start = items.findIndex((it) => initial.includes(it.value));
55
+ let state = {
56
+ cursor: Math.max(0, start),
57
+ picked: new Set(multi ? items.map((it, i) => (initial.includes(it.value) ? i : -1)).filter((i) => i >= 0) : []),
58
+ count: items.length,
59
+ multi,
60
+ done: null,
61
+ };
62
+ const answer = (idx) => (multi ? idx.map((i) => items[i].value) : items[idx[0]].value);
63
+
64
+ const input = process.stdin;
65
+ if (!input.isTTY) return Promise.resolve(answer(multi && state.picked.size ? [...state.picked] : [state.cursor]));
66
+
67
+ const out = process.stdout;
68
+ const help = hint || (multi ? "↑/↓ move · space select · a all · enter confirm" : "↑/↓ move · enter select");
69
+ let drawn = 0;
70
+ const draw = () => {
71
+ if (drawn) {
72
+ readline.moveCursor(out, 0, -drawn);
73
+ readline.clearScreenDown(out);
74
+ }
75
+ const lines = [...render(items, state, prefix), `${prefix}${color.dim(help)}`];
76
+ out.write(lines.join("\n") + "\n");
77
+ drawn = lines.length;
78
+ };
79
+
80
+ return new Promise((resolve) => {
81
+ readline.emitKeypressEvents(input);
82
+ const wasRaw = input.isRaw;
83
+ input.setRawMode(true);
84
+ input.resume();
85
+ out.write("\x1b[?25l"); // hide the caret while the list owns the screen
86
+ const finish = () => {
87
+ input.removeListener("keypress", onKey);
88
+ input.setRawMode(wasRaw);
89
+ input.pause();
90
+ out.write("\x1b[?25h");
91
+ };
92
+ const onKey = (_s, key) => {
93
+ if (key && key.ctrl && key.name === "c") {
94
+ finish();
95
+ out.write("\n");
96
+ process.exit(130);
97
+ }
98
+ state = reduce(state, key);
99
+ if (state.done) {
100
+ finish();
101
+ // Collapse the list to the answer, so the transcript keeps only what was chosen.
102
+ readline.moveCursor(out, 0, -drawn);
103
+ readline.clearScreenDown(out);
104
+ const chosen = state.done.map((i) => items[i].label).join(", ");
105
+ if (echo) out.write(`${prefix}${color.green("✓")} ${chosen}\n`);
106
+ resolve(answer(state.done));
107
+ return;
108
+ }
109
+ draw();
110
+ };
111
+ input.on("keypress", onKey);
112
+ draw();
113
+ });
114
+ }
115
+
116
+ module.exports = { choose, reduce };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Stop a cloud command from being run on a folder the engine on this machine already indexes.
3
+ *
4
+ * **The problem this exists for.** Three commands put a folder into Niro's cloud: `niro init`,
5
+ * `niro add repo` and `niro build`. None of them knew about the local engine, so a developer
6
+ * whose folder is indexed on their own machine could run one of them and have that folder's
7
+ * source uploaded and indexed a second time. Nothing broke: the MCP bridge answers every tool
8
+ * call from the local graph whether or not a cloud copy exists, and the watcher takes the local
9
+ * branch before it reaches the cloud one. What was lost was the only reason to choose the local
10
+ * engine, which is that the source stays on the machine. The customer also paid for two indexes
11
+ * of one folder, and the cloud copy then went quietly stale, because nothing ever asked it
12
+ * anything again.
13
+ *
14
+ * **Why a refusal and not a prompt.** `niro build` is run from scripts, and `niro init` is run
15
+ * by the setup wizard. A prompt in either would hang a script with no terminal attached. A
16
+ * refusal with the two commands that resolve it is readable in a log and cannot hang.
17
+ *
18
+ * **Why `niro new-temp-project` is NOT guarded**, although it uploads a working tree too:
19
+ * temporary projects stay in the cloud on purpose (decision 3 of the plan), because they serve
20
+ * pull request review and root cause analysis, which the local engine does not do. It is also
21
+ * run automatically by an assistant following `should_create_temp_project`, so a refusal there
22
+ * would break that route. It gets `noticeIfIndexedLocally` instead, which says what is about to
23
+ * happen in one line and carries on.
24
+ */
25
+ const localBuild = require("./localBuild");
26
+ const color = require("./color");
27
+
28
+ /**
29
+ * Refuse `command` when `dir` is indexed by the engine on this machine.
30
+ *
31
+ * Returns true when it refused, so the caller returns without doing anything else. Sets
32
+ * `process.exitCode`, rather than throwing, so a refusal reads as a refusal in a script
33
+ * instead of as a crash.
34
+ *
35
+ * A machine with no engine, or one whose engine cannot be run, answers "not indexed locally"
36
+ * (`localBuild.isLocallyIndexed` swallows the failure), so every existing customer is
37
+ * unaffected by this check.
38
+ */
39
+ async function refuseIfIndexedLocally(dir, { command, log = console.error } = {}) {
40
+ if (!(await localBuild.isLocallyIndexed(dir))) return false;
41
+ log("");
42
+ log(color.red(` This folder is indexed on this machine, so \`niro ${command}\` was not run.`));
43
+ log("");
44
+ log(` ${dir}`);
45
+ log(" is held in the local graph by niro-local. Running this command would upload its");
46
+ log(" source to Niro and index it a second time, which is the one thing the local engine");
47
+ log(" is for, and you would be charged for both copies.");
48
+ log("");
49
+ log(` To update the local index: ${color.bold(`niro-local build --path ${dir}`)}`);
50
+ log(` To move it to the cloud: ${color.bold(`niro-local remove --path ${dir}`)}`);
51
+ log(` then run \`niro ${command}\` again`);
52
+ log("");
53
+ process.exitCode = 1;
54
+ return true;
55
+ }
56
+
57
+ /**
58
+ * Say that this folder is indexed locally, and carry on.
59
+ *
60
+ * For a command that is allowed to reach the cloud for a locally indexed folder, where the
61
+ * developer should still know that a copy of the working tree is about to leave the machine.
62
+ */
63
+ async function noticeIfIndexedLocally(dir, { what, log = console.log } = {}) {
64
+ if (!(await localBuild.isLocallyIndexed(dir))) return false;
65
+ log("");
66
+ log(color.yellow(` Note: this folder is indexed on this machine by niro-local.`));
67
+ log(` ${what} uploads a copy of the working tree to Niro anyway, because it serves`);
68
+ log(" pull request review and root cause analysis, which the local graph does not answer.");
69
+ log(" Your local index and the answers from it are unchanged.");
70
+ log("");
71
+ return true;
72
+ }
73
+
74
+ module.exports = { refuseIfIndexedLocally, noticeIfIndexedLocally };
@@ -52,8 +52,8 @@ function isExpired(token) {
52
52
  }
53
53
  }
54
54
 
55
- function requireCreds() {
56
- const c = load();
55
+ /** `c` defaults to the saved file; a caller that read it through the module (so a test can stub it) passes it in. */
56
+ function requireCreds(c = load()) {
57
57
  // Only block when there is no usable credential at all. If the JWT is
58
58
  // expired but a durable cliToken exists, the request layer will
59
59
  // transparently refresh it, so do not throw here.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Whether any folder on this machine is built with `niro-local build --llm`, for `niro docs-send`.
3
+ * Split out of docsUpload.js so that file stays under the monorepo's 500-line flag.
4
+ *
5
+ * Two ways to find out, newest first: ask the engine `docs --marked`, which needs no licence,
6
+ * and, for an engine from before that flag, read what `docs --emit` printed.
7
+ */
8
+ const localEngine = require("./localEngine");
9
+
10
+ /**
11
+ * Whether `docs --emit` found any folder built with --llm, read from its standard error. Used
12
+ * only with an engine that cannot answer `docs --marked` (see markedFolders).
13
+ *
14
+ * `docs --model-info` cannot say: it answers before the engine looks at any folder (niro-local
15
+ * `reuse/command.py`, the `args.model_info` branch returns first). The emit path prints
16
+ * "<n> request lines for <m> methods" only after it has gone through the marked folders, and
17
+ * "No folder is built with --llm" instead when there are none (same file). The positive line is
18
+ * matched, not the negative one, so a reworded message errs towards NOT downloading 90 MB onto
19
+ * a machine that never asked for documentation.
20
+ */
21
+ function foldersMarked(emitNotes) {
22
+ return /\d+ request lines for \d+ methods/.test(String(emitNotes || ""));
23
+ }
24
+
25
+ /**
26
+ * How many folders are built with --llm, asked of the engine without a licence:
27
+ * `niro-local docs --marked`, one JSON line `{"marked": <n>}` on standard output, exit 0.
28
+ *
29
+ * That flag is the engine change this needs (NOTES.md, "Engine change needed"): the engine has
30
+ * no unlicensed way to say it today. `docs --model-info` is the only unlicensed docs action and
31
+ * answers before any folder is looked at (niro-local `cli.py` `_cmd_docs`, `reuse/command.py`).
32
+ *
33
+ * Returns the number, "no-engine" when there is no engine to run, or null when this engine
34
+ * cannot say: an older engine refuses the unknown flag (argparse exits 2), and anything else
35
+ * that is not exactly the shape above is treated the same, so the round falls back to emit
36
+ * rather than skipping documentation on a misread.
37
+ */
38
+ async function markedFolders() {
39
+ let out;
40
+ try {
41
+ out = await localEngine.run(["docs", "--marked"]);
42
+ } catch (err) {
43
+ const message = (err && err.message) || String(err);
44
+ return /^cannot run /.test(message) ? "no-engine" : null;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(String(out).trim());
48
+ const n = parsed && parsed.marked;
49
+ return Number.isInteger(n) && n >= 0 ? n : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ module.exports = { markedFolders, foldersMarked };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The two pieces of state `niro docs-send` keeps on disk, under ~/.niro, split out of
3
+ * docsUpload.js so each file has one job.
4
+ *
5
+ * - `docs-backoff.json`: batch hashes and times, so a batch the service keeps failing on is
6
+ * asked for less and less often instead of on every round. Never code.
7
+ * - `docs-send.lock`: who is running a round now, so the watcher and a person typing
8
+ * `niro docs-send` never send the same batch twice at once.
9
+ */
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+
14
+ /** Backoff: 10 minutes after the first failure, doubling, never more than a day. */
15
+ const BACKOFF_BASE_MS = 10 * 60 * 1000;
16
+ const BACKOFF_MAX_MS = 24 * 60 * 60 * 1000;
17
+ /** A backoff entry this long past its time is forgotten: the batch has gone or succeeded. */
18
+ const BACKOFF_FORGET_MS = 7 * 24 * 60 * 60 * 1000;
19
+ /** And the file never holds more than this many, newest kept, so it cannot grow for ever. */
20
+ const BACKOFF_MAX_ENTRIES = 5000;
21
+
22
+ /** A lock file this old with no pid in it is a crash between create and write, not a run. */
23
+ const EMPTY_LOCK_GRACE_MS = 10_000;
24
+
25
+ /**
26
+ * A lock not touched for this long is taken over whatever its pid says.
27
+ *
28
+ * Why it is needed: a pid is reused. A lock left by a run that died can name a pid that now
29
+ * belongs to an unrelated live process (the independent check found pid 1 in a three-day-old
30
+ * lock), and the pid test alone would then block documentation for ever.
31
+ *
32
+ * Why two hours: a live run touches the lock before every step (emit, each model file, each
33
+ * batch, the import), so the age is the length of ONE step, not of the round. The longest step
34
+ * by default is a model file download (`NIRO_DOCS_MODEL_TIMEOUT_MS`, 15 minutes) or one batch
35
+ * (`NIRO_DOCS_TIMEOUT_MS`, 180 seconds); emit on a large first build, which embeds every
36
+ * documented method, is the only step without a timeout of its own and is minutes, not hours.
37
+ * Two hours is eight times the longest timed step, and a stuck run is replaced within the day.
38
+ */
39
+ const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
40
+
41
+ /** Read per call, because HOME can change between calls in a test and the answer must follow. */
42
+ function niroDir() {
43
+ return path.join(os.homedir(), ".niro");
44
+ }
45
+
46
+ function backoffFile() {
47
+ return path.join(niroDir(), "docs-backoff.json");
48
+ }
49
+
50
+ function lockFile() {
51
+ return path.join(niroDir(), "docs-send.lock");
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------------------------
55
+ // One run at a time
56
+ // ---------------------------------------------------------------------------------------------
57
+
58
+ function pidAlive(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch (err) {
63
+ // EPERM: the process exists and belongs to somebody else. Still alive.
64
+ return Boolean(err && err.code === "EPERM");
65
+ }
66
+ }
67
+
68
+ /** Whether an existing lock file belongs to a run that is still going. */
69
+ function lockHeld(file) {
70
+ let text;
71
+ let stat;
72
+ try {
73
+ text = fs.readFileSync(file, "utf8").trim();
74
+ stat = fs.statSync(file);
75
+ } catch {
76
+ // Gone between the failed create and this read: not held any more.
77
+ return false;
78
+ }
79
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) return false;
80
+ const pid = Number(text);
81
+ if (Number.isInteger(pid) && pid > 0) return pidAlive(pid);
82
+ // Created but the pid not written yet, which is a window of microseconds for a live run. Only
83
+ // an old empty file is a leftover.
84
+ return Date.now() - stat.mtimeMs < EMPTY_LOCK_GRACE_MS;
85
+ }
86
+
87
+ /**
88
+ * Take the run lock, or return null when another run holds it.
89
+ *
90
+ * O_EXCL ("wx") makes the create itself the test, so two runs cannot both create it. A lock left
91
+ * by a run that died (its pid no longer exists), or not touched for LOCK_STALE_MS, is removed
92
+ * and the create tried once more.
93
+ *
94
+ * Returns the release function, which also carries `touch()`: the run calls it before each step
95
+ * so a live lock never looks stale.
96
+ *
97
+ * Known narrow race: two runs that both find the same dead lock at the same moment can both
98
+ * remove it, and the second removal can take away the first run's new lock. It needs a crash
99
+ * followed by two starts within the same few microseconds, and the cost is one batch documented
100
+ * twice, which the engine's store accepts.
101
+ */
102
+ function acquireLock(file = lockFile()) {
103
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
104
+ for (let attempt = 0; attempt < 2; attempt += 1) {
105
+ try {
106
+ const fd = fs.openSync(file, "wx", 0o600);
107
+ try {
108
+ fs.writeSync(fd, String(process.pid));
109
+ } finally {
110
+ fs.closeSync(fd);
111
+ }
112
+ const ours = () => {
113
+ try {
114
+ return fs.readFileSync(file, "utf8").trim() === String(process.pid);
115
+ } catch {
116
+ return false;
117
+ }
118
+ };
119
+ const release = () => {
120
+ // Removed only if it is still ours, so a lock a later run took over is left alone.
121
+ try {
122
+ if (ours()) fs.unlinkSync(file);
123
+ } catch {
124
+ // Already gone.
125
+ }
126
+ };
127
+ release.touch = () => {
128
+ try {
129
+ if (ours()) {
130
+ const now = new Date();
131
+ fs.utimesSync(file, now, now);
132
+ }
133
+ } catch {
134
+ // Gone or unwritable: the next step still runs, and only the age check is affected.
135
+ }
136
+ };
137
+ return release;
138
+ } catch (err) {
139
+ if (!err || err.code !== "EEXIST") throw err;
140
+ if (lockHeld(file)) return null;
141
+ try {
142
+ fs.unlinkSync(file);
143
+ } catch (gone) {
144
+ if (!gone || gone.code !== "ENOENT") throw gone;
145
+ }
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------------------------
152
+ // Backoff: hashes and times only
153
+ // ---------------------------------------------------------------------------------------------
154
+
155
+ const BATCH_ID = /^[0-9a-f]{40}$/;
156
+
157
+ function loadBackoff() {
158
+ try {
159
+ const parsed = JSON.parse(fs.readFileSync(backoffFile(), "utf8"));
160
+ // An array passes `typeof === "object"` and would drop every key we set on it
161
+ // (the trap periodicClaim.js describes). Anything but a plain object starts empty.
162
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
163
+ return parsed;
164
+ } catch {
165
+ return {};
166
+ }
167
+ }
168
+
169
+ function saveBackoff(state) {
170
+ const file = backoffFile();
171
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
172
+ const tmp = `${file}.${process.pid}.tmp`;
173
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
174
+ fs.renameSync(tmp, file);
175
+ }
176
+
177
+ /** Forget what is long past, and keep the newest entries only, so the file stays small. */
178
+ function pruneBackoff(state, now) {
179
+ const kept = Object.entries(state).filter(
180
+ ([batch, entry]) =>
181
+ BATCH_ID.test(batch) &&
182
+ entry &&
183
+ Number.isFinite(Number(entry.until)) &&
184
+ Number(entry.until) > now - BACKOFF_FORGET_MS
185
+ );
186
+ kept.sort((a, b) => Number(b[1].until) - Number(a[1].until));
187
+ return Object.fromEntries(kept.slice(0, BACKOFF_MAX_ENTRIES));
188
+ }
189
+
190
+ function waitingUntil(state, batch, now) {
191
+ const entry = state[batch];
192
+ return entry && Number(entry.until) > now ? Number(entry.until) : 0;
193
+ }
194
+
195
+ function recordFailure(state, batch, now) {
196
+ if (!BATCH_ID.test(String(batch))) return;
197
+ const failures = (Number(state[batch] && state[batch].failures) || 0) + 1;
198
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 20));
199
+ state[batch] = { failures, until: now + delay };
200
+ }
201
+
202
+ module.exports = {
203
+ acquireLock,
204
+ backoffFile,
205
+ lockFile,
206
+ loadBackoff,
207
+ saveBackoff,
208
+ pruneBackoff,
209
+ waitingUntil,
210
+ recordFailure,
211
+ BACKOFF_BASE_MS,
212
+ LOCK_STALE_MS,
213
+ };