@niroai/niro 0.5.0 → 0.7.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,165 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
- ## Unreleased
5
+ ## 0.7.0 — 2026-08-17
6
+
7
+ A data-loss release. If you use `niro watch`, upgrade.
8
+
9
+ ### Upgrading
10
+
11
+ ```bash
12
+ npm install -g @niroai/niro@latest
13
+ ```
14
+
15
+ Nothing extra is needed from 0.6.0. **From 0.4.x or earlier, upgrade urgently** — every fix
16
+ below is client-side, so a server update alone does not protect you.
17
+
18
+ ### Why this release exists
19
+
20
+ On 31 July a repository lost 494 source files. The client reported them deleted, the server
21
+ removed the working copy and the durable backup, and the client's own record then said
22
+ "deleted", so nothing ever re-sent them. Eleven days passed before anyone noticed.
23
+
24
+ The cause was a `git checkout`. Git removed a directory of test files and wrote it straight
25
+ back a moment later. In that gap the watcher looked, saw nothing, and reported 249 files
26
+ deleted. Everything below follows from that one sentence: **the watcher believed a file was
27
+ gone because it could not see it at one instant.**
28
+
29
+ ### Fixed — the client no longer infers a deletion from a failure to look
30
+
31
+ - **Deletes now wait for the folder to go quiet.** A file that disappears is queued, not
32
+ reported. One timer per folder resets on every new disappearance, so a three-file checkout
33
+ holds for a few seconds and a ninety-second rebase holds for ninety seconds. When the folder
34
+ settles, whatever is still missing is deleted and whatever came back is dropped.
35
+
36
+ This covers `git checkout`, `git stash`, rebases, `npm ci`, `make clean`, a Docker remount and
37
+ editors that save by deleting and rewriting — without needing to know which of them is
38
+ happening. A real deletion now reaches the server about five seconds later than before.
39
+ Set `NIRO_DELETE_SETTLE_MS` to change that.
40
+
41
+ - **A file that cannot be READ is no longer treated as deleted.** A permission error, a
42
+ transient lock, a file being rewritten or simply too many open files all made the hash return
43
+ nothing, and the file was then reported gone. It now keeps its previous state.
44
+
45
+ - **A DIRECTORY that cannot be read is no longer treated as "everything under it was deleted".**
46
+ The same mistake one level up, and worse: an unreadable folder lists as empty, which is
47
+ indistinguishable from one whose files were all removed. Renaming a watched folder and
48
+ restarting reported the entire project as deleted, in a pass that reported itself successful.
49
+ An unreadable root now aborts the pass; an unreadable subdirectory keeps its files while the
50
+ rest of the tree syncs.
51
+
52
+ - **A delete the server refuses is retried instead of forgotten.** It was marked done regardless
53
+ of the outcome, and once out of the local record it could never be re-attempted — so a
54
+ transient error became permanent.
55
+
56
+ - **`niro discard-temp-project` no longer loses the work done in the temporary project.** The
57
+ file record followed the folder back to the source repository, so the source appeared to
58
+ already hold edits it had never received, and the next sync uploaded nothing. Discarding now
59
+ clears that record and re-uploads once.
60
+
61
+ - **Excluding a path removes it again.** A guard added in the same work stopped an excluded file
62
+ ever being removed from the index, which mattered most for a path excluded *because* it holds
63
+ secrets.
64
+
65
+ - **No delete calls for files that were never uploaded.** Editor scratch files are rejected on
66
+ the way in, but a delete was still sent when the editor renamed them away — 21% of all file
67
+ events on one real repository, every one of them pointless traffic.
68
+
69
+ ### Fixed — the retry flood
70
+
71
+ - **The 60-second re-scan now backs off.** It retried at full rate forever, so a two-day server
72
+ problem became roughly 1,400 failed requests a day per folder and buried the real error. The
73
+ delay now doubles to a 30-minute ceiling and resets on success. It never gives up entirely, so
74
+ a recovered server is picked up within half an hour on its own.
75
+
76
+ - **A pass whose file operations failed is no longer counted as a recovery**, which previously
77
+ reset the backoff mid-incident and printed a green "Sync recovered" while the outage continued.
78
+
79
+ ### Changed — worktrees and temporary projects
80
+
81
+ You get one temporary project per project, and creating another moves it rather than adding one.
82
+ With several worktrees open that meant they silently took it from each other.
83
+
84
+ - A second worktree is no longer told to create one. It is told which branch currently holds it
85
+ and to use its own tools for that folder.
86
+ - `niro discard-temp-project` asks before moving the slot away from another folder.
87
+ - A folder that has lost the slot stops uploading, instead of blending two branches into one copy.
88
+ - A branch switch is now told apart from another folder taking the slot.
89
+ - `niro info` shows which local folder and branch syncs each project, and marks the one you are
90
+ standing in.
91
+
92
+ ### Also fixed
93
+
94
+ - **A sync finishing after the folder was re-pointed no longer writes its stale record.** A pass takes
95
+ minutes on a large tree; if `niro new-temp-project` or `niro discard-temp-project` re-pointed the
96
+ folder while it ran, its results were written anyway, against a repository they did not describe.
97
+ For discard that undid the clean slate above and the re-upload was skipped. The record now belongs
98
+ to one repository and a pass that no longer matches is dropped, the same way a folder release has
99
+ always been handled.
100
+
101
+ - **A folder whose root cannot be read now says so once**, and says when it can be read again. It used
102
+ to repeat the same error every sixty seconds for as long as the folder stayed away.
103
+
104
+ ## 0.6.0 — 2026-08-11
105
+
106
+ ### Upgrading
107
+
108
+ ```bash
109
+ npm install -g @niroai/niro@latest
110
+ ```
111
+
112
+ If you are coming from 0.3.x you also still need `niro mcp install` once per assistant — see
113
+ the 0.5.0 notes below. Upgrading from 0.5.0 needs nothing extra.
114
+
115
+ ### Changed — read this before upgrading
116
+
117
+ - **`--api-url` now applies to `niro login` only.** Every other command refuses it and exits 1.
118
+
119
+ It was a global flag, but only half of the code honoured it. Ordinary API calls took it,
120
+ while the file-upload path built its own address from your stored credentials and never saw
121
+ it. So `niro init --api-url http://localhost:8095` while logged in to another backend listed
122
+ projects from localhost and uploaded your source to the backend you were logged in to. One
123
+ command, two servers, no warning.
124
+
125
+ The backend is a property of a session, not of a single command: your credentials, tokens,
126
+ repo ids and sync records are all scoped to one backend, so a per-command override could
127
+ only ever mix them. Choose it once instead:
128
+
129
+ ```bash
130
+ niro login --api-url <url> # log in to that backend
131
+ NIRO_API_URL=<url> niro <command> # or set it for every command
132
+ ```
133
+
134
+ `NIRO_API_URL` and the `apiUrl` config setting still work everywhere, because both feed the
135
+ same resolution the uploader uses. **If you script `--api-url` on any command other than
136
+ `login`, that script now fails with a message** telling it which of the two forms to use.
137
+
138
+ - **`niro init` and `niro add repo` now refuse a folder that is not a project.** Onboarding a
139
+ folder used to work anywhere, including your home folder, which uploaded source from every
140
+ project on the machine, pinned `~/.niro-project` and started a watcher over your whole home
141
+ directory. Two rules now apply before anything is uploaded:
142
+
143
+ - Your home folder, the filesystem root, a parent of your home folder and the system folders
144
+ are refused outright. `--force` does **not** override this.
145
+ - Any other folder must look like a project: a build file (`package.json`, `pom.xml`, `go.mod`,
146
+ `*.csproj`, and the rest of the set the indexer already understands) at the folder itself or
147
+ just below it. A monorepo root whose build files sit one or two levels down is fine.
148
+
149
+ **If you script either command**, a folder with no build file now exits `1` where it used to
150
+ onboard. Pass `--force` for that case. `niro add repo` gains `--force` for this; on `niro init`
151
+ the existing `--force` now covers it too.
152
+
153
+ A folder this backend already has a repo for is exempt from the build-file rule, so an existing
154
+ sync keeps working. The home and root refusal still applies to it.
155
+
156
+ The check runs before the login and before any server call, so a refused folder leaves nothing
157
+ behind: no registered repo, no orphan project, no pin file, no watcher.
158
+
159
+ - **`niro init` now notices nested repos deeper in the tree.** The "this folder contains repos
160
+ already indexed in Niro" refusal only looked two levels down, so it never fired for the common
161
+ `~/projects/<host>/<org>/<repo>` layout. It now looks four.
162
+
163
+ ## 0.5.0 — 2026-08-06
6
164
 
