@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 +159 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/add-repo.js +40 -13
- package/src/commands/info.js +70 -0
- package/src/commands/init.js +41 -3
- package/src/commands/release.js +16 -0
- package/src/commands/takeover.js +42 -5
- package/src/commands/watch.js +422 -37
- package/src/index.js +43 -15
- package/src/lib/onboardTarget.js +258 -0
- package/src/lib/tempProject.js +37 -0
- package/src/lib/watchState.js +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,165 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@niroai/niro`. Dates are release dates.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
|
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
package/src/commands/add-repo.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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);
|
package/src/commands/info.js
CHANGED
|
@@ -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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
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),
|
package/src/commands/release.js
CHANGED
|
@@ -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);
|
package/src/commands/takeover.js
CHANGED
|
@@ -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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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`
|
|
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
|
|