@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/src/index.js CHANGED
@@ -9,7 +9,34 @@ function main() {
9
9
  .name("niro")
10
10
  .description("Niro CLI — onboard repos, build indexes, manage projects from the terminal.")
11
11
  .version(pkg.version)
12
- .option("--api-url <url>", "override API base URL (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
12
+ .option("--api-url <url>", "backend to log in to — `niro login` only (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
13
+
14
+ // --api-url is accepted by `niro login` and REFUSED everywhere else.
15
+ //
16
+ // It used to be a global override, but it was only half honoured. api.request takes it
17
+ // (api.js: `const base = apiUrl || baseUrl(creds)`), while the upload chain builds its own
18
+ // URL from the stored credentials and never sees it (agentApi.js:113 and :175). So
19
+ // `niro init --api-url <local>` while logged in elsewhere listed projects from one backend
20
+ // and uploaded the folder's source to the other — one command, two servers, no warning.
21
+ //
22
+ // Threading it through every path is the other possible fix, but the backend is a property
23
+ // of a SESSION, not of a single command: credentials, tokens, gitIds and watchState tags are
24
+ // all scoped to one backend, so a per-command override can only ever be a way to mix them.
25
+ // Choosing it once, at login, is the honest contract. NIRO_API_URL and the config file still
26
+ // work for every command, because both feed the same resolution chain the uploader uses.
27
+ //
28
+ // `niro new-temp-project` already refused this flag for exactly this reason; that local
29
+ // check is now redundant and has been removed in favour of this one.
30
+ program.hook("preAction", (_thisCommand, actionCommand) => {
31
+ if (!program.opts().apiUrl) return;
32
+ if (actionCommand.name() === "login") return;
33
+ console.error("`--api-url` only applies to `niro login`.");
34
+ console.error(" It was ignored by the upload path, so using it here would read from one");
35
+ console.error(" backend and write to another. Pick the backend once instead:");
36
+ console.error(" niro login --api-url <url> log in to that backend");
37
+ console.error(" NIRO_API_URL=<url> niro <cmd> or set it for every command");
38
+ process.exit(1);
39
+ });
13
40
 
14
41
  program
15
42
  .command("login")
@@ -19,7 +46,7 @@ function main() {
19
46
  .option("--email <email>", "email address (password flow only)")
20
47
  .action((opts) => run(async () => {
21
48
  const loginCmd = require("./commands/login");
22
- await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl });
49
+ await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl }); // login: the one command that may set it
23
50
  }));
24
51
 
25
52
  program