7
165
  ### Upgrading from 0.3.x — one manual step
8
166
 
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.7.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),
@@ -151,6 +151,22 @@ async function release(pathArg, opts = {}) {
151
151
  ...e,
152
152
  gitId: backup.previousGitId || null,
153
153
  projectId: sourceProjectId,
154
+ // The baseline describes what the TEMPORARY project holds, so it MUST NOT survive the move
155
+ // back to the source repo. Carrying it across told the daemon the source already had every
156
+ // file at its sandbox-era fingerprint — which it did not, because those edits only ever went
157
+ // to the temporary copy. The next pass then compared disk against that baseline, found no
158
+ // difference, and uploaded nothing. Every edit made during the temporary project was silently
159
+ // lost to the source project, with no error and nothing to prompt a re-sync.
160
+ //
161
+ // Clearing it makes the next pass treat every file as new and upload it. That is a full
162
+ // re-upload once, which is the cost of being sure: the client cannot know which files the
163
+ // source repo is missing, so the only safe assumption is all of them.
164
+ //
165
+ // The opposite direction already gets this right — takeover.js seeds fileHashes with what it
166
+ // actually uploaded to the new gitId. A baseline belongs to ONE gitId; when that changes, so
167
+ // must it.
168
+ fileHashes: {},
169
+ lastSyncedAt: null,
154
170
  takeover: undefined,
155
171
  }, e.apiUrl || baseUrl);
156
172
  repinned.push(e.folderPath);
@@ -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