@niroai/niro 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,66 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
- ## Unreleased
5
+ ## 0.6.0 — 2026-08-11
6
+
7
+ ### Upgrading
8
+
9
+ ```bash
10
+ npm install -g @niroai/niro@latest
11
+ ```
12
+
13
+ If you are coming from 0.3.x you also still need `niro mcp install` once per assistant — see
14
+ the 0.5.0 notes below. Upgrading from 0.5.0 needs nothing extra.
15
+
16
+ ### Changed — read this before upgrading
17
+
18
+ - **`--api-url` now applies to `niro login` only.** Every other command refuses it and exits 1.
19
+
20
+ It was a global flag, but only half of the code honoured it. Ordinary API calls took it,
21
+ while the file-upload path built its own address from your stored credentials and never saw
22
+ it. So `niro init --api-url http://localhost:8095` while logged in to another backend listed
23
+ projects from localhost and uploaded your source to the backend you were logged in to. One
24
+ command, two servers, no warning.
25
+
26
+ The backend is a property of a session, not of a single command: your credentials, tokens,
27
+ repo ids and sync records are all scoped to one backend, so a per-command override could
28
+ only ever mix them. Choose it once instead:
29
+
30
+ ```bash
31
+ niro login --api-url <url> # log in to that backend
32
+ NIRO_API_URL=<url> niro <command> # or set it for every command
33
+ ```
34
+
35
+ `NIRO_API_URL` and the `apiUrl` config setting still work everywhere, because both feed the
36
+ same resolution the uploader uses. **If you script `--api-url` on any command other than
37
+ `login`, that script now fails with a message** telling it which of the two forms to use.
38
+
39
+ - **`niro init` and `niro add repo` now refuse a folder that is not a project.** Onboarding a
40
+ folder used to work anywhere, including your home folder, which uploaded source from every
41
+ project on the machine, pinned `~/.niro-project` and started a watcher over your whole home
42
+ directory. Two rules now apply before anything is uploaded:
43
+
44
+ - Your home folder, the filesystem root, a parent of your home folder and the system folders
45
+ are refused outright. `--force` does **not** override this.
46
+ - Any other folder must look like a project: a build file (`package.json`, `pom.xml`, `go.mod`,
47
+ `*.csproj`, and the rest of the set the indexer already understands) at the folder itself or
48
+ just below it. A monorepo root whose build files sit one or two levels down is fine.
49
+
50
+ **If you script either command**, a folder with no build file now exits `1` where it used to
51
+ onboard. Pass `--force` for that case. `niro add repo` gains `--force` for this; on `niro init`
52
+ the existing `--force` now covers it too.
53
+
54
+ A folder this backend already has a repo for is exempt from the build-file rule, so an existing
55
+ sync keeps working. The home and root refusal still applies to it.
56
+
57
+ The check runs before the login and before any server call, so a refused folder leaves nothing
58
+ behind: no registered repo, no orphan project, no pin file, no watcher.
59
+
60
+ - **`niro init` now notices nested repos deeper in the tree.** The "this folder contains repos
61
+ already indexed in Niro" refusal only looked two levels down, so it never fired for the common
62
+ `~/projects/<host>/<org>/<repo>` layout. It now looks four.
63
+
64
+ ## 0.5.0 — 2026-08-06
6
65
 
7
66
  ### Upgrading from 0.3.x — one manual step
8
67
 
package/README.md CHANGED
@@ -98,7 +98,9 @@ During `init` you'll be asked, **per environment variable**, which value to use
98
98
  **Ask the agent**
99
99
  > "Onboard this folder into Niro." The `niro-cli` skill checks auth, runs `niro info` to see if it's already indexed, then runs `niro init` for you and walks you through the env-var values in chat.
100
100
 
101
- **Note for cloud:** Niro indexes your folder by **uploading its files** (it can't read your laptop's disk). Source and committed config files (`package.json`, `pom.xml`, `application.yml`, …) are uploaded; anything in your `.gitignore` (like a real `.env`) is **never** uploaded.
101
+ **Note for cloud:** Niro indexes your folder by **uploading its files** (it can't read your laptop's disk). Source and committed config files (`package.json`, `pom.xml`, `application.yml`, …) are uploaded; anything your repo `.gitignore`s (like a real `.env`) is **never** uploaded. One caveat: that filtering needs the folder to be a git repository. If you onboard a plain folder that is not a git repository, there is no `.gitignore` to honour and only the filename rules apply, so keep secrets out of it.
102
+
103
+ **What can be onboarded:** the folder has to look like a project — a build file (`package.json`, `pom.xml`, `go.mod`, `*.csproj`, …) at the folder itself or just below it. A monorepo root whose build files sit one or two levels down is fine. A folder with no build file is refused; pass `--force` if you really mean it. Your home folder, the filesystem root and system folders are refused outright, and `--force` does not override that — onboarding one of those would upload source from every project on the machine.
102
104
 
103
105
  ---
104
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -25,30 +25,57 @@ const { startWatch } = require("./watch");
25
25
  const color = require("../lib/color");
26
26
  const watchDaemon = require("../lib/watchDaemon");
27
27
  const { login } = require("./login");
28
+ const onboardTarget = require("../lib/onboardTarget");
28
29
 
