@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/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,46 @@ 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 = {}, expectedGitId } = {}) {
|
|
147
|
+
const state = load();
|
|
148
|
+
const key = path.resolve(folderPath);
|
|
149
|
+
if (!state[key]) return false;
|
|
150
|
+
// Compare-and-merge, exactly as `remove` above does its compare-and-delete, and for the same reason.
|
|
151
|
+
// A sync pass can take minutes on a large tree. If a `niro new-temp-project` or
|
|
152
|
+
// `niro discard-temp-project` re-points the folder DURING that pass, the hashes it computed describe
|
|
153
|
+
// a repo the folder is no longer registered against — and writing them lands a stale baseline on the
|
|
154
|
+
// new gitId. Discard was the case that bit: it clears the baseline so the source repo gets a full
|
|
155
|
+
// re-upload, and an in-flight pass finishing afterwards restored the old map, so the next pass said
|
|
156
|
+
// "No changes since last sync" and the sandbox edits were never uploaded. Exactly the silent loss
|
|
157
|
+
// clearing the baseline was meant to prevent.
|
|
158
|
+
if (expectedGitId !== undefined && state[key].gitId !== expectedGitId) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const merged = { ...(state[key].fileHashes || {}) };
|
|
162
|
+
for (const rp of remove) delete merged[rp];
|
|
163
|
+
// Applied after the removals, so a file deleted and re-created within one run keeps its new hash.
|
|
164
|
+
Object.assign(merged, set);
|
|
165
|
+
state[key] = { ...state[key], ...patch, fileHashes: merged };
|
|
166
|
+
save(state);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
130
170
|
/**
|
|
131
171
|
* Remove a folder's entry. When `expectedGitId` is given, only remove if the CURRENT entry still has that
|
|
132
172
|
* gitId (compare-and-delete): the self-heal path removes a poison entry after an async repoExists round-trip,
|
|
@@ -214,4 +254,5 @@ function hashFile(filePath) {
|
|
|
214
254
|
}
|
|
215
255
|
}
|
|
216
256
|
|
|
217
|
-
module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
|
|
257
|
+
module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
|
|
258
|
+
mergeFileHashes, FILE };
|