@@ -48,7 +75,7 @@ function main() {
48
75
  .option("--quiet-period <seconds>", "debounce seconds before an auto-build (used with --auto-build on)")
49
76
  .option("--confirm-build <yes|no>", "whether to trigger the initial index build now without prompting")
50
77
  .option("--defaults", "accept default values for all env vars without prompting")
51
- .option("--force", "proceed past cross-project/overlap warnings without confirming")
78
+ .option("--force", "onboard a folder with no build file, and proceed past cross-project/overlap warnings without confirming (never bypasses the home/root refusal)")
52
79
  .option("--no-watch", "return immediately after triggering the build instead of watching")
53
80
  .option(
54
81
  "-X, --module-exclude <pattern>",
@@ -64,7 +91,7 @@ function main() {
64
91
  )
65
92
  .action((pathArg, opts) => run(async () => {
66
93
  const initCmd = require("./commands/init");
67
- await initCmd.init(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
94
+ await initCmd.init(pathArg, { ...opts });
68
95
  }));
69
96
 
70
97
  // `niro add` subcommand group
@@ -79,6 +106,7 @@ function main() {
79
106
  .option("--name <name>", "display name for this repo")
80
107
  .option("--sync-mode <local|remote>", "sync on local file changes or remote commits (skips the interactive choice)")
81
108
  .option("--no-confirm", "skip confirmation prompts (non-interactive)")
109
+ .option("--force", "onboard a folder with no build file in it (never bypasses the home/root refusal)")
82
110
  .option("--token-id <id>", "git token ID for private repos")
83
111
  .option(
84
112
  "-X, --module-exclude <pattern>",
@@ -94,7 +122,7 @@ function main() {
94
122
  )
95
123
  .action((pathArg, opts) => run(async () => {
96
124
  const cmd = require("./commands/add-repo");
97
- await cmd.addRepo(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
125
+ await cmd.addRepo(pathArg, { ...opts });
98
126
  }));
99
127
 
100
128
  add
@@ -103,7 +131,7 @@ function main() {
103
131
  .option("--no-watch", "return immediately after triggering the build")
104
132
  .action((opts) => run(async () => {
105
133
  const cmd = require("./commands/add-project");
106
- await cmd.addProject({ ...opts, apiUrl: program.opts().apiUrl });
134
+ await cmd.addProject({ ...opts });
107
135
  }));
108
136
 
109
137
  program
@@ -112,7 +140,7 @@ function main() {
112
140
  .option("--json", "emit machine-readable JSON")
113
141
  .action((pathArg, opts) => run(async () => {
114
142
  const infoCmd = require("./commands/info");
115
- await infoCmd.info(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
143
+ await infoCmd.info(pathArg, { ...opts });
116
144
  }));
117
145
 
118
146
  program
@@ -131,7 +159,7 @@ function main() {
131
159
  .option("--json", "emit machine-readable JSON where supported (set-env, list-env)")
132
160
  .action((opts) => run(async () => {
133
161
  const editCmd = require("./commands/edit");
134
- await editCmd.edit({ ...opts, apiUrl: program.opts().apiUrl });
162
+ await editCmd.edit({ ...opts });
135
163
  }));
136
164
 
137
165
  program
@@ -143,7 +171,7 @@ function main() {
143
171
  .option("--confirm-name <value>", "required for --project: must equal the project's alias or id")
144
172
  .action((opts) => run(async () => {
145
173
  const removeCmd = require("./commands/remove");
146
- await removeCmd.remove({ ...opts, apiUrl: program.opts().apiUrl });
174
+ await removeCmd.remove({ ...opts });
147
175
  }));
148
176
 
149
177
  program
@@ -161,7 +189,7 @@ function main() {
161
189
  .option("--project <id_or_alias>", "which project to base the temporary project on when the repo maps to several")
162
190
  .action((pathArg, opts) => run(async () => {
163
191
  const cmd = require("./commands/takeover");
164
- await cmd.takeover(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
192
+ await cmd.takeover(pathArg, { ...opts });
165
193
  }));
166
194
 
167
195
  program
@@ -172,7 +200,7 @@ function main() {
172
200
  .option("--project <temp_project_id>", "the temporary project to discard, when this machine has no local record of it (e.g. created elsewhere)")
173
201
  .action((pathArg, opts) => run(async () => {
174
202
  const cmd = require("./commands/release");
175
- await cmd.release(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
203
+ await cmd.release(pathArg, { ...opts });
176
204
  }));
177
205
 
178
206
  program
@@ -181,7 +209,7 @@ function main() {
181
209
  .option("--json", "emit machine-readable JSON")
182
210
  .action((projectId, opts) => run(async () => {
183
211
  const statusCmd = require("./commands/status");
184
- await statusCmd.status(projectId, { ...opts, apiUrl: program.opts().apiUrl });
212
+ await statusCmd.status(projectId, { ...opts });
185
213
  }));
186
214
 
187
215
  program
@@ -191,7 +219,7 @@ function main() {
191
219
  .option("--json", "emit machine-readable JSON")
192
220
  .action((opts) => run(async () => {
193
221
  const projectsCmd = require("./commands/projects");
194
- await projectsCmd.projects({ ...opts, apiUrl: program.opts().apiUrl });
222
+ await projectsCmd.projects({ ...opts });
195
223
  }));
196
224
 
197
225
  program
@@ -202,7 +230,7 @@ function main() {
202
230
  .option("--json", "emit machine-readable JSON status (NDJSON: one object per status change + a final summary)")
203
231
  .action((projectId, opts) => run(async () => {
204
232
  const buildCmd = require("./commands/build");
205
- await buildCmd.build(projectId, { ...opts, apiUrl: program.opts().apiUrl });
233
+ await buildCmd.build(projectId, { ...opts });
206
234
  }));
207
235
 
208
236
  program
@@ -211,7 +239,7 @@ function main() {
211
239
  .option("-y, --yes", "skip the confirmation prompt")
212
240
  .action((projectId, opts) => run(async () => {
213
241
  const deleteCmd = require("./commands/delete");
214
- await deleteCmd.deleteProject(projectId, { ...opts, apiUrl: program.opts().apiUrl });
242
+ await deleteCmd.deleteProject(projectId, { ...opts });
215
243
  }));
216
244
 
217
245
  const svc = program
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Is this folder a sane thing to onboard? The check that runs BEFORE
3
+ * `niro init` / `niro add repo` register anything on the server or upload a byte.
4
+ *
5
+ * Two independent rules:
6
+ *
7
+ * 1. PROTECTED ROOT — never bypassable. $HOME, the filesystem root, any ancestor
8
+ * of $HOME (/home, /Users) and the well-known system dirs are refused outright.
9
+ * Onboarding one of these is never intended, and the damage is not the kind
10
+ * --force should be able to cause by accident: source from every project on the
11
+ * machine uploaded as one blob, a .niro-project pin dropped at $HOME, and a
12
+ * watch daemon streaming the whole home tree. Worse, .gitignore is only honoured
13
+ * when the onboarded folder is itself a git work tree, so onboarding $HOME
14
+ * silently uploads files that each nested repo deliberately ignores.
15
+ *
16
+ * 2. PROJECT INDICATOR — bypassable with --force. The folder must LOOK like a
17
+ * project: a build descriptor at the folder itself, or in a directory just below
18
+ * it. This mirrors ai-orchestrator
19
+ * ProjectStructureResolverService.detectProjects(), which marks any directory
20
+ * holding a descriptor from the ConfigFileParser SUPPORTED_FILES union as a
21
+ * project root. folderSync already carries that union as STRUCTURAL_FILENAMES /
22
+ * STRUCTURAL_EXTENSIONS (documented there as byte-identical to the server), so
23
+ * this REUSES those sets. There is deliberately no second list to drift.
24
+ *
25
+ * Two deliberate differences from the server:
26
+ *
27
+ * - Depth-bounded (INDICATOR_MAX_DEPTH) instead of a full-tree walk. The question
28
+ * here is "is THIS folder a project", not "which projects live somewhere inside
29
+ * it". A descriptor buried deep is precisely the accident being caught: $HOME
30
+ * contains thousands of them.
31
+ * - No parser veto. The server asks the owning parser isProjectIndicator(file) to
32
+ * reject a false match (a Python module literally named setup.py). The CLI cannot
33
+ * parse, so a name match is accepted. That errs toward ALLOWING onboarding, which
34
+ * is the safe direction for a guard: the worst case is the behaviour we had before.
35
+ */
36
+ const fs = require("fs");
37
+ const os = require("os");
38
+ const path = require("path");
39
+ const color = require("./color");
40
+ const folderSync = require("./folderSync");
41
+
42
+ // How far below the target we look for a build descriptor. 0 = the folder itself.
43
+ // 2 covers a monorepo root whose deployables sit under a grouping dir
44
+ // (services/foo/pom.xml, packages/foo/package.json) and still refuses a folder whose
45
+ // only descriptors are buried deeper than any real project root would be.
46
+ const INDICATOR_MAX_DEPTH = 2;
47
+
48
+ // Hard bound on the walk so a pathological tree cannot stall the command.
49
+ const MAX_DIRS_SCANNED = 2000;
50
+
51
+ // Refused outright, by exact path match. Ancestors of $HOME (/home, /Users) are
52
+ // caught by the ancestor rule below as well; listed here for the case where $HOME
53
+ // lives somewhere unusual.
54
+ const SYSTEM_DIRS = new Set([
55
+ "/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/media",
56
+ "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp",
57
+ "/usr", "/var", "/Applications", "/Library", "/System", "/Users", "/Volumes",
58
+ ]);
59
+
60
+ // Windows paths are case-insensitive; POSIX paths are not.
61
+ function samePath(a, b) {
62
+ if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
63
+ return a === b;
64
+ }
65
+
66
+ // Resolve symlinks before comparing, so a symlinked route to a protected path (macOS
67
+ // /tmp -> /private/tmp, an $HOME reached through /mnt/home/<user>) cannot walk past the
68
+ // refusal on a string mismatch. Falls back to the lexical path when the target does not
69
+ // exist or is unreadable — the comparison is then no worse than before.
70
+ function realPath(p) {
71
+ try {
72
+ return fs.realpathSync(p);
73
+ } catch {
74
+ return p;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Why this folder must never be onboarded, or null when it is fine.
80
+ * Returns a human phrase that slots into "Refusing to onboard <phrase>".
81
+ */
82
+ function protectedRootReason(dir) {
83
+ const target = realPath(path.resolve(dir));
84
+
85
+ if (samePath(target, path.parse(target).root)) return "the filesystem root";
86
+
87
+ let home = null;
88
+ try {
89
+ home = os.homedir() ? realPath(path.resolve(os.homedir())) : null;
90
+ } catch {
91
+ home = null; // no home resolvable -> only the root/system rules apply
92
+ }
93
+ if (home) {
94
+ if (samePath(target, home)) return "your home folder";
95
+ // home lives INSIDE target => target is an ancestor of home (/home, /Users, ...).
96
+ const rel = path.relative(target, home);
97
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
98
+ return "a parent of your home folder";
99
+ }
100
+ }
101
+
102
+ if (SYSTEM_DIRS.has(target)) return "a system folder";
103
+ // Also refuse the lexical path when it differs from the resolved one, so a symlink
104
+ // POINTING AT an ordinary folder cannot be used to name a protected path either way.
105
+ if (SYSTEM_DIRS.has(path.resolve(dir))) return "a system folder";
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * The indicator kind this filename represents, or null. Name match wins over
111
+ * extension match, mirroring the server's parserRegistry-then-extensionRegistry
112
+ * lookup order. Returned as a display label ("pom.xml", "*.csproj").
113
+ */
114
+ function indicatorName(fileName) {
115
+ const lower = path.basename(fileName).toLowerCase();
116
+ if (folderSync.STRUCTURAL_FILENAMES.has(lower)) return lower;
117
+ const ext = path.extname(lower);
118
+ if (ext && folderSync.STRUCTURAL_EXTENSIONS.has(ext)) return `*${ext}`;
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Look for project roots at `targetDir` and just below it.
124
+ *
125
+ * Stops descending as soon as a directory IS a project root: once a descriptor is
126
+ * found there is nothing further to prove, and it keeps the common case (a normal
127
+ * repo with a descriptor at its top) down to a single readdir.
128
+ *
129
+ * Returns { roots, indicators: Map(label -> count), truncated }.
130
+ */
131
+ function findProjectIndicators(targetDir, opts = {}) {
132
+ const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : INDICATOR_MAX_DEPTH;
133
+ // Injectable so the breadth-first guarantee can be tested without a 2000-directory fixture.
134
+ const maxDirs = Number.isInteger(opts.maxDirs) ? opts.maxDirs : MAX_DIRS_SCANNED;
135
+ const indicators = new Map();
136
+ let roots = 0;
137
+ let dirsScanned = 0;
138
+ let truncated = false;
139
+
140
+ // Breadth-first, one depth level at a time. Depth-first would let a single wide or
141
+ // deep subtree spend the whole MAX_DIRS_SCANNED budget before the walk ever looked
142
+ // at the target's own siblings, so a real project root could go unseen while junk
143
+ // was scanned. Breadth-first means level 0 is always examined, then all of level 1,
144
+ // and only the deepest level can ever be cut short.
145
+ let frontier = [path.resolve(targetDir)];
146
+ for (let depth = 0; depth <= maxDepth && frontier.length > 0; depth++) {
147
+ const next = [];
148
+ for (const dir of frontier) {
149
+ if (dirsScanned >= maxDirs) { truncated = true; break; }
150
+ dirsScanned++;
151
+
152
+ let entries;
153
+ try {
154
+ entries = fs.readdirSync(dir, { withFileTypes: true });
155
+ } catch {
156
+ continue; // unreadable (permissions, vanished) -> nothing to prove here
157
+ }
158
+
159
+ let isRoot = false;
160
+ for (const entry of entries) {
161
+ if (!entry.isFile()) continue;
162
+ const label = indicatorName(entry.name);
163
+ if (!label) continue;
164
+ isRoot = true;
165
+ indicators.set(label, (indicators.get(label) || 0) + 1);
166
+ }
167
+ // A project root proves what we came to prove; there is no reason to look inside it.
168
+ if (isRoot) { roots++; continue; }
169
+ if (depth >= maxDepth) continue;
170
+
171
+ // Sorted so the walk is deterministic: readdir order is filesystem-dependent, and
172
+ // which directories fall inside the budget must not vary between machines.
173
+ const children = entries
174
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !folderSync.IGNORED_DIRS.has(e.name))
175
+ .map((e) => e.name)
176
+ .sort();
177
+ for (const name of children) next.push(path.join(dir, name));
178
+ }
179
+ if (truncated) break;
180
+ frontier = next;
181
+ }
182
+
183
+ return { roots, indicators, truncated };
184
+ }
185
+
186
+ // "pom.xml x10, package.json x1" — the three commonest kinds, then a tail count.
187
+ function describeIndicators(indicators) {
188
+ const sorted = [...indicators.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
189
+ const shown = sorted.slice(0, 3).map(([label, n]) => (n > 1 ? `${label} x${n}` : label));
190
+ const rest = sorted.length - shown.length;
191
+ return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
192
+ }
193
+
194
+ /**
195
+ * The gate itself. Prints the refusal and returns false when this folder must not be
196
+ * onboarded; returns true when the caller may proceed.
197
+ *
198
+ * opts.force bypasses the INDICATOR rule only. It deliberately does not bypass a
199
+ * protected root: there is no legitimate reason to upload $HOME, and a scripted
200
+ * --force must not be able to do it by accident.
201
+ *
202
+ * opts.alreadyRegistered likewise bypasses the INDICATOR rule only, for a folder this
203
+ * backend already holds a repo for. Callers pass it from watchState so that adding the
204
+ * gate never breaks a sync that was already working.
205
+ */
206
+ function ensureOnboardable(targetDir, opts = {}) {
207
+ const resolved = path.resolve(targetDir);
208
+
209
+ const reason = protectedRootReason(resolved);
210
+ if (reason) {
211
+ console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
212
+ console.error(" That would upload source from every project underneath it, pin this folder");
213
+ console.error(" with a .niro-project file, and watch the whole tree for changes.");
214
+ console.error(` Change into the project folder you meant, then run the command there.`);
215
+ return false;
216
+ }
217
+
218
+ const { roots, indicators, truncated } = findProjectIndicators(resolved, opts);
219
+ if (indicators.size === 0) {
220
+ // A folder this backend ALREADY has a repo for is exempt from the descriptor rule.
221
+ // That rule gates NEW onboarding; it is not a reason to start failing a re-sync of a
222
+ // folder onboarded by an earlier CLI, which would strand the user with no way back in.
223
+ // The protected-root rule above still applied, and still refuses.
224
+ if (opts.alreadyRegistered) {
225
+ console.log(color.dim(
226
+ ` No build file in ${resolved}, but it is already registered here — continuing.`
227
+ ));
228
+ return true;
229
+ }
230
+ if (opts.force) {
231
+ console.log(color.yellow(
232
+ `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
+ ));
234
+ return true;
235
+ }
236
+ console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
+ console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
+ console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
+ console.error(" Pass --force if you really mean to upload this folder.");
240
+ return false;
241
+ }
242
+
243
+ console.log(
244
+ ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
245
+ `(${describeIndicators(indicators)}).`
246
+ );
247
+ return true;
248
+ }
249
+
250
+ module.exports = {
251
+ ensureOnboardable,
252
+ protectedRootReason,
253
+ findProjectIndicators,
254
+ indicatorName,
255
+ describeIndicators,
256
+ INDICATOR_MAX_DEPTH,
257
+ MAX_DIRS_SCANNED,
258
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading the state of a temporary project.
3
+ *
4
+ * A developer gets ONE temporary project per project, and `niro new-temp-project` MOVES it rather than
5
+ * adding a second. So "which folder does Niro currently reflect?" has exactly one answer, and it lives
6
+ * on the server: the branch recorded against the temporary project's repo.
7
+ *
8
+ * That matters because a folder cannot tell locally whether it still holds the slot. Every folder taken
9
+ * over for one project shares the SAME repo id, so the server cannot tell them apart either — if two
10
+ * folders upload, their branches blend into one copy and every answer drawn from it is a mixture. The
11
+ * only way to know is to ask which branch the copy is on and compare it with your own.
12
+ */
13
+ const api = require("./api");
14
+
15
+ /**
16
+ * The branch a temporary project is currently bound to, or null if it cannot be determined.
17
+ *
18
+ * Returns null rather than throwing on a server or network problem: this is used to decide whether to
19
+ * hold back an upload, and "I could not reach the server" must never be read as "you have lost the
20
+ * slot" — that would stop a perfectly healthy folder syncing every time the network hiccuped.
21
+ */
22
+ async function currentBranch(tempProjectId, gitId, opts = {}) {
23
+ if (!tempProjectId) return null;
24
+ let project;
25
+ try {
26
+ project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`, opts);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const repos = project.git_details || project.gitDetails || [];
31
+ // Match the repo by id when we can. Falling back to the first is safe for the single-repo case and
32
+ // still right for multi-repo: every repo in one temporary project is taken over onto one branch.
33
+ const match = repos.find((r) => r.id === gitId) || repos[0];
34
+ return match && match.branch ? match.branch : null;
35
+ }
36
+
37
+ module.exports = { currentBranch };
@@ -127,6 +127,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, FILE };
257
+ module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
258
+ mergeFileHashes, FILE };