29
30
  async function addRepo(pathArg, opts = {}) {
31
+ const targetRaw = pathArg || ".";
32
+ const isRemote = targetRaw.startsWith("http://") || targetRaw.startsWith("https://") || targetRaw.startsWith("git@");
33
+
34
+ // Backend URL as it looks BEFORE the login. Used for ONE thing only: the gate's
35
+ // already-registered lookup, which has to run before the login. It is NOT reused
36
+ // afterwards — a login can change which backend this session talks to, and tagging a
37
+ // watchState entry with a stale value stamps it with a backend that did not issue its
38
+ // gitId, which silently stops the daemon syncing that folder. See the same note in
39
+ // init.js. The authoritative value is re-read after ensureLoggedIn below.
40
+ const apiUrlBeforeLogin = api.currentBaseUrl();
41
+
42
+ // LOCAL FOLDER: validate and gate BEFORE the login, for two reasons. Refusing a folder
43
+ // should not first cost the user a browser device-flow. And an abandoned login used to
44
+ // swallow the refusal entirely: ensureLoggedIn returned null, the bare `return` printed
45
+ // nothing and left process.exitCode undefined, so `niro add repo ~ && echo ok` printed
46
+ // ok. A remote URL has no folder to gate, so it stays behind the login.
47
+ let targetDir = null;
48
+ if (!isRemote) {
49
+ targetDir = path.isAbsolute(targetRaw) ? path.resolve(targetRaw) : path.resolve(process.cwd(), targetRaw);
50
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
51
+ console.error(color.red(`Not a directory: ${targetDir}`));
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ // Same onboarding gate as `niro init` — this command uploads a folder too, so it must
56
+ // refuse $HOME / the filesystem root on the same terms. A folder this backend already
57
+ // has a repo for is exempt from the BUILD-DESCRIPTOR half: that half gates new
58
+ // onboarding, and must not break the re-sync branch below for a descriptor-less folder
59
+ // onboarded by an earlier CLI. See src/lib/onboardTarget.js.
60
+ const known = watchState.get(targetDir, apiUrlBeforeLogin);
61
+ const alreadyRegistered = !!(known && known.gitId);
62
+ if (!onboardTarget.ensureOnboardable(targetDir, { ...opts, alreadyRegistered })) {
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ }
67
+
30
68
  const creds = await ensureLoggedIn(opts);
31
69
  if (!creds) return;
32
70
 
33
- // Backend the CLI is pointed at — scopes watchState so a cached gitId from
34
- // another stack is never reused here.
71
+ // Authoritative backend, re-read AFTER the login (see the note above).
35
72
  const currentApiUrl = api.currentBaseUrl();
36
73
 
37
- const targetRaw = pathArg || ".";
38
-
39
- // Remote URL?
40
- if (targetRaw.startsWith("http://") || targetRaw.startsWith("https://") || targetRaw.startsWith("git@")) {
74
+ if (isRemote) {
41
75
  await addRemoteRepo(targetRaw, opts);
42
76
  return;
43
77
  }
44
78
 
45
- const targetDir = path.isAbsolute(targetRaw) ? path.resolve(targetRaw) : path.resolve(process.cwd(), targetRaw);
46
- if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
47
- console.error(color.red(`Not a directory: ${targetDir}`));
48
- process.exitCode = 1;
49
- return;
50
- }
51
-
52
79
  // --sync-mode local|remote skips the interactive [1]/[2] sync-type choice.
53
80
  // --no-confirm skips any confirmation prompt.
54
81
  const syncMode = parseSyncMode(opts.syncMode);
@@ -13,7 +13,10 @@
13
13
  * NEVER prints secrets: it only emits project ids, aliases and status fields —
14
14
  * no env-var values, tokens or credentials are touched here.
15
15
  */
16
+ const path = require("path");
16
17
  const api = require("../lib/api");
18
+ const watchState = require("../lib/watchState");
19
+ const tempProject = require("../lib/tempProject");
17
20
  const credentials = require("../lib/credentials");
18
21
  const color = require("../lib/color");
19
22
  const { resolveContext } = require("../lib/resolveContext");
@@ -91,8 +94,58 @@ async function info(pathArg, opts = {}) {
91
94
  // A status fetch failure for one project must not abort the whole command.
92
95
  status = null;
93
96
  }
97
+ // Which local folders sync to THIS project, and on what branch. Purely local: ~/.niro/projects.json
98
+ // already records branch and projectId per folder.
99
+ //
100
+ // A developer gets ONE temporary project per project, and creating another RELABELS it onto the new
101
+ // branch. So with several worktrees open, exactly one of them is reflected by Niro and the others
102
+ // are not, with nothing on screen to say which. This is that missing line: it lets someone see that
103
+ // the temporary project is bound to worktree A on feature/x while they are standing in worktree B.
104
+ //
105
+ // Matching on projectId ALONE could never show the one folder this line exists for. Taking over a
106
+ // folder rewrites its entry's projectId to the TEMPORARY project's id, and /api/projects/resolve
107
+ // deliberately never returns temporary projects (they share the source project's git URL, so
108
+ // including them would make every teammate's resolution ambiguous). So for a taken-over worktree
109
+ // the two ids can never be equal, and the folder Niro actually reflects was the only one missing
110
+ // from the list, while the folder it does NOT reflect was the only one shown.
111
+ //
112
+ // takeover.sourceProjectId is the link back to the source project. It is written when the temporary
113
+ // project is created and cleared on discard, so it is present exactly while there is something to
114
+ // report. `niro release` already pairs the two this way.
115
+ //
116
+ // apiUrl comes from api.currentBaseUrl(), not opts: `--api-url` is refused outside `niro login`, so
117
+ // opts.apiUrl is always undefined here, and an undefined backend makes allLocal match entries
118
+ // belonging to ANY backend.
119
+ const localEntries = watchState.allLocal(api.currentBaseUrl())
120
+ .filter(e => e.projectId === p.project_id
121
+ || (e.takeover && e.takeover.sourceProjectId === p.project_id));
122
+ const folders = localEntries.map(e => ({
123
+ path: e.folderPath,
124
+ branch: e.branch || null,
125
+ // The temporary project's own id when this folder is bound to one, else null.
126
+ temp_project_id: e.takeover && e.takeover.sourceProjectId === p.project_id ? e.projectId : null,
127
+ // Whether the temporary project is currently reflecting THIS folder. null when it has none, or
128
+ // when the server could not be asked.
129
+ reflected: null,
130
+ }));
131
+
132
+ // A temporary project can only be bound to one branch at a time, so listing several folders as
133
+ // "via temporary project X" implies they are all covered when at most one is. That is not
134
+ // hypothetical: two worktrees of one project end up sharing a repo id, and I have watched this
135
+ // command mark "you are here" against a folder the server had stopped reflecting. The branch the
136
+ // copy is on is the answer, and only the server knows it, so ask once per temporary project.
137
+ const tempIds = [...new Set(folders.map(f => f.temp_project_id).filter(Boolean))];
138
+ for (const tempId of tempIds) {
139
+ const owner = localEntries.find(e => e.projectId === tempId);
140
+ const heldBranch = await tempProject.currentBranch(tempId, owner && owner.gitId);
141
+ if (!heldBranch) continue; // could not ask — say nothing rather than guess
142
+ for (const f of folders) {
143
+ if (f.temp_project_id === tempId) f.reflected = f.branch === heldBranch;
144
+ }
145
+ }
94
146
  projects.push({
95
147
  project_id: p.project_id,
148
+ folders,
96
149
  alias: p.alias || null,
97
150
  // Matched repo's registration kind (REMOTE_GIT / LOCAL_PATH / AGENT_UPLOAD),
98
151
  // or null. Lets agents warn on remote-git projects (auto-build only on commits).
@@ -166,6 +219,23 @@ function printHuman(p) {
166
219
  if (proj.doc_gen_status) bits.push(`docs: ${proj.doc_gen_status}`);
167
220
  if (proj.updated_at) bits.push(`updated: ${proj.updated_at}`);
168
221
  console.log(` - ${name}${bits.length ? " — " + color.dim(bits.join(", ")) : ""}`);
222
+ for (const f of proj.folders || []) {
223
+ // Marked so the folder you are STANDING IN is obvious at a glance; the whole point is telling
224
+ // "this worktree is the one Niro reflects" from "this one is not".
225
+ const here = path.resolve(f.path) === path.resolve(p.git_root) ? color.green(" <- you are here") : "";
226
+ // Say plainly which folder holds the temporary project. Without this the line reads as though
227
+ // Niro were answering from the source project for that folder, which is the opposite of the truth.
228
+ let temp = "";
229
+ if (f.temp_project_id && f.reflected === false) {
230
+ // The line that used to mislead. Say plainly that this folder is not the one being reflected.
231
+ temp = color.yellow(` — NOT reflected: temporary project ${f.temp_project_id} is on another branch`);
232
+ } else if (f.temp_project_id) {
233
+ temp = color.dim(` via temporary project ${f.temp_project_id}`)
234
+ + (f.reflected === null ? color.dim(" (could not confirm)") : "");
235
+ }
236
+ console.log(color.dim(` syncing from ${f.path}`) +
237
+ (f.branch ? color.dim(` on branch ${f.branch}`) : "") + temp + here);
238
+ }
169
239
  }
170
240
  }
171
241
 
@@ -36,6 +36,7 @@ const credentials = require("../lib/credentials");
36
36
  const prompt = require("../lib/prompt");
37
37
  const git = require("../lib/git");
38
38
  const niroProjectFile = require("../lib/niroProjectFile");
39
+ const onboardTarget = require("../lib/onboardTarget");
39
40
  const color = require("../lib/color");
40
41
  const watchDaemon = require("../lib/watchDaemon");
41
42
  const { login } = require("./login");
@@ -62,6 +63,36 @@ async function init(pathArg, opts = {}) {
62
63
  process.exitCode = 1;
63
64
  return;
64
65
  }
66
+ // Backend URL as it looks BEFORE the login. Used for ONE thing only: the gate's
67
+ // already-registered lookup, which has to happen before the login prompt.
68
+ //
69
+ // It is deliberately NOT reused after the login. currentBaseUrl() reads the backend out
70
+ // of the stored credentials, so a login can CHANGE it: `niro init --api-url <local>` on a
71
+ // machine with no session resolves the production URL here and the local one afterwards.
72
+ // Tagging a watchState entry with this stale value would stamp an entry with a backend
73
+ // that did not issue its gitId, after which watchState.get(dir, realBackend) returns null,
74
+ // the daemon never syncs the folder, and the next run re-registers a duplicate repo and
75
+ // re-uploads everything. The authoritative value is re-read below, after the login.
76
+ //
77
+ // Being wrong here only affects the alreadyRegistered waiver, which can at worst refuse a
78
+ // folder that --force reopens. It can never waive the protected-root refusal.
79
+ const apiUrlBeforeLogin = api.currentBaseUrl();
80
+
81
+ // Onboarding gate — runs BEFORE the login prompt and before any server call, so a
82
+ // refused folder leaves nothing behind: no registered repo, no orphan project, no
83
+ // .niro-project pin, no watcher. Refuses $HOME / the filesystem root outright, and
84
+ // refuses a folder with no build descriptor at it or just below it. --force covers only
85
+ // that second rule, and so does alreadyRegistered: a folder this backend already has a
86
+ // repo for must not start failing its re-run just because it has no build file.
87
+ // See src/lib/onboardTarget.js.
88
+ const knownEntry = watchState.get(targetDir, apiUrlBeforeLogin);
89
+ if (!onboardTarget.ensureOnboardable(targetDir, {
90
+ ...opts,
91
+ alreadyRegistered: !!(knownEntry && knownEntry.gitId),
92
+ })) {
93
+ process.exitCode = 1;
94
+ return;
95
+ }
65
96
 
66
97
  let creds = credentials.load();
67
98
  if (!creds || !creds.token) {
@@ -71,8 +102,9 @@ async function init(pathArg, opts = {}) {
71
102
  if (!creds || !creds.token) return;
72
103
  }
73
104
 
74
- // The backend the CLI is pointed at. watchState entries (cached gitIds) are
75
- // scoped to this so a backend switch never reuses a gitId from another stack.
105
+ // Authoritative backend, re-read AFTER the login: the login is what establishes which
106
+ // stack this session talks to. Every watchState read and write below uses THIS value, so
107
+ // an entry is always tagged with the backend that actually issued its gitId.
76
108
  const currentApiUrl = api.currentBaseUrl();
77
109
 
78
110
  const gitInfo = git.inspect(targetDir);
@@ -640,7 +672,13 @@ const CHILD_SCAN_SKIP = new Set([
640
672
  "node_modules", "target", "dist", "build", "out", ".next", "vendor", "__pycache__",
641
673
  ]);
642
674
  const CHILD_SCAN_MAX = 60; // bound the number of nested repos we resolve
643
- const CHILD_SCAN_MAX_DEPTH = 2; // immediate children + one level of grouping dirs (services/, packages/, apps/)
675
+ // Immediate children plus three levels of grouping dirs. Real checkout layouts nest
676
+ // deeper than one grouping level (~/projects/github/<org>/<repo> is depth 3), and at
677
+ // depth 2 this scan silently found NOTHING there — so the "parent of indexed repos"
678
+ // refusal never fired for the most common layout. The walk never descends INTO a git
679
+ // repo, so the extra depth only costs readdir on grouping dirs: measured 9 dirs for a
680
+ // single repo and 50 for this monorepo, versus 5 and 13 at depth 2.
681
+ const CHILD_SCAN_MAX_DEPTH = 4;
644
682
 
645
683
  /**
646
684
  * Collect directories under `root` that are their OWN git repo (have a `.git` entry),
@@ -13,6 +13,7 @@
13
13
  *
14
14
  * Undo with `niro discard-temp-project` (deletes P'; routing falls back to P automatically).
15
15
  */
16
+ const path = require("path");
16
17
  const api = require("../lib/api");
17
18
  const color = require("../lib/color");
18
19
  const folderSync = require("../lib/folderSync");
@@ -27,12 +28,14 @@ const warn = (s) => color.yellow(s);
27
28
  const step = (s) => color.cyan(s);
28
29
 
29
30
  async function takeover(pathArg, opts = {}) {
30
- // --api-url is threaded only into project resolution, not the clone/build POSTs or the agentApi upload
31
- // chain — so a global `--api-url X` would resolve against X but clone/upload/watch-tag against the DEFAULT
32
- // backend, mixing two stacks in one flow. Fail fast BEFORE any server mutation rather than corrupt state
33
- // across backends. (Full per-backend threading is future work; set the default backend via login/config.)
31
+ // Second layer. src/index.js refuses `--api-url` for every command except `niro login`
32
+ // before the action ever runs, so a CLI user never reaches this. It stays for a
33
+ // PROGRAMMATIC caller (a test, a skill, a future internal call) that constructs opts
34
+ // directly and bypasses commander entirely: a per-command backend override mixes two
35
+ // stacks in one flow, and this is the one command that clones, uploads and tags watchState
36
+ // in a single pass.
34
37
  if (opts.apiUrl) {
35
- throw new Error("`--api-url` is not supported for `niro new-temp-project` yet (it would mix backends). "
38
+ throw new Error("`--api-url` is not supported for `niro new-temp-project` (it would mix backends). "
36
39
  + "Select the backend with `niro login` / your config first, then run this without --api-url.");
37
40
  }
38
41
  const targetDir = require("path").resolve(pathArg || process.cwd());
@@ -71,6 +74,40 @@ async function takeover(pathArg, opts = {}) {
71
74
  throw new Error("Server did not report which repo of the project matches this folder (git_id missing).");
72
75
  }
73
76
 
77
+ // ONE temporary project per project. If a DIFFERENT folder already holds it, moving it here stops
78
+ // Niro reflecting that folder — and until now that happened silently: the other folder kept uploading
79
+ // its own branch into the copy, so two branches blended into one and every answer drawn from it was a
80
+ // mixture. Verified live with two worktrees.
81
+ //
82
+ // The SAME folder is deliberately not asked. Switching branch in place loses nothing: the copy was
83
+ // already reflecting this folder, and the branch it names has moved on. The server relabels it, which
84
+ // is right, and a prompt there would be pure friction.
85
+ const here = path.resolve(repoRoot);
86
+ const holder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
87
+ e.takeover && e.takeover.sourceProjectId === source.project_id
88
+ && path.resolve(e.folderPath) !== here);
89
+ if (holder) {
90
+ const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
91
+ console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
92
+ console.log(warn(" held by another folder:"));
93
+ console.log(` ${holder.folderPath}${onBranch}`);
94
+ console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
95
+ console.log(color.dim(" project until `niro new-temp-project` is run there again."));
96
+ if (prompt.isNonInteractive()) {
97
+ // Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
98
+ // would break any agent or script that runs this unattended. The way forward is two explicit
99
+ // commands, which is clearer than a flag that quietly takes the slot from another folder.
100
+ throw new Error(
101
+ "Refusing to move the temporary project without confirmation (nothing to prompt on). "
102
+ + "Niro keeps ONE temporary project per project, and this one is held by "
103
+ + holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
104
+ + ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
105
+ }
106
+ if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
107
+ throw new Error("Cancelled — the temporary project stays where it is.");
108
+ }
109
+ }
110
+
74
111
  console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
75
112
  console.log(color.dim(" Your original project is not changed."));
76
113
 
@@ -19,6 +19,8 @@ const credentials = require("../lib/credentials");
19
19
  const prompt = require("../lib/prompt");
20
20
  const color = require("../lib/color");
21
21
  const folderSync = require("../lib/folderSync");
22
+ const git = require("../lib/git");
23
+ const tempProject = require("../lib/tempProject");
22
24
  const {
23
25
  SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
24
26
  IGNORED_DIRS, isConfigName, chunk,
@@ -31,10 +33,52 @@ const DEBOUNCE_MS = 2_000;
31
33
  // so a slower run would clobber a faster one's hash updates. A per-folder guard serializes them (a skipped
32
34
  // run is safe — the in-progress one re-hashes the whole tree and so already covers the skipped trigger).
33
35
  const syncInFlight = new Set();
36
+ // Folders that are NOT the one their temporary project currently reflects. Uploads from these are held
37
+ // back until the slot returns, so two branches cannot blend into one copy. Set by deltaSyncOnResume and
38
+ // honoured by the live file handler, so one check per sync pass covers both paths rather than asking
39
+ // the server on every save.
40
+ const notHolding = new Set();
34
41
  // The state file is rewritten several times during a `niro takeover` (per-file hashes), so debounce
35
42
  // before reconciling the watch set. Also the interval for the mtime backstop re-scan below.
36
43
  const STATE_RELOAD_DEBOUNCE_MS = 1_500;
37
44
  const RESCAN_INTERVAL_MS = 60_000;
45
+ /** Ceiling for the backstop's exponential backoff. Long enough to stop hammering a broken server,
46
+ * short enough that a recovered one is picked up within half an hour without anyone intervening. */
47
+ const BACKSTOP_BACKOFF_MAX_MS = 30 * 60_000;
48
+
49
+ /**
50
+ * Per-folder retry state for the backstop re-scan: { failures, nextAttemptAt }.
51
+ *
52
+ * The backstop used to fire every 60s regardless of how many times it had just failed, with no
53
+ * backoff, no cap and no notion that anything was wrong. When the server broke on 8-9 August it
54
+ * minted a fresh sync session every single tick — roughly 1,400 failed requests a day, for two days,
55
+ * per watched folder. The bug was ours, but the client turned it into a flood.
56
+ */
57
+ const backstopBackoff = new Map();
58
+
59
+ /** Doubling delay, capped. failures=1 gives 2 minutes; the cap is reached after about six. */
60
+ function nextBackoffMs(failures) {
61
+ return Math.min(RESCAN_INTERVAL_MS * Math.pow(2, failures), BACKSTOP_BACKOFF_MAX_MS);
62
+ }
63
+
64
+ /** False while a folder is still serving a backoff from its last failure. */
65
+ function shouldAttemptBackstop(key, now = Date.now()) {
66
+ const state = backstopBackoff.get(key);
67
+ return !state || now >= state.nextAttemptAt;
68
+ }
69
+
70
+ /** @return the delay applied, so the caller can say how long it is waiting. */
71
+ function recordBackstopFailure(key, now = Date.now()) {
72
+ const failures = (backstopBackoff.get(key)?.failures || 0) + 1;
73
+ const delay = nextBackoffMs(failures);
74
+ backstopBackoff.set(key, { failures, nextAttemptAt: now + delay });
75
+ return delay;
76
+ }
77
+
78
+ /** @return true if this success ended a run of failures (so recovery is worth announcing). */
79
+ function recordBackstopSuccess(key) {
80
+ return backstopBackoff.delete(key);
81
+ }
38
82
 
39
83
  async function watch(pathArg, opts = {}) {
40
84
  const creds = credentials.requireCreds();
@@ -192,8 +236,42 @@ async function startWatch(targetDir, opts = {}) {
192
236
  const rescanTimer = setInterval(() => {
193
237
  const fresh = watchState.get(targetDir, api.currentBaseUrl());
194
238
  if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
195
- deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true }).catch(err =>
196
- console.error(color.red(` Backstop re-scan failed for ${targetDir}: ${err.message}`)));
239
+ // Back off after failures instead of retrying every 60s forever. Retrying at full rate against a
240
+ // server that is failing does not fix anything and buries the real error in its own noise.
241
+ const backoffKey = path.resolve(targetDir);
242
+ if (!shouldAttemptBackstop(backoffKey)) return;
243
+ deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true })
244
+ .then(result => {
245
+ // A run that never reached the server proves nothing about the server. The in-flight guard
246
+ // returns at once when another sync for this folder is already going, and during an outage a
247
+ // large delta holds that flag for minutes — so a skipped tick was clearing the failure count
248
+ // and printing a green "Sync recovered" in the middle of the incident, while the real run was
249
+ // still heading for its own failure. The count then never grew past one, so the backoff never
250
+ // climbed towards its ceiling and kept hammering every two minutes: both things this backoff
251
+ // was added to stop.
252
+ // Only a pass that actually reached the server and had nothing fail counts as a recovery.
253
+ // A skipped run (another sync in flight) and a no-change run both contact nothing, and a pass
254
+ // whose per-file deletes were all refused resolves cleanly — so all three used to clear the
255
+ // failure count and print a green "Sync recovered" during an outage, which is how the retry rate
256
+ // never decayed and the real error stayed buried.
257
+ if (!result || result.skipped || result.noContact || result.notHolder) return;
258
+ if (result.failures > 0) {
259
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
260
+ console.error(color.red(
261
+ ` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
262
+ `${Math.round(delayMs / 60_000)} min.`));
263
+ return;
264
+ }
265
+ if (recordBackstopSuccess(backoffKey)) {
266
+ console.log(color.green(` Sync recovered for ${targetDir}.`));
267
+ }
268
+ })
269
+ .catch(err => {
270
+ const delayMs = recordBackstopFailure(backoffKey, Date.now());
271
+ console.error(color.red(
272
+ ` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
273
+ `${Math.round(delayMs / 60_000)} min.`));
274
+ });
197
275
  }, RESCAN_INTERVAL_MS);
198
276
 
199
277
  // Start file watcher
@@ -244,11 +322,51 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
244
322
  if (syncInFlight.has(inFlightKey)) {
245
323
  // A sync (startup, live event, or backstop) is already running for this folder — skip to avoid the
246
324
  // whole-map fileHashes clobber race; the in-progress run re-hashes the whole tree and covers this trigger.
247
- return;
325
+ // Reported so the caller can tell "nothing to do" from "the server answered": this run never
326
+ // contacted it, so it is not evidence that anything is working.
327
+ return { skipped: true };
248
328
  }
249
329
  syncInFlight.add(inFlightKey);
250
330
  try {
251
331
  const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
332
+
333
+ // Am I still the folder this temporary project reflects? Every folder taken over for one project
334
+ // shares the SAME repo id, so the server cannot tell us apart. If I keep uploading after the slot has
335
+ // moved, my branch blends into the copy that is reflecting someone else, and every answer drawn from
336
+ // it is a mixture — with nothing on screen to say so. Verified live with two worktrees.
337
+ //
338
+ // Nothing is changed or discarded here. The local record is left exactly as it is, so running
339
+ // `niro new-temp-project` here picks straight back up. This only declines to WRITE.
340
+ if (state.takeover && state.takeover.sourceProjectId) {
341
+ const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
342
+ const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
343
+ // A null heldBranch means the server could not be asked. That must NEVER be read as "you lost the
344
+ // slot", or one network hiccup would stop a perfectly healthy folder syncing.
345
+ if (heldBranch && myBranch && heldBranch !== myBranch) {
346
+ if (!notHolding.has(inFlightKey)) {
347
+ notHolding.add(inFlightKey);
348
+ // TWO very different causes, and they need different advice. Another folder bound to the same
349
+ // copy is the stealing case. NO other folder means you simply switched branch in place, and
350
+ // saying "another folder holds it" there sends you hunting for a folder that does not exist —
351
+ // which is exactly what it did on a real repo the first time this ran.
352
+ const otherFolder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
353
+ e.projectId === state.projectId && path.resolve(e.folderPath) !== inFlightKey);
354
+ console.warn(color.yellow(otherFolder
355
+ ? ` ${targetDir} is on \`${myBranch}\`, but this project's temporary project is on ` +
356
+ `\`${heldBranch}\`, held by ${otherFolder.folderPath}. Pausing uploads from here so the ` +
357
+ `two branches do not mix. Run \`niro new-temp-project\` here to move it, or ` +
358
+ `\`niro discard-temp-project\` in that folder.`
359
+ : ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still on ` +
360
+ `\`${heldBranch}\` — you switched branch in this folder. Pausing uploads so the two ` +
361
+ `branches do not mix. Run \`niro new-temp-project\` here to move the temporary project ` +
362
+ `onto \`${myBranch}\`.`));
363
+ }
364
+ return { notHolder: true };
365
+ }
366
+ if (notHolding.delete(inFlightKey)) {
367
+ console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
368
+ }
369
+ }
252
370
  const storedHashes = state.fileHashes || {};
253
371
  const currentHashes = {};
254
372
  const changed = [];
@@ -257,27 +375,50 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
257
375
  // Walk all currently included files
258
376
  const manifest = buildMinimalManifest(targetDir, state.includePatterns, state.excludePatterns);
259
377
 
378
+ const unreadable = [];
260
379
  for (const relativePath of manifest) {
261
380
  const absPath = path.join(targetDir, relativePath);
262
381
  const hash = watchState.hashFile(absPath);
263
- if (!hash) continue;
382
+ if (!hash) {
383
+ // The walk that produced this manifest JUST FOUND this file, so it exists. Failing to READ it
384
+ // is not evidence that it was deleted — it is a permission error, a transient lock, a file being
385
+ // rewritten, or simply too many open files. hashFile returns null for every one of those.
386
+ //
387
+ // Skipping the file here used to leave it out of currentHashes, and the loop below reads
388
+ // "absent from currentHashes" as "deleted", so an unreadable file became a delete instruction to
389
+ // the server. Carrying the stored hash forward keeps it in the baseline and out of `deleted`.
390
+ unreadable.push(relativePath);
391
+ if (Object.prototype.hasOwnProperty.call(storedHashes, relativePath)) {
392
+ currentHashes[relativePath] = storedHashes[relativePath];
393
+ }
394
+ continue;
395
+ }
264
396
  currentHashes[relativePath] = hash;
265
397
  if (storedHashes[relativePath] !== hash) {
266
398
  changed.push(relativePath);
267
399
  }
268
400
  }
269
401
 
270
- // Files that were in stored hashes but no longer exist
402
+ if (unreadable.length > 0) {
403
+ console.warn(color.yellow(
404
+ ` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
405
+ `reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
406
+ }
407
+
408
+ // Files that were in stored hashes but are no longer present in the walk at all.
271
409
  for (const rp of Object.keys(storedHashes)) {
272
- if (!currentHashes[rp]) {
410
+ if (!Object.prototype.hasOwnProperty.call(currentHashes, rp)) {
273
411
  deleted.push(rp);
274
412
  }
275
413
  }
276
414
 
277
415
  if (changed.length === 0 && deleted.length === 0) {
278
416
  if (!quiet) console.log(` No changes since last sync.`);
279
- watchState.update(targetDir, { fileHashes: currentHashes });
280
- return;
417
+ watchState.mergeFileHashes(targetDir, { set: currentHashes });
418
+ // Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
419
+ // This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
420
+ // very next tick after one, so the backoff could never grow past its first step.
421
+ return { noContact: true };
281
422
  }
282
423
 
283
424
  // A quiet backstop run only speaks up when it actually found something fs.watch missed.
@@ -287,6 +428,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
287
428
  // using the same helper as init/add-repo. A gitignored secret edited after
288
429
  // onboarding (e.g. a raw .env) must never be re-uploaded here.
289
430
  let changedToUpload = changed;
431
+ const gitignoreDropped = [];
290
432
  if (changed.length > 0) {
291
433
  const drops = folderSync.gitignoreDrops(targetDir, changed);
292
434
  if (drops.size > 0) {
@@ -294,7 +436,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
294
436
  // Don't persist hashes for dropped (gitignored / unverifiable) files — they
295
437
  // were never uploaded, so leave them out of state so they're re-evaluated
296
438
  // rather than recorded as synced.
297
- for (const rp of drops) delete currentHashes[rp];
439
+ for (const rp of drops) { delete currentHashes[rp]; gitignoreDropped.push(rp); }
298
440
  }
299
441
  }
300
442
 
@@ -313,16 +455,61 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
313
455
  await agentApi.completeSync(gitId, sessionId);
314
456
  }
315
457
 
458
+ // Re-check each file immediately before telling the server to remove it. The walk that built this
459
+ // list ran BEFORE the uploads above, which on a large delta take minutes, so by now a file may exist
460
+ // again — or may never have been gone, having merely been unreadable at the wrong moment. The live
461
+ // per-file path already stats before deleting; this one fired blind.
462
+ // Per-file failures are swallowed below so one bad path cannot abort the pass. They still have to be
463
+ // REPORTED, or a pass in which everything failed is indistinguishable from one in which all was well.
464
+ let failures = 0;
465
+ const reappeared = [];
466
+ // Only paths the server actually accepted. Marking a FAILED delete as done drops it from the
467
+ // baseline, and once dropped it can never re-enter `deleted` — so the file stays on the server, stays
468
+ // indexed, and nothing retries it. With the burst guard that is systematic: delete 500 files, the
469
+ // first 100 land, the rest are refused, and all 500 were being forgotten.
470
+ const deletedOk = [];
316
471
  for (const rp of deleted) {
472
+ // Existing on disk is NOT on its own a reason to keep it. A file leaves the manifest for two
473
+ // different reasons: it was removed from disk, or it stopped matching the include/exclude
474
+ // patterns. Only the first can "come back". Rescuing on existence alone meant adding an exclude
475
+ // pattern no longer removed the file from Niro — so a path excluded BECAUSE it holds secrets
476
+ // would have gone on being indexed for ever.
477
+ if (fs.existsSync(path.join(targetDir, rp)) && inManifestScope(rp, state)) {
478
+ reappeared.push(rp);
479
+ continue;
480
+ }
317
481
  try {
318
482
  await agentApi.deleteFile(gitId, rp);
483
+ deletedOk.push(rp);
319
484
  } catch (err) {
485
+ // Swallowed so one bad path cannot abort the pass, but COUNTED: a pass whose deltas are deletes
486
+ // only never throws, so without this the caller saw a clean resolve while every single delete was
487
+ // being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
488
+ failures++;
320
489
  console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
321
490
  }
322
491
  }
323
-
324
- watchState.update(targetDir, { fileHashes: currentHashes, lastSyncedAt: new Date().toISOString() });
492
+ if (reappeared.length > 0) {
493
+ console.warn(color.yellow(
494
+ ` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
495
+ `Sample: ${reappeared.slice(0, 5).join(", ")}`));
496
+ // Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
497
+ // (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
498
+ // was never uploaded — recording the new hash would claim the server holds content it never
499
+ // received. Leaving its OLD entry untouched is what makes the next pass see a hash mismatch and
500
+ // upload it. It is in scope and on disk, so that pass finds it in the manifest.
501
+ }
502
+ const actuallyDeleted = deletedOk;
503
+
504
+ // Merge rather than replace. Removals are explicit: the files we actually deleted, plus any the
505
+ // gitignore policy dropped (those were never uploaded, so they must not stay recorded as synced).
506
+ watchState.mergeFileHashes(targetDir, {
507
+ set: currentHashes,
508
+ remove: [...actuallyDeleted, ...gitignoreDropped],
509
+ patch: { lastSyncedAt: new Date().toISOString() },
510
+ });
325
511
  console.log(` ${color.green("✓")} Delta sync complete.`);
512
+ return { failures };
326
513
  } finally {
327
514
  syncInFlight.delete(inFlightKey);
328
515
  }
@@ -335,6 +522,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
335
522
  // daemon kept uploading to the OLD gitId — every edit failed with "Repository not found"
336
523
  // until the daemon was manually restarted. Reading fresh also picks up include/exclude
337
524
  // pattern changes from `niro edit`.
525
+ //
526
+ // Held back by the last sync pass: this folder is not the one its temporary project reflects, so
527
+ // uploading would mix two branches into one copy. Re-checked every pass, so it clears by itself.
528
+ if (notHolding.has(path.resolve(targetDir))) return;
338
529
  const fresh = watchState.get(targetDir, api.currentBaseUrl());
339
530
  if (!fresh || !fresh.gitId) {
340
531
  // Folder was released / is no longer tracked for this backend — stop syncing it.
@@ -390,9 +581,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
390
581
  // File deleted
391
582
  try {
392
583
  await agentApi.deleteFile(gitId, relativePath);
393
- const hashes = { ...(watchState.get(targetDir) || {}).fileHashes };
394
- delete hashes[relativePath];
395
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
584
+ watchState.mergeFileHashes(targetDir, {
585
+ remove: [relativePath],
586
+ patch: { lastSyncedAt: new Date().toISOString() },
587
+ });
396
588
  console.log(` ${color.yellow("−")} ${relativePath}`);
397
589
  } catch (err) {
398
590
  console.error(color.red(` Failed to delete ${relativePath}: ${err.message}`));
@@ -422,8 +614,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
422
614
 
423
615
  try {
424
616
  await agentApi.upsertFile(gitId, relativePath, absPath);
425
- const hashes = { ...storedHashes, [relativePath]: newHash };
426
- watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
617
+ watchState.mergeFileHashes(targetDir, {
618
+ set: { [relativePath]: newHash },
619
+ patch: { lastSyncedAt: new Date().toISOString() },
620
+ });
427
621
  console.log(` ${color.green("+")} ${relativePath}`);
428
622
  } catch (err) {
429
623
  console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
@@ -435,6 +629,13 @@ function shouldIgnore(relativePath) {
435
629
  return parts.some(p => IGNORED_DIRS.has(p) || p.startsWith("."));
436
630
  }
437
631
 
632
+ /** Mirrors buildMinimalManifest's membership rule: empty includePatterns means everything is in. */
633
+ function inManifestScope(relativePath, state) {
634
+ const include = state.includePatterns;
635
+ if (!include || include.length === 0) return true;
636
+ return matchesPatterns(relativePath, include, state.excludePatterns);
637
+ }
638
+
438
639
  function matchesPatterns(relativePath, includePatterns, excludePatterns) {
439
640
  if (!includePatterns || includePatterns.length === 0) return true;
440
641
  const matched = includePatterns.some(p => minimatch(relativePath, p));
@@ -495,4 +696,5 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
495
696
 
496
697
  // handleFileEvent + deltaSyncOnResume are exported for unit testing the
497
698
  // gitignore/fail-closed guards without a live server or fs.watch loop.
498
- module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure };
699
+ module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
700
+ nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
package/src/index.js CHANGED
@@ -9,7 +9,34 @@ function main() {
9
9
  .name("niro")
10
10
  .description("Niro CLI — onboard repos, build indexes, manage projects from the terminal.")
11
11
  .version(pkg.version)
12
- .option("--api-url <url>", "override API base URL (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
12
+ .option("--api-url <url>", "backend to log in to — `niro login` only (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
13
+
14
+ // --api-url is accepted by `niro login` and REFUSED everywhere else.
15
+ //
16
+ // It used to be a global override, but it was only half honoured. api.request takes it
17
+ // (api.js: `const base = apiUrl || baseUrl(creds)`), while the upload chain builds its own
18
+ // URL from the stored credentials and never sees it (agentApi.js:113 and :175). So
19
+ // `niro init --api-url <local>` while logged in elsewhere listed projects from one backend
20
+ // and uploaded the folder's source to the other — one command, two servers, no warning.
21
+ //
22
+ // Threading it through every path is the other possible fix, but the backend is a property
23
+ // of a SESSION, not of a single command: credentials, tokens, gitIds and watchState tags are
24
+ // all scoped to one backend, so a per-command override can only ever be a way to mix them.
25
+ // Choosing it once, at login, is the honest contract. NIRO_API_URL and the config file still
26
+ // work for every command, because both feed the same resolution chain the uploader uses.
27
+ //
28
+ // `niro new-temp-project` already refused this flag for exactly this reason; that local
29
+ // check is now redundant and has been removed in favour of this one.
30
+ program.hook("preAction", (_thisCommand, actionCommand) => {
31
+ if (!program.opts().apiUrl) return;
32
+ if (actionCommand.name() === "login") return;
33
+ console.error("`--api-url` only applies to `niro login`.");
34
+ console.error(" It was ignored by the upload path, so using it here would read from one");
35
+ console.error(" backend and write to another. Pick the backend once instead:");
36
+ console.error(" niro login --api-url <url> log in to that backend");
37
+ console.error(" NIRO_API_URL=<url> niro <cmd> or set it for every command");
38
+ process.exit(1);
39
+ });
13
40
 
14
41
  program
15
42
  .command("login")
@@ -19,7 +46,7 @@ function main() {
19
46
  .option("--email <email>", "email address (password flow only)")
20
47
  .action((opts) => run(async () => {
21
48
  const loginCmd = require("./commands/login");
22
- await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl });
49
+ await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl }); // login: the one command that may set it
23
50
  }));
24
51
 
25
52
  program
@@ -48,7 +75,7 @@ function main() {
48
75
  .option("--quiet-period <seconds>", "debounce seconds before an auto-build (used with --auto-build on)")
49
76
  .option("--confirm-build <yes|no>", "whether to trigger the initial index build now without prompting")
50
77
  .option("--defaults", "accept default values for all env vars without prompting")
51
- .option("--force", "proceed past cross-project/overlap warnings without confirming")
78
+ .option("--force", "onboard a folder with no build file, and proceed past cross-project/overlap warnings without confirming (never bypasses the home/root refusal)")
52
79
  .option("--no-watch", "return immediately after triggering the build instead of watching")
53
80
  .option(
54
81
  "-X, --module-exclude <pattern>",
@@ -64,7 +91,7 @@ function main() {
64
91
  )
65
92
  .action((pathArg, opts) => run(async () => {
66
93
  const initCmd = require("./commands/init");
67
- await initCmd.init(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
94
+ await initCmd.init(pathArg, { ...opts });
68
95
  }));
69
96
 
70
97
  // `niro add` subcommand group
@@ -79,6 +106,7 @@ function main() {
79
106
  .option("--name <name>", "display name for this repo")
80
107
  .option("--sync-mode <local|remote>", "sync on local file changes or remote commits (skips the interactive choice)")
81
108
  .option("--no-confirm", "skip confirmation prompts (non-interactive)")
109
+ .option("--force", "onboard a folder with no build file in it (never bypasses the home/root refusal)")
82
110
  .option("--token-id <id>", "git token ID for private repos")
83
111
  .option(
84
112
  "-X, --module-exclude <pattern>",
@@ -94,7 +122,7 @@ function main() {
94
122
  )
95
123
  .action((pathArg, opts) => run(async () => {
96
124
  const cmd = require("./commands/add-repo");
97
- await cmd.addRepo(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
125
+ await cmd.addRepo(pathArg, { ...opts });
98
126
  }));
99
127
 
100
128
  add
@@ -103,7 +131,7 @@ function main() {
103
131
  .option("--no-watch", "return immediately after triggering the build")
104
132
  .action((opts) => run(async () => {
105
133
  const cmd = require("./commands/add-project");
106
- await cmd.addProject({ ...opts, apiUrl: program.opts().apiUrl });
134
+ await cmd.addProject({ ...opts });
107
135
  }));
108
136
 
109
137
  program
@@ -112,7 +140,7 @@ function main() {
112
140
  .option("--json", "emit machine-readable JSON")
113
141
  .action((pathArg, opts) => run(async () => {
114
142
  const infoCmd = require("./commands/info");
115
- await infoCmd.info(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
143
+ await infoCmd.info(pathArg, { ...opts });
116
144
  }));
117
145
 
118
146
  program
@@ -131,7 +159,7 @@ function main() {
131
159
  .option("--json", "emit machine-readable JSON where supported (set-env, list-env)")
132
160
  .action((opts) => run(async () => {
133
161
  const editCmd = require("./commands/edit");
134
- await editCmd.edit({ ...opts, apiUrl: program.opts().apiUrl });
162
+ await editCmd.edit({ ...opts });
135
163
  }));
136
164
 
137
165
  program
@@ -143,7 +171,7 @@ function main() {
143
171
  .option("--confirm-name <value>", "required for --project: must equal the project's alias or id")
144
172
  .action((opts) => run(async () => {
145
173
  const removeCmd = require("./commands/remove");
146
- await removeCmd.remove({ ...opts, apiUrl: program.opts().apiUrl });
174
+ await removeCmd.remove({ ...opts });
147
175
  }));
148
176
 
149
177
  program
@@ -161,7 +189,7 @@ function main() {
161
189
  .option("--project <id_or_alias>", "which project to base the temporary project on when the repo maps to several")
162
190
  .action((pathArg, opts) => run(async () => {
163
191
  const cmd = require("./commands/takeover");
164
- await cmd.takeover(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
192
+ await cmd.takeover(pathArg, { ...opts });
165
193
  }));
166
194
 
167
195
  program
@@ -172,7 +200,7 @@ function main() {
172
200
  .option("--project <temp_project_id>", "the temporary project to discard, when this machine has no local record of it (e.g. created elsewhere)")
173
201
  .action((pathArg, opts) => run(async () => {
174
202
  const cmd = require("./commands/release");
175
- await cmd.release(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
203
+ await cmd.release(pathArg, { ...opts });
176
204
  }));
177
205
 
178
206
  program
@@ -181,7 +209,7 @@ function main() {
181
209
  .option("--json", "emit machine-readable JSON")
182
210
  .action((projectId, opts) => run(async () => {
183
211
  const statusCmd = require("./commands/status");
184
- await statusCmd.status(projectId, { ...opts, apiUrl: program.opts().apiUrl });
212
+ await statusCmd.status(projectId, { ...opts });
185
213
  }));
186
214
 
187
215
  program
@@ -191,7 +219,7 @@ function main() {
191
219
  .option("--json", "emit machine-readable JSON")
192
220
  .action((opts) => run(async () => {
193
221
  const projectsCmd = require("./commands/projects");
194
- await projectsCmd.projects({ ...opts, apiUrl: program.opts().apiUrl });
222
+ await projectsCmd.projects({ ...opts });
195
223
  }));
196
224
 
197
225
  program
@@ -202,7 +230,7 @@ function main() {
202
230
  .option("--json", "emit machine-readable JSON status (NDJSON: one object per status change + a final summary)")
203
231
  .action((projectId, opts) => run(async () => {
204
232
  const buildCmd = require("./commands/build");
205
- await buildCmd.build(projectId, { ...opts, apiUrl: program.opts().apiUrl });
233
+ await buildCmd.build(projectId, { ...opts });
206
234
  }));
207
235
 
208
236
  program
@@ -211,7 +239,7 @@ function main() {
211
239
  .option("-y, --yes", "skip the confirmation prompt")
212
240
  .action((projectId, opts) => run(async () => {
213
241
  const deleteCmd = require("./commands/delete");
214
- await deleteCmd.deleteProject(projectId, { ...opts, apiUrl: program.opts().apiUrl });
242
+ await deleteCmd.deleteProject(projectId, { ...opts });
215
243
  }));
216
244
 
217
245
  const svc = program
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Is this folder a sane thing to onboard? The check that runs BEFORE
3
+ * `niro init` / `niro add repo` register anything on the server or upload a byte.
4
+ *
5
+ * Two independent rules:
6
+ *
7
+ * 1. PROTECTED ROOT — never bypassable. $HOME, the filesystem root, any ancestor
8
+ * of $HOME (/home, /Users) and the well-known system dirs are refused outright.
9
+ * Onboarding one of these is never intended, and the damage is not the kind
10
+ * --force should be able to cause by accident: source from every project on the
11
+ * machine uploaded as one blob, a .niro-project pin dropped at $HOME, and a
12
+ * watch daemon streaming the whole home tree. Worse, .gitignore is only honoured
13
+ * when the onboarded folder is itself a git work tree, so onboarding $HOME
14
+ * silently uploads files that each nested repo deliberately ignores.
15
+ *
16
+ * 2. PROJECT INDICATOR — bypassable with --force. The folder must LOOK like a
17
+ * project: a build descriptor at the folder itself, or in a directory just below
18
+ * it. This mirrors ai-orchestrator
19
+ * ProjectStructureResolverService.detectProjects(), which marks any directory
20
+ * holding a descriptor from the ConfigFileParser SUPPORTED_FILES union as a
21
+ * project root. folderSync already carries that union as STRUCTURAL_FILENAMES /
22
+ * STRUCTURAL_EXTENSIONS (documented there as byte-identical to the server), so
23
+ * this REUSES those sets. There is deliberately no second list to drift.
24
+ *
25
+ * Two deliberate differences from the server:
26
+ *
27
+ * - Depth-bounded (INDICATOR_MAX_DEPTH) instead of a full-tree walk. The question
28
+ * here is "is THIS folder a project", not "which projects live somewhere inside
29
+ * it". A descriptor buried deep is precisely the accident being caught: $HOME
30
+ * contains thousands of them.
31
+ * - No parser veto. The server asks the owning parser isProjectIndicator(file) to
32
+ * reject a false match (a Python module literally named setup.py). The CLI cannot
33
+ * parse, so a name match is accepted. That errs toward ALLOWING onboarding, which
34
+ * is the safe direction for a guard: the worst case is the behaviour we had before.
35
+ */
36
+ const fs = require("fs");
37
+ const os = require("os");
38
+ const path = require("path");
39
+ const color = require("./color");
40
+ const folderSync = require("./folderSync");
41
+
42
+ // How far below the target we look for a build descriptor. 0 = the folder itself.
43
+ // 2 covers a monorepo root whose deployables sit under a grouping dir
44
+ // (services/foo/pom.xml, packages/foo/package.json) and still refuses a folder whose
45
+ // only descriptors are buried deeper than any real project root would be.
46
+ const INDICATOR_MAX_DEPTH = 2;
47
+
48
+ // Hard bound on the walk so a pathological tree cannot stall the command.
49
+ const MAX_DIRS_SCANNED = 2000;
50
+
51
+ // Refused outright, by exact path match. Ancestors of $HOME (/home, /Users) are
52
+ // caught by the ancestor rule below as well; listed here for the case where $HOME
53
+ // lives somewhere unusual.
54
+ const SYSTEM_DIRS = new Set([
55
+ "/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/media",
56
+ "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp",
57
+ "/usr", "/var", "/Applications", "/Library", "/System", "/Users", "/Volumes",
58
+ ]);
59
+
60
+ // Windows paths are case-insensitive; POSIX paths are not.
61
+ function samePath(a, b) {
62
+ if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
63
+ return a === b;
64
+ }
65
+
66
+ // Resolve symlinks before comparing, so a symlinked route to a protected path (macOS
67
+ // /tmp -> /private/tmp, an $HOME reached through /mnt/home/<user>) cannot walk past the
68
+ // refusal on a string mismatch. Falls back to the lexical path when the target does not
69
+ // exist or is unreadable — the comparison is then no worse than before.
70
+ function realPath(p) {
71
+ try {
72
+ return fs.realpathSync(p);
73
+ } catch {
74
+ return p;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Why this folder must never be onboarded, or null when it is fine.
80
+ * Returns a human phrase that slots into "Refusing to onboard <phrase>".
81
+ */
82
+ function protectedRootReason(dir) {
83
+ const target = realPath(path.resolve(dir));
84
+
85
+ if (samePath(target, path.parse(target).root)) return "the filesystem root";
86
+
87
+ let home = null;
88
+ try {
89
+ home = os.homedir() ? realPath(path.resolve(os.homedir())) : null;
90
+ } catch {
91
+ home = null; // no home resolvable -> only the root/system rules apply
92
+ }
93
+ if (home) {
94
+ if (samePath(target, home)) return "your home folder";
95
+ // home lives INSIDE target => target is an ancestor of home (/home, /Users, ...).
96
+ const rel = path.relative(target, home);
97
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
98
+ return "a parent of your home folder";
99
+ }
100
+ }
101
+
102
+ if (SYSTEM_DIRS.has(target)) return "a system folder";
103
+ // Also refuse the lexical path when it differs from the resolved one, so a symlink
104
+ // POINTING AT an ordinary folder cannot be used to name a protected path either way.
105
+ if (SYSTEM_DIRS.has(path.resolve(dir))) return "a system folder";
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * The indicator kind this filename represents, or null. Name match wins over
111
+ * extension match, mirroring the server's parserRegistry-then-extensionRegistry
112
+ * lookup order. Returned as a display label ("pom.xml", "*.csproj").
113
+ */
114
+ function indicatorName(fileName) {
115
+ const lower = path.basename(fileName).toLowerCase();
116
+ if (folderSync.STRUCTURAL_FILENAMES.has(lower)) return lower;
117
+ const ext = path.extname(lower);
118
+ if (ext && folderSync.STRUCTURAL_EXTENSIONS.has(ext)) return `*${ext}`;
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Look for project roots at `targetDir` and just below it.
124
+ *
125
+ * Stops descending as soon as a directory IS a project root: once a descriptor is
126
+ * found there is nothing further to prove, and it keeps the common case (a normal
127
+ * repo with a descriptor at its top) down to a single readdir.
128
+ *
129
+ * Returns { roots, indicators: Map(label -> count), truncated }.
130
+ */
131
+ function findProjectIndicators(targetDir, opts = {}) {
132
+ const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : INDICATOR_MAX_DEPTH;
133
+ // Injectable so the breadth-first guarantee can be tested without a 2000-directory fixture.
134
+ const maxDirs = Number.isInteger(opts.maxDirs) ? opts.maxDirs : MAX_DIRS_SCANNED;
135
+ const indicators = new Map();
136
+ let roots = 0;
137
+ let dirsScanned = 0;
138
+ let truncated = false;
139
+
140
+ // Breadth-first, one depth level at a time. Depth-first would let a single wide or
141
+ // deep subtree spend the whole MAX_DIRS_SCANNED budget before the walk ever looked
142
+ // at the target's own siblings, so a real project root could go unseen while junk
143
+ // was scanned. Breadth-first means level 0 is always examined, then all of level 1,
144
+ // and only the deepest level can ever be cut short.
145
+ let frontier = [path.resolve(targetDir)];
146
+ for (let depth = 0; depth <= maxDepth && frontier.length > 0; depth++) {
147
+ const next = [];
148
+ for (const dir of frontier) {
149
+ if (dirsScanned >= maxDirs) { truncated = true; break; }
150
+ dirsScanned++;
151
+
152
+ let entries;
153
+ try {
154
+ entries = fs.readdirSync(dir, { withFileTypes: true });
155
+ } catch {
156
+ continue; // unreadable (permissions, vanished) -> nothing to prove here
157
+ }
158
+
159
+ let isRoot = false;
160
+ for (const entry of entries) {
161
+ if (!entry.isFile()) continue;
162
+ const label = indicatorName(entry.name);
163
+ if (!label) continue;
164
+ isRoot = true;
165
+ indicators.set(label, (indicators.get(label) || 0) + 1);
166
+ }
167
+ // A project root proves what we came to prove; there is no reason to look inside it.
168
+ if (isRoot) { roots++; continue; }
169
+ if (depth >= maxDepth) continue;
170
+
171
+ // Sorted so the walk is deterministic: readdir order is filesystem-dependent, and
172
+ // which directories fall inside the budget must not vary between machines.
173
+ const children = entries
174
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !folderSync.IGNORED_DIRS.has(e.name))
175
+ .map((e) => e.name)
176
+ .sort();
177
+ for (const name of children) next.push(path.join(dir, name));
178
+ }
179
+ if (truncated) break;
180
+ frontier = next;
181
+ }
182
+
183
+ return { roots, indicators, truncated };
184
+ }
185
+
186
+ // "pom.xml x10, package.json x1" — the three commonest kinds, then a tail count.
187
+ function describeIndicators(indicators) {
188
+ const sorted = [...indicators.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
189
+ const shown = sorted.slice(0, 3).map(([label, n]) => (n > 1 ? `${label} x${n}` : label));
190
+ const rest = sorted.length - shown.length;
191
+ return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
192
+ }
193
+
194
+ /**
195
+ * The gate itself. Prints the refusal and returns false when this folder must not be
196
+ * onboarded; returns true when the caller may proceed.
197
+ *
198
+ * opts.force bypasses the INDICATOR rule only. It deliberately does not bypass a
199
+ * protected root: there is no legitimate reason to upload $HOME, and a scripted
200
+ * --force must not be able to do it by accident.
201
+ *
202
+ * opts.alreadyRegistered likewise bypasses the INDICATOR rule only, for a folder this
203
+ * backend already holds a repo for. Callers pass it from watchState so that adding the
204
+ * gate never breaks a sync that was already working.
205
+ */
206
+ function ensureOnboardable(targetDir, opts = {}) {
207
+ const resolved = path.resolve(targetDir);
208
+
209
+ const reason = protectedRootReason(resolved);
210
+ if (reason) {
211
+ console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
212
+ console.error(" That would upload source from every project underneath it, pin this folder");
213
+ console.error(" with a .niro-project file, and watch the whole tree for changes.");
214
+ console.error(` Change into the project folder you meant, then run the command there.`);
215
+ return false;
216
+ }
217
+
218
+ const { roots, indicators, truncated } = findProjectIndicators(resolved, opts);
219
+ if (indicators.size === 0) {
220
+ // A folder this backend ALREADY has a repo for is exempt from the descriptor rule.
221
+ // That rule gates NEW onboarding; it is not a reason to start failing a re-sync of a
222
+ // folder onboarded by an earlier CLI, which would strand the user with no way back in.
223
+ // The protected-root rule above still applied, and still refuses.
224
+ if (opts.alreadyRegistered) {
225
+ console.log(color.dim(
226
+ ` No build file in ${resolved}, but it is already registered here — continuing.`
227
+ ));
228
+ return true;
229
+ }
230
+ if (opts.force) {
231
+ console.log(color.yellow(
232
+ `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
+ ));
234
+ return true;
235
+ }
236
+ console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
+ console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
+ console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
+ console.error(" Pass --force if you really mean to upload this folder.");
240
+ return false;
241
+ }
242
+
243
+ console.log(
244
+ ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
245
+ `(${describeIndicators(indicators)}).`
246
+ );
247
+ return true;
248
+ }
249
+
250
+ module.exports = {
251
+ ensureOnboardable,
252
+ protectedRootReason,
253
+ findProjectIndicators,
254
+ indicatorName,
255
+ describeIndicators,
256
+ INDICATOR_MAX_DEPTH,
257
+ MAX_DIRS_SCANNED,
258
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading the state of a temporary project.
3
+ *
4
+ * A developer gets ONE temporary project per project, and `niro new-temp-project` MOVES it rather than
5
+ * adding a second. So "which folder does Niro currently reflect?" has exactly one answer, and it lives
6
+ * on the server: the branch recorded against the temporary project's repo.
7
+ *
8
+ * That matters because a folder cannot tell locally whether it still holds the slot. Every folder taken
9
+ * over for one project shares the SAME repo id, so the server cannot tell them apart either — if two
10
+ * folders upload, their branches blend into one copy and every answer drawn from it is a mixture. The
11
+ * only way to know is to ask which branch the copy is on and compare it with your own.
12
+ */
13
+ const api = require("./api");
14
+
15
+ /**
16
+ * The branch a temporary project is currently bound to, or null if it cannot be determined.
17
+ *
18
+ * Returns null rather than throwing on a server or network problem: this is used to decide whether to
19
+ * hold back an upload, and "I could not reach the server" must never be read as "you have lost the
20
+ * slot" — that would stop a perfectly healthy folder syncing every time the network hiccuped.
21
+ */
22
+ async function currentBranch(tempProjectId, gitId, opts = {}) {
23
+ if (!tempProjectId) return null;
24
+ let project;
25
+ try {
26
+ project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`, opts);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const repos = project.git_details || project.gitDetails || [];
31
+ // Match the repo by id when we can. Falling back to the first is safe for the single-repo case and
32
+ // still right for multi-repo: every repo in one temporary project is taken over onto one branch.
33
+ const match = repos.find((r) => r.id === gitId) || repos[0];
34
+ return match && match.branch ? match.branch : null;
35
+ }
36
+
37
+ module.exports = { currentBranch };
@@ -127,6 +127,34 @@ function update(folderPath, patch) {
127
127
  save(state);
128
128
  }
129
129
 
130
+ /**
131
+ * Merge per-path hash changes into an entry instead of replacing the whole map.
132
+ *
133
+ * `update({ fileHashes })` replaces the map wholesale, so whichever writer saves last wins and every
134
+ * entry the other writer added in the meantime is silently gone. The delta sync computes a map from
135
+ * its own walk and writes it at the end, which can take minutes on a large upload; any live per-file
136
+ * event that landed during that window is erased. Losing a baseline entry is not cosmetic — the file
137
+ * stops being tracked, so it is never re-sent and the divergence is permanent. That is the difference
138
+ * between the 27 July incident, which healed itself in 35 minutes, and 31 July, which never did.
139
+ *
140
+ * Removals must be explicit rather than implied by absence, so nothing disappears by accident.
141
+ *
142
+ * load/save are synchronous and save renames atomically, so this is safe against other writers in
143
+ * THIS process. The watch daemon is a separate process, so a cross-process interleave is still
144
+ * possible; this shrinks the window from minutes to microseconds rather than closing it entirely.
145
+ */
146
+ function mergeFileHashes(folderPath, { set = {}, remove = [], patch = {} } = {}) {
147
+ const state = load();
148
+ const key = path.resolve(folderPath);
149
+ if (!state[key]) return;
150
+ const merged = { ...(state[key].fileHashes || {}) };
151
+ for (const rp of remove) delete merged[rp];
152
+ // Applied after the removals, so a file deleted and re-created within one run keeps its new hash.
153
+ Object.assign(merged, set);
154
+ state[key] = { ...state[key], ...patch, fileHashes: merged };
155
+ save(state);
156
+ }
157
+
130
158
  /**
131
159
  * Remove a folder's entry. When `expectedGitId` is given, only remove if the CURRENT entry still has that
132
160
  * gitId (compare-and-delete): the self-heal path removes a poison entry after an async repoExists round-trip,
@@ -214,4 +242,5 @@ function hashFile(filePath) {
214
242
  }
215
243
  }
216
244
 
217
- module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile, FILE };
245
+ module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
246
+ mergeFileHashes, FILE };