@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
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Send a whole repository to Niro, when the person typing `niro audit-push-repo` answers yes.
3
+ *
4
+ * **What it is for, since 2026-11-11.** The way a user sends a repository to Niro for support:
5
+ * when something is wrong and they are happy for us to look at their code (Niraj, 2026-11-11: "We
6
+ * plan to ask users to use it when something is wrong and they're happy to share the repo with us
7
+ * to have a look at it"). Typing the command and answering yes is the consent, so it works with
8
+ * "Help improve Niro" on or off, on every plan, and with a lease past its expiry time. Before that
9
+ * day it needed the plan's `audit_full_repo` claim and sharing on (the rule of 2026-11-05); neither is
10
+ * checked now. `audit_full_repo` was dropped because niro-billing does not set it on every plan
11
+ * (LeaseService.auditFullRepoFor: true only on a plan marked "by terms" or for an account that gave
12
+ * consent, and the seeded Free plan is neither). Decision 01M75P5GYJBJ4NG3FHGSP2NJHE.
13
+ *
14
+ * **What this is, and why it is separate from `audit-send`.** The coverage audit normally
15
+ * sends only the files it flagged: a file is flagged when a line looks like a declaration or
16
+ * a route and the graph holds nothing covering it, which is a handful of files out of a
17
+ * repository. This command sends the whole repository, which is also how we get a corpus to
18
+ * measure the parser against rather than only the places it already failed. Decision 13 of the
19
+ * local-engine plan (which, until 2026-11-11, limited it to plans that allowed it).
20
+ *
21
+ * **Where it lands** (Niraj, 2026-10-16, answering the open question): "in S3 (locally minio)
22
+ * under the customer's account == exactly the same location if customer was uploading it as
23
+ * AGENT_UPLOAD". So this is not a new destination: it is the upload path the CLI has always
24
+ * had, `POST /api/agent/repos` for a workspace and then the ordinary sync chain, which stores
25
+ * source at `source/<accountId>/<repoId>/<path>` when the service runs in object-store mode
26
+ * (`niro-commons` `ObjectStoreSourceStore`). Account-scoped, so an erasure request is a
27
+ * prefix delete, and nothing in that chain creates a project
28
+ * (`AgentSyncController.registerAgentRepo` allocates a workspace and returns a git id).
29
+ *
30
+ * **This is the highest-consequence code in the plan**, which is its own risk 6: a wrong
31
+ * permission, or a push that ignores the gitignore pass, uploads a private repository nobody
32
+ * agreed to send. Three things follow from that and none of them is optional.
33
+ *
34
+ * 1. **It refuses rather than guesses.** The licence lease is still asked for, for the account
35
+ * id, and a service that refused, a service that could not be reached or a lease that will
36
+ * not parse is a refusal. An unknown answer is a no.
37
+ * 2. **The file set is the files Niro indexes, and nothing else** (since 2026-11-11: the engine's
38
+ * own list, `niro-local indexed-files`, through shareFiles.js, which the background sharing uses
39
+ * too), behind the same onboarding gate and the same server-side filter as every other upload
40
+ * path. Before 2026-11-11 the list was folderSync's manifest, picked by file name. The first version of
41
+ * this file had the manifest and neither of the other two, and an adversarial review
42
+ * measured what that cost. Without the gate, running this in `~/projects/github` offered
43
+ * 198,998 files and 2,392 MiB, of which 6,044 were files their own repositories gitignore,
44
+ * because `.gitignore` is only honoured when the folder being sent is itself a git work
45
+ * tree. Without the server filter, the monorepo root sent 100 files over the 256 KiB cap,
46
+ * 678 MiB, 71 per cent of the bytes, which no other path would put on the wire.
47
+ * 3. **It is explicit, and it says what it will do before it does it.** No watcher calls this
48
+ * and no command calls it as a side effect. (The watcher's background sharing, on when "Help
49
+ * improve Niro" is on, is shareCode.js: it shares this file's workspace, file list and
50
+ * redaction, but never this function, and keeps every gate this command dropped.) A folder
51
+ * excluded with `niro share exclude` is asked about ("Send it anyway?") and sent only on a
52
+ * yes. A person types it, sees the count and the size, and answers a question. With no terminal there is nobody to answer, so it refuses unless
53
+ * `--yes` was passed: the one guard underneath (`folderSync.aggregateGuard`, 50,000 files
54
+ * and 1 GiB) prints a warning and PROCEEDS when nothing can answer it, which is right for
55
+ * a folder somebody onboarded on purpose and wrong for this.
56
+ *
57
+ * **Secrets are masked on this machine before anything is sent** (Niraj, 2026-11-06; niro-local
58
+ * `docs/usecases/on-machine-redaction.md`). The engine writes masked copies of the accepted files
59
+ * into a new private temporary folder (`niro-local redact-files`, llm-gateway's own rules, names
60
+ * kept), and the upload reads from that folder, never from the repository. The folder is deleted
61
+ * whatever happens. A file that is not text is not copied, so not sent; a file that masking made
62
+ * larger than the server's per-file cap is dropped. Both are counted on screen. If the engine
63
+ * cannot mask (too old, or it fails), nothing is sent. `doInitialSync` itself is unchanged and is
64
+ * still what `niro init`, `niro add repo` and takeover use to upload unmasked source for hosted
65
+ * Niro to build from; only this command points it at the masked copies.
66
+ *
67
+ * **Why the CLI trusts the claim without checking the signature**, which the engine does
68
+ * check: the claim protects the customer from us, not us from the customer. Everything here
69
+ * uploads the customer's own code to the customer's own account with the customer's own
70
+ * token, so somebody who forged a claim would be uploading their own repository to their own
71
+ * storage. The engine's signature check exists for the opposite direction, where the claim
72
+ * decides whether a licence is valid.
73
+ */
74
+ const crypto = require("crypto");
75
+ const fs = require("fs");
76
+ const os = require("os");
77
+ const path = require("path");
78
+
79
+ const agentApi = require("./agentApi");
80
+ const api = require("./api");
81
+ const color = require("./color");
82
+ const folderSync = require("./folderSync");
83
+ const lease = require("./lease");
84
+ const onboardTarget = require("./onboardTarget");
85
+ const prompt = require("./prompt");
86
+ const shareExclusions = require("./shareExclusions");
87
+ const shareFiles = require("./shareFiles");
88
+
89
+ /** Asked when the folder itself is excluded with `niro share exclude` (Niraj, 2026-11-11). */
90
+ const EXCLUDED_QUESTION = " You excluded this folder from sharing. Send it anyway?";
91
+
92
+ /** Asked instead when the list of excluded folders cannot be read, so every folder counts as excluded. */
93
+ const UNREADABLE_EXCLUSIONS_QUESTION = " Niro could not read your excluded-folders list. Send anyway?";
94
+
95
+ /** Where a push is remembered, so the same repository is not sent twice by accident. */
96
+ const STATE_FILE = path.join(os.homedir(), ".niro", "audit-repos.json");
97
+
98
+ function loadState() {
99
+ try {
100
+ const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
101
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Written 0600 through a renamed temporary file, so a file left 0664 by an older version becomes
109
+ * 0600 too. It holds folder paths, workspace ids and the account id (independent check of 31ffc5f,
110
+ * finding 4).
111
+ */
112
+ function saveState(state) {
113
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
114
+ const tmp = `${STATE_FILE}.${process.pid}.tmp`;
115
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
116
+ fs.renameSync(tmp, STATE_FILE);
117
+ }
118
+
119
+ /** The engine's masking of a set of files, shared with the background sharing (shareFiles.js). */
120
+ const engineRedact = (...args) => shareFiles.engineRedact(...args);
121
+
122
+ /** One key per folder per backend, because a git id from one stack is meaningless on another. */
123
+ function keyFor(folder) {
124
+ return `${api.currentBaseUrl()}::${folder}`;
125
+ }
126
+
127
+ /**
128
+ * Can this machine read its account from the licence service?
129
+ *
130
+ * Returns `{ allowed: true, accountId, planId }`, or `{ allowed: false, reason }` where the
131
+ * reason is already a sentence for a developer to read. A refused, unreachable or unreadable
132
+ * lease is a no. Nothing else is checked: not the plan's `audit_full_repo`, not "Help improve
133
+ * Niro" (`share_code`), and not the lease's expiry, because typing the command and answering yes
134
+ * is the consent (Niraj, 2026-11-11).
135
+ */
136
+ async function permitted({ fetchLease = lease.fetchLease } = {}) {
137
+ const nonce = crypto.randomBytes(16).toString("hex");
138
+ const answer = await fetchLease(nonce);
139
+ if (answer.refused) {
140
+ return { allowed: false, reason: answer.reason || "the licence service refused this account" };
141
+ }
142
+ if (answer.unreachable) {
143
+ return {
144
+ allowed: false,
145
+ reason: `the licence service could not be reached (${answer.detail || "no detail"}), so nothing was sent`,
146
+ };
147
+ }
148
+ const claims = lease.claimsOf(answer.lease);
149
+ if (!claims) {
150
+ return { allowed: false, reason: "the licence could not be read, so nothing was sent" };
151
+ }
152
+ return { allowed: true, accountId: claims.account_id, planId: claims.plan_id };
153
+ }
154
+
155
+ /**
156
+ * The marked workspace this folder's shared code goes to, and the server's answer about which of
157
+ * `manifest`'s files it will take. Used by `niro audit-push-repo` and by the background sharing
158
+ * (shareCode.js), so one folder has one workspace however its code is sent.
159
+ *
160
+ * Returns `{ gitId, filtered }`, or `{ refused: reason }` when the server cannot promise to delete
161
+ * the code when sharing is switched off. `state` and `key` are the caller's when it has the record
162
+ * open already; the entry is written before anything is uploaded.
163
+ */
164
+ async function sharedWorkspace(resolved, accountId, manifest, { state = loadState(), key = keyFor(resolved) } = {}) {
165
+ const already = state[key];
166
+ // The workspace is allocated before the filter, because the filter is a question about a
167
+ // repository the service has to know. Written down straight away, with no `sentAt`: a push
168
+ // that fails halfway leaves bytes on the server, and without this the next run allocated a
169
+ // SECOND workspace and sent everything again.
170
+ // Reused only when that workspace was registered with the sharing mark (`shared: true`, written
171
+ // below once the server echoed it). An entry from before the mark existed names a workspace the
172
+ // server would never delete when sharing goes off, so it is not sent to again.
173
+ let gitId = already && already.shared === true ? already.gitId : null;
174
+ if (gitId && already.accountId && accountId && already.accountId !== accountId) {
175
+ // A different account on the same backend. The service refuses a workspace that is not
176
+ // the caller's anyway (AgentSyncService.requireRepo), so this only decides whether the
177
+ // developer gets a clear message or a "repository not found".
178
+ gitId = null;
179
+ }
180
+ let filtered;
181
+ if (gitId) {
182
+ try {
183
+ filtered = await agentApi.filterManifest(gitId, manifest);
184
+ } catch (err) {
185
+ // The server deleted that workspace, which is what happens 7 days after sharing was
186
+ // switched off (or when the developer deleted it in the console). Forget it and make a
187
+ // new one below, rather than failing with "Repository not found" for ever.
188
+ if (!agentApi.isRepoNotFound(err)) throw err;
189
+ delete state[key];
190
+ saveState(state);
191
+ gitId = null;
192
+ }
193
+ }
194
+ if (!gitId) {
195
+ const registered = await agentApi.registerSharedRepo({ displayName: path.basename(resolved) });
196
+ if (!registered.sharedForImprovement) {
197
+ // An older server ignores the mark without saying so. It would keep this code after
198
+ // sharing is switched off, so the empty workspace is taken back and nothing is sent.
199
+ if (registered.gitId) {
200
+ try {
201
+ await agentApi.deleteRepo(registered.gitId);
202
+ } catch {
203
+ // The workspace is empty: nothing was uploaded to it. The refusal below still stands.
204
+ }
205
+ }
206
+ return {
207
+ refused:
208
+ "the Niro service cannot yet promise to delete this code when sharing is switched off, so nothing was sent",
209
+ };
210
+ }
211
+ gitId = registered.gitId;
212
+ state[key] = { gitId, sentAt: null, files: 0, accountId, shared: true };
213
+ saveState(state);
214
+ // The server decides what it will take, which is what every other upload path does
215
+ // (`init.js:476`, `add-repo.js`, `takeover.js`) and what `folderSync` calls "the
216
+ // authoritative gate". The one rule it adds that the local walk has not got is the
217
+ // per-file size cap: without this, the monorepo root sent 100 files over the 256 KiB cap,
218
+ // 678 MiB, 71 per cent of the bytes, which no other path would have put on the wire.
219
+ filtered = await agentApi.filterManifest(gitId, manifest);
220
+ }
221
+ return { gitId, filtered };
222
+ }
223
+
224
+ /**
225
+ * Send one folder's source.
226
+ *
227
+ * Returns what happened, rather than printing it, so the command prints and a test can read
228
+ * it. `force` sends a folder that has been sent before; without it a second call says when
229
+ * the first one happened and does nothing.
230
+ */
231
+ async function pushFolder(
232
+ folder,
233
+ {
234
+ force = false,
235
+ yes = false,
236
+ log = console.log,
237
+ fetchLease = lease.fetchLease,
238
+ confirm = prompt.confirm,
239
+ interactive = !prompt.isNonInteractive(),
240
+ redactFiles = engineRedact,
241
+ } = {}
242
+ ) {
243
+ const resolved = path.resolve(folder);
244
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
245
+ return { sent: false, reason: `not a directory: ${resolved}` };
246
+ }
247
+ // A folder the user excluded from sharing is asked about first, before anything about it is
248
+ // read: typed, the person may send it anyway (Niraj, 2026-11-11). The background sharing never
249
+ // sends it. The question needs a person: `--yes` answers the size question below, not this one,
250
+ // because the exclusion was a choice made on purpose, so with no terminal nothing is sent.
251
+ //
252
+ // Every question, and the refusal when nobody can answer, comes before any request to Niro other
253
+ // than the licence lease: nothing about the repository, not even its file names, leaves before a
254
+ // yes (independent check of aac6573: a "no" with sharing off still registered a workspace and
255
+ // sent the names to the server's filter).
256
+ let sendExcluded = false;
257
+ if (shareExclusions.isExcluded(resolved)) {
258
+ const unreadable = shareExclusions.read() === null;
259
+ if (!interactive) {
260
+ return {
261
+ sent: false,
262
+ excluded: true,
263
+ reason: unreadable
264
+ ? `the list of excluded folders (${shareExclusions.file()}) cannot be read and there is no terminal to ask you. Fix or delete it, or run this in a terminal`
265
+ : "this folder is excluded from code sharing and there is no terminal to ask you. Run it in a terminal, or run `niro share include <folder>` first",
266
+ };
267
+ }
268
+ if (!(await confirm(unreadable ? UNREADABLE_EXCLUSIONS_QUESTION : EXCLUDED_QUESTION, false))) {
269
+ return { sent: false, excluded: true, reason: "you said no: this folder stays excluded from sharing" };
270
+ }
271
+ sendExcluded = true;
272
+ }
273
+
274
+ // The same gate `niro init` and `niro add repo` pass through, and for a stronger reason
275
+ // here: it refuses $HOME, the filesystem root, a parent of $HOME and the system
276
+ // directories, and its own comment describes this command exactly ("there is no legitimate
277
+ // reason to upload $HOME, and a scripted --force must not be able to do it by accident",
278
+ // onboardTarget.js:198-204). It also refuses a folder holding no project indicator, which
279
+ // is what a directory full of unrelated repositories looks like, and that case is the
280
+ // expensive one: measured before this gate was added, `~/projects/github` offered 198,998
281
+ // files and 2,392 MiB, 6,044 of them files their own repositories gitignore, because the
282
+ // gitignore pass only runs when the folder being sent is itself a git work tree.
283
+ if (!onboardTarget.ensureOnboardable(resolved, {})) {
284
+ // That gate's own refusal ends by offering `--force`, which it honours for a folder with
285
+ // no project indicator. This command deliberately does NOT pass it through, because a
286
+ // folder of unrelated repositories is exactly the case `--force` would wave past, and
287
+ // `--force` here means "send it again" instead. Said plainly, or the developer types a
288
+ // flag that does something else.
289
+ return {
290
+ sent: false,
291
+ gateRefused: true,
292
+ reason: "this folder must not be sent whole (the refusal is above)",
293
+ };
294
+ }
295
+
296
+ // The token first, so a machine that cannot authenticate does not build a manifest of
297
+ // somebody's repository only to fail. Same order as the journal and audit uploads.
298
+ try {
299
+ await api.ensureFreshToken();
300
+ } catch (err) {
301
+ return { sent: false, reason: (err && err.message) || String(err) };
302
+ }
303
+
304
+ const permission = await permitted({ fetchLease });
305
+ if (!permission.allowed) return { sent: false, reason: permission.reason };
306
+ // Nobody can answer the question below: refused here, after the lease and before anything else
307
+ // reaches Niro, so the reason is the lease's when the lease is the problem.
308
+ if (!yes && !interactive) {
309
+ // The caller prints "Nothing was sent: <reason>", so the reason does not repeat it.
310
+ return { sent: false, reason: "there is no terminal to confirm from. Pass --yes to mean it." };
311
+ }
312
+
313
+ const state = loadState();
314
+ const key = keyFor(resolved);
315
+ const already = state[key];
316
+ if (already && already.sentAt && !force) {
317
+ return {
318
+ sent: false,
319
+ alreadySent: true,
320
+ reason: `already sent on ${already.sentAt} (${already.files} files). Use --force to send it again.`,
321
+ };
322
+ }
323
+
324
+ // The files the engine considers when it indexes this folder, and nothing else (Niraj,
325
+ // 2026-11-11: "we send what is indexed with niro"): the same list the background sharing sends
326
+ // (shareFiles.js). Until 2026-11-11 this was folderSync's own walk, which picked by file name and
327
+ // could differ from what Niro indexes.
328
+ let listed;
329
+ try {
330
+ listed = await shareFiles.indexedFiles(resolved);
331
+ } catch (err) {
332
+ return {
333
+ sent: false,
334
+ reason: `the local engine could not list this folder's files (${firstLine(err)}), so nothing was sent. Run \`niro upgrade\` and try again`,
335
+ };
336
+ }
337
+ // Files under a folder the user excluded (this folder, or one inside it) are held back by
338
+ // shareFiles. They are sent only on a yes: the one above for this folder, or the question here
339
+ // for a folder inside it. With no terminal, a folder inside stays out and the rest is sent.
340
+ let files = listed.files;
341
+ const heldFiles = Array.isArray(listed.heldFiles) ? listed.heldFiles : [];
342
+ if (heldFiles.length > 0) {
343
+ if (!sendExcluded && interactive) {
344
+ sendExcluded = await confirm(
345
+ ` You excluded ${heldFiles.length} file(s) in this folder from sharing. Send them too?`,
346
+ false
347
+ );
348
+ }
349
+ if (sendExcluded) files = files.concat(heldFiles);
350
+ else log(` ${heldFiles.length} file(s) under a folder you excluded from sharing are not sent.`);
351
+ }
352
+ if (files.length === 0) {
353
+ return { sent: false, reason: "no files to send: nothing in this folder is uploadable" };
354
+ }
355
+ // Masked copies first, into a folder only this user can read (mkdtemp makes it 0700), so the
356
+ // count and the size shown below are what will be sent. Deleted in the finally whatever
357
+ // happens, including a "no" to the question. Everything up to the question is on this machine:
358
+ // the workspace and the server's file filter come after the yes.
359
+ const maskedDir = fs.mkdtempSync(path.join(os.tmpdir(), "niro-push-masked-"));
360
+ // Ctrl-C, a kill or any exit during the upload must delete the copies too: a finally does not
361
+ // run when the process is ended by a signal (the independent check found the copies left).
362
+ const removeCopies = () => fs.rmSync(maskedDir, { recursive: true, force: true });
363
+ const onSignal = (signal) => {
364
+ removeCopies();
365
+ process.exit(signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143);
366
+ };
367
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
368
+ for (const signal of signals) process.on(signal, onSignal);
369
+ process.on("exit", removeCopies);
370
+ let uploaded;
371
+ let gitId;
372
+ try {
373
+ let masked;
374
+ try {
375
+ // The engine masks secrets, then redact.js removes author and company names from each copy:
376
+ // the same two passes the background sharing makes (shareFiles.maskCopies).
377
+ masked = await shareFiles.maskCopies(resolved, maskedDir, files.map((file) => file.path), shareFiles.redactionContext(resolved), {
378
+ redactFiles: async (...args) => {
379
+ const report = await redactFiles(...args);
380
+ if (!report || !report.redacted || !Array.isArray(report.files)) throw new NotMasked();
381
+ return report;
382
+ },
383
+ });
384
+ } catch (err) {
385
+ if (err instanceof NotMasked) {
386
+ return { sent: false, reason: "the local engine did not say it masked the files, so nothing was sent" };
387
+ }
388
+ return {
389
+ sent: false,
390
+ reason: `the local engine could not mask the files (${firstLine(err)}), so nothing was sent. Run \`niro upgrade\` and try again`,
391
+ };
392
+ }
393
+ // Masking can make a file longer than the value it hides; the server would refuse it. The
394
+ // server's own cap is checked again after the yes, when its filter answers.
395
+ const sending = masked.files.filter((file) => file.bytes <= folderSync.PER_FILE_CAP_BYTES);
396
+ const grown = masked.files.length - sending.length;
397
+ const notText = Number(masked.not_text) || 0;
398
+ const tooSlow = Number(masked.too_slow) || 0;
399
+ const skipped = notText + (Number(masked.missing) || 0) + (Number(masked.refused) || 0);
400
+ // Only the masks in the files that are sent (it counted the dropped files' masks too).
401
+ const masksSent = sending.reduce((total, file) => total + (Number(file.masks) || 0), 0);
402
+ if (sending.length === 0) {
403
+ return { sent: false, reason: "no file was left to send once the files were masked" };
404
+ }
405
+ const bytes = sending.reduce((total, file) => total + file.bytes, 0);
406
+
407
+ // Said before anything is uploaded, and then asked. A whole repository is the most this
408
+ // product ever sends, so the developer sees the size of it first.
409
+ log("");
410
+ log(` ${resolved}`);
411
+ log(` ${sending.length} files, ${megabytes(bytes)}, to your own account's storage at Niro.`);
412
+ log(" This is the whole repository, not only the files the audit flagged.");
413
+ log(` Secrets were masked on this machine first: ${masksSent} value(s) replaced.`);
414
+ if (notText) log(` ${notText} file(s) are not sent because they are not text.`);
415
+ if (tooSlow) log(` ${tooSlow} file(s) are not sent because masking them took too long.`);
416
+ if (skipped - notText) log(` ${skipped - notText} file(s) could not be read and are not sent.`);
417
+ if (grown) log(` ${grown} file(s) are not sent because masking made them larger than the server takes.`);
418
+ if (masked.linesMoved) log(` ${masked.linesMoved} file(s) are not sent because removing names would have moved their lines.`);
419
+ log("");
420
+ if (!yes && !(await confirm(" Send it?", false))) {
421
+ return { sent: false, reason: "you said no" };
422
+ }
423
+
424
+ // After the yes, and only then: the marked workspace, and the server's answer about which of
425
+ // these files it will take (its per-file size cap is the rule it adds). Only the names of the
426
+ // files the person agreed to send are in the question.
427
+ const workspace = await sharedWorkspace(resolved, permission.accountId, shareFiles.manifestOf(sending), { state, key });
428
+ if (workspace.refused) return { sent: false, reason: workspace.refused };
429
+ gitId = workspace.gitId;
430
+ const filtered = workspace.filtered;
431
+ const accepted = new Set(filtered.included_paths || filtered.includedPaths || []);
432
+ const serverCap = Number(filtered.max_file_size_bytes || filtered.maxFileSizeBytes) || folderSync.PER_FILE_CAP_BYTES;
433
+ const taken = sending.filter((file) => accepted.has(file.path) && file.bytes <= serverCap);
434
+ if (taken.length === 0) {
435
+ return { sent: false, reason: "the service accepted none of this folder's files" };
436
+ }
437
+ if (taken.length < sending.length) {
438
+ log(` ${sending.length - taken.length} file(s) are not sent because the server does not take them.`);
439
+ }
440
+ const sendPaths = taken.map((file) => file.path);
441
+ // The copies' own sizes, so the aggregate guard does not re-stat them (it once re-stat'ed a
442
+ // whole repository because it was not given the sizes it already had).
443
+ const maskedSizes = Object.fromEntries(taken.map((file) => [file.path, file.bytes]));
444
+ const sentBytes = taken.reduce((total, file) => total + file.bytes, 0);
445
+ const sentMasks = taken.reduce((total, file) => total + (Number(file.masks) || 0), 0);
446
+
447
+ // The guard inside this (1 GiB, 50,000 files) throws, which is the right shape: a
448
+ // repository over the limit is not sent at all rather than sent in part. It reads the
449
+ // masked copies, never the repository.
450
+ await folderSync.doInitialSync(gitId, maskedDir, sendPaths, { sizes: maskedSizes });
451
+ uploaded = { files: sendPaths.length, bytes: sentBytes, masks: sentMasks, notText, tooSlow, grown };
452
+ } finally {
453
+ for (const signal of signals) process.removeListener(signal, onSignal);
454
+ process.removeListener("exit", removeCopies);
455
+ removeCopies();
456
+ }
457
+
458
+ state[key] = {
459
+ gitId,
460
+ sentAt: new Date().toISOString(),
461
+ files: uploaded.files,
462
+ bytes: uploaded.bytes,
463
+ accountId: permission.accountId,
464
+ shared: true,
465
+ };
466
+ saveState(state);
467
+ return { sent: true, gitId, ...uploaded };
468
+ }
469
+
470
+ /** The engine answered, but without saying it masked: kept apart so the reason says exactly that. */
471
+ class NotMasked extends Error {}
472
+
473
+ /** The first line of an error, without its stack, for a one-line reason. */
474
+ function firstLine(err) {
475
+ return String((err && err.message) || err).split("\n")[0].slice(0, 200);
476
+ }
477
+
478
+ /** A size a person reads, for the one line that decides whether they say yes. */
479
+ function megabytes(bytes) {
480
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
481
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
482
+ }
483
+
484
+ module.exports = {
485
+ permitted,
486
+ pushFolder,
487
+ sharedWorkspace,
488
+ keyFor,
489
+ loadState,
490
+ megabytes,
491
+ engineRedact,
492
+ STATE_FILE,
493
+ EXCLUDED_QUESTION,
494
+ UNREADABLE_EXCLUSIONS_QUESTION,
495
+ };
package/src/lib/secret.js CHANGED
@@ -111,4 +111,4 @@ function _strip(value, omit, maskValues) {
111
111
  return value;
112
112
  }
113
113
 
114
- module.exports = { maskSecret, isSecretKey, looksSecretValue, stripSecrets };
114
+ module.exports = { maskSecret, isSecretKey, looksSecretValue, stripSecrets, SECRET_KEY_PATTERN };