@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 +60 -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/takeover.js +42 -5
- package/src/commands/watch.js +219 -17
- 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 +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@niroai/niro`. Dates are release dates.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
|
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/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
|
|
package/src/commands/watch.js
CHANGED
|
@@ -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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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.
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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>", "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 };
|
package/src/lib/watchState.js
CHANGED
|
@@ -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,
|
|
245
|
+
module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
|
|
246
|
+
mergeFileHashes, FILE };
|