@niroai/niro 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
+ ## 0.8.0 — 2026-08-26
6
+
7
+ Needed before a server has the layered store turned on. On an older client the new
8
+ server-side setting is not just unusable, it is misleading.
9
+
10
+ ### Upgrading
11
+
12
+ ```bash
13
+ npm install -g @niroai/niro@latest
14
+ ```
15
+
16
+ Nothing extra is needed from 0.7.0.
17
+
18
+ ### Why this release exists
19
+
20
+ A working copy is a private index of the branch in your folder, including edits you have not
21
+ committed. The server can now keep several of them per developer, and can store one as a
22
+ layer over the shared project rather than a full copy. Three things in 0.7.0 get in the way.
23
+
24
+ **A second working copy was refused on your own machine.** 0.7.0 decided by itself that one
25
+ folder already held the project, and never asked the server. Once the server allows several,
26
+ that refusal is simply wrong. Worse, run unattended, by an agent or a script, it threw an
27
+ error advising `niro discard-temp-project` in the other folder. Following that advice
28
+ destroys a working copy that did not need destroying. The client now asks the server and
29
+ does what it says.
30
+
31
+ **A git worktree of a repo with no remote could not be onboarded at all.** Niro records
32
+ onboarding against the folder that was onboarded, which is the main checkout. A linked
33
+ worktree has no entry of its own, so the repo could not be named and `niro new-temp-project`
34
+ failed. It now resolves the main working tree through `git rev-parse --git-common-dir`.
35
+ That combination, a worktree of a remote-less repo, is the layered store's main use case.
36
+
37
+ **Large files were dropped from a working copy without telling you.** The size warning
38
+ existed only in `niro init`. A developer ran `niro new-temp-project` and lost their three
39
+ largest source files, the ones the whole branch was about. Niro then answered questions
40
+ about those files from the shared project's older copy, confidently, with nothing on screen
41
+ to say the working copy did not contain them. The warning now runs on both commands, and it
42
+ quotes the limit the server reports rather than a number compiled into the client, which
43
+ said 256 KiB while a server raised to 2 MiB was accepting the file happily.
44
+
45
+ ### Compatibility
46
+
47
+ Against an older server, the per-file limit is not reported and the client falls back to its
48
+ built-in figure, so the warning quotes an estimate rather than the server's real cap. Nothing
49
+ else changes.
50
+
5
51
  ## 0.7.0 — 2026-08-17
6
52
 
7
53
  A data-loss release. If you use `niro watch`, upgrade.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -174,6 +174,7 @@ async function addRepo(pathArg, opts = {}) {
174
174
  // Get filter recommendation
175
175
  console.log(` Analyzing project structure...`);
176
176
  const filterResult = await agentApi.filterManifest(gitId, manifest);
177
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(color.yellow(m)));
177
178
  const includedCount = filterResult.included_count || filterResult.includedCount || 0;
178
179
  const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
179
180
 
@@ -422,16 +422,9 @@ async function init(pathArg, opts = {}) {
422
422
  process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
423
423
  const manifest = folderSync.buildManifest(targetDir);
424
424
  process.stdout.write(` ${manifest.length} files found.\n`);
425
- for (const m of manifest) {
426
- if (m.size_bytes > folderSync.PER_FILE_CAP_BYTES) {
427
- console.log(warn(
428
- ` ⚠ ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) exceeds the ` +
429
- `256 KiB per-file cap — it will be skipped and not indexed.`
430
- ));
431
- }
432
- }
433
-
434
425
  const filterResult = await agentApi.filterManifest(gitId, manifest);
426
+ // After the filter, not before: the limit is the SERVER's, and it reports it back to us.
427
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
435
428
  const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
436
429
  const includedCount = filterResult.included_count || filterResult.includedCount || includedPaths.length;
437
430
  const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
@@ -48,7 +48,18 @@ async function takeover(pathArg, opts = {}) {
48
48
  const ctx = await resolveCtxLib.resolveContext(targetDir, { apiUrl: opts.apiUrl });
49
49
  if (ctx.state === "error") throw new Error(`Could not resolve project: ${ctx.error}`);
50
50
  if (!ctx.projects.length) {
51
- throw new Error("This folder's git remote doesn't match any Niro project. Run `niro init` first.");
51
+ // Two different dead ends, and telling them apart is the difference between a fix and a guess.
52
+ // A folder with no git remote is not a broken setup: `niro init` never required one. What is
53
+ // missing is the LOCAL record naming the repo, and for a git worktree that record belongs to the
54
+ // main checkout, not to the folder you are standing in. The old message sent every such user off
55
+ // to add a remote, which does not help: the repo keeps the URL it was registered with.
56
+ if (ctx.state === "no_origin") {
57
+ throw new Error(
58
+ "This folder has no git remote, and no Niro repo is recorded for it on this machine. "
59
+ + "Run `niro init` here, or, if this is a git worktree, run it once in the main checkout, "
60
+ + "then re-run this command.");
61
+ }
62
+ throw new Error("This folder's git remote does not match any Niro project. Run `niro init` first.");
52
63
  }
53
64
  let source = ctx.projects[0];
54
65
  if (opts.project) {
@@ -83,8 +94,29 @@ async function takeover(pathArg, opts = {}) {
83
94
  // already reflecting this folder, and the branch it names has moved on. The server relabels it, which
84
95
  // is right, and a prompt there would be pure friction.
85
96
  const here = path.resolve(repoRoot);
86
- const holder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
97
+ // Matched on the REPO as well as the project, and that second clause is the whole point.
98
+ //
99
+ // The hazard this guard exists for is one REPO checked out in two folders on two branches, both
100
+ // uploading into one copy until every answer is a blend of the two. Matching on the project alone
101
+ // also caught the case immediately below this block: a DIFFERENT repo of the same project, which is
102
+ // the multi-repo extend the very next call performs on purpose (ADR-016).
103
+ //
104
+ // So a developer whose change spans ai-orchestrator and ai-assistant was told their project's copy
105
+ // was "already held by another folder" and asked to take it away from themselves, when the server
106
+ // would have happily added the second repo to the same copy. Verified 2026-08-22.
107
+ //
108
+ // It stands down only when it can PROVE the other folder holds a different repo. An entry written
109
+ // before sourceGitId existed records no repo at all, and for those the honest answer is "cannot
110
+ // tell", which has to mean warn: skipping on an unknown would quietly remove the guard for exactly
111
+ // the older entries most likely to have been left behind. Two tests cover that case.
112
+ // ...and it does not apply at all when the server allows several copies per developer. The hazard
113
+ // named above is two folders uploading into ONE copy; when each folder gets its own, there is no
114
+ // blending to prevent. Refusing anyway is what made LAYERED_STORE_MULTIPLE_WORKTREES_PER_DEVELOPER
115
+ // unreachable: the server answers `multiple_worktrees_allowed: true` on every resolve and the CLI
116
+ // refused the second worktree regardless.
117
+ const holder = ctx.multipleWorktreesAllowed ? null : watchState.allLocal(api.currentBaseUrl()).find((e) =>
87
118
  e.takeover && e.takeover.sourceProjectId === source.project_id
119
+ && (e.takeover.sourceGitId == null || e.takeover.sourceGitId === source.git_id)
88
120
  && path.resolve(e.folderPath) !== here);
89
121
  if (holder) {
90
122
  const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
@@ -154,6 +186,9 @@ async function takeover(pathArg, opts = {}) {
154
186
  const manifest = folderSync.buildManifest(repoRoot);
155
187
  const filterResult = await agentApi.filterManifest(newGitId, manifest);
156
188
  const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
189
+ // The gap that cost a day: this command had no oversize warning at all, so a working copy
190
+ // silently omitted the three largest source files in the repo.
191
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
157
192
  const sizes = {};
158
193
  for (const m of manifest) sizes[m.path] = m.size_bytes;
159
194
  console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
@@ -126,8 +126,9 @@ const IGNORED_DIRS = new Set([
126
126
  "dist", ".venv", "vendor", ".idea", ".gradle", "out",
127
127
  ]);
128
128
 
129
- // Server-side per-file cap (256 KiB). Oversize files are skipped server-side at
130
- // /filter; we surface them as a warning so missing source is never silent.
129
+ // Fallback only, for a server too old to report its own limit. The real limit comes back from
130
+ // /filter as max_file_size_bytes, because a duplicated number is wrong the moment either side is
131
+ // configured: this constant said 256 KiB while a server raised to 2 MiB took the file happily.
131
132
  const PER_FILE_CAP_BYTES = 262144;
132
133
 
133
134
  const DEFAULT_CONCURRENCY = 4;
@@ -482,6 +483,46 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
482
483
  return hashes;
483
484
  }
484
485
 
486
+
487
+ /**
488
+ * Tell the developer which files the server is going to drop for being too large.
489
+ *
490
+ * <p>This is not cosmetic. `niro new-temp-project` silently dropped a developer's three largest source
491
+ * files -- the ones the whole branch was about -- because the warning lived only in `niro init`. Niro
492
+ * then answered questions about those files from the shared project's older copy, confidently, with
493
+ * nothing on screen to say the working copy did not contain them.
494
+ *
495
+ * Uses the limit the SERVER reports, so it cannot drift from what the server actually did. Falls back
496
+ * to the built-in constant only when talking to a server too old to report one.
497
+ *
498
+ * Cross-referenced against excluded_paths where the server sends it, so a file dropped for some other
499
+ * reason (a doc, a lockfile, a property file) is not blamed on its size.
500
+ *
501
+ * @returns the number of files warned about
502
+ */
503
+ function warnOversizeFiles(manifest, filterResult, log) {
504
+ const cap = Number(filterResult.max_file_size_bytes || filterResult.maxFileSizeBytes) || PER_FILE_CAP_BYTES;
505
+ // Over the limit AND a file that would otherwise have been indexed. The second half is what keeps
506
+ // this honest: a 900 KiB markdown file is over the limit too, but it was never going to be indexed,
507
+ // so blaming its size teaches the reader to ignore the warning.
508
+ //
509
+ // Deliberately NOT cross-referenced against excluded_paths, though that was the first attempt. It
510
+ // reads as the more precise test and is strictly worse: a file the server never saw is absent from
511
+ // that list, so a 3 MB source file went unwarned in testing. Size plus type needs no agreement
512
+ // between two lists to be right.
513
+ const hits = (manifest || []).filter((m) =>
514
+ m.size_bytes > cap && isUploadableName(m.path.split("/").pop()));
515
+ for (const m of hits) {
516
+ log(` \u26a0 ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) is over this server's `
517
+ + `${Math.round(cap / 1024)} KiB per-file limit \u2014 it will NOT be indexed.`);
518
+ }
519
+ if (hits.length) {
520
+ log(` ${hits.length} file(s) will be missing from this project. Niro will answer about them `
521
+ + "from whatever version it already has, if any.");
522
+ }
523
+ return hits.length;
524
+ }
525
+
485
526
  module.exports = {
486
527
  buildManifest,
487
528
  walkDir,
@@ -500,4 +541,5 @@ module.exports = {
500
541
  IGNORED_DIRS,
501
542
  BATCH_SIZE,
502
543
  PER_FILE_CAP_BYTES,
544
+ warnOversizeFiles,
503
545
  };
package/src/lib/git.js CHANGED
@@ -99,6 +99,28 @@ function inspect(startDir) {
99
99
  return { repoRoot: found.repoRoot, originUrl, branch };
100
100
  }
101
101
 
102
+ /**
103
+ * The root of the MAIN working tree for `repoRoot`.
104
+ *
105
+ * A linked worktree (`git worktree add`) is its own folder on its own branch, but Niro onboarding is
106
+ * recorded against the folder that was actually onboarded, which is the main checkout. Without this a
107
+ * worktree has NO local identity: nothing in ~/.niro/projects.json is keyed by its path, so the repo it
108
+ * belongs to cannot be named at all. That is why `niro new-temp-project` could not be run from a
109
+ * worktree of a repo with no git remote, which is the layered store's main use case.
110
+ *
111
+ * Falls back to `repoRoot` itself when this IS the main working tree, when git cannot answer, or when
112
+ * the common dir is not the usual `<root>/.git` (a bare repo, or an unusual layout). That fallback is
113
+ * the behaviour that existed before this function, so it can only add an answer, never change one.
114
+ */
115
+ function mainWorktreeRoot(repoRoot) {
116
+ if (!repoRoot) return null;
117
+ const common = gitOut("git rev-parse --git-common-dir", repoRoot);
118
+ if (!common) return repoRoot;
119
+ const abs = path.resolve(repoRoot, common);
120
+ if (path.basename(abs) !== ".git") return repoRoot;
121
+ return path.dirname(abs);
122
+ }
123
+
102
124
  /**
103
125
  * Run ONE `git check-ignore --stdin` in `repoRoot` and report BOTH the matched
104
126
  * paths AND whether the check itself succeeded. Paths must be POSIX-relative to
@@ -187,4 +209,5 @@ function isTracked(repoRoot, relativePath) {
187
209
  }
188
210
  }
189
211
 
190
- module.exports = { inspect, deriveRepoName, checkIgnored, checkIgnoredPaths, redactRemoteCreds, isTracked };
212
+ module.exports = { inspect, mainWorktreeRoot, deriveRepoName, checkIgnored, checkIgnoredPaths,
213
+ redactRemoteCreds, isTracked };
@@ -27,9 +27,17 @@
27
27
  *
28
28
  * Never logs secrets. Resolution failures degrade gracefully (state: "error")
29
29
  * rather than throwing, so callers/agents can branch on `state`.
30
+ *
31
+ * A git URL is not an identity every repo has. `niro init` on a folder with no remote registers it
32
+ * with a synthetic `local://<name>` URL (see init.js / add-repo.js) that this module never sends back,
33
+ * and a folder that gains a remote AFTER onboarding keeps the URL it had at the time. Either way the
34
+ * URL join can never match, so when it comes back empty this falls back to the repo id recorded for
35
+ * the folder locally, and asks the server by that instead. See resolveByLocalGitId below.
30
36
  */
37
+ const path = require("path");
31
38
  const git = require("./git");
32
39
  const api = require("./api");
40
+ const watchState = require("./watchState");
33
41
 
34
42
  async function resolveContext(startPath = process.cwd(), opts = {}) {
35
43
  const { repoRoot, originUrl, branch } = git.inspect(startPath);
@@ -39,7 +47,11 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
39
47
  }
40
48
 
41
49
  if (!originUrl) {
42
- return { gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [] };
50
+ // No remote to ask about, but the folder may still be a repo Niro knows: onboarding does not
51
+ // require one. Try the local identity before giving up.
52
+ return resolveByLocalGitId(repoRoot, branch, opts, {
53
+ gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [],
54
+ });
43
55
  }
44
56
 
45
57
  // Omit branch when null (detached HEAD) -> URL-only resolve. Do NOT normalize
@@ -62,13 +74,83 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
62
74
  }
63
75
 
64
76
  const projects = normalizeProjects(data);
65
- return {
77
+ const byUrl = {
66
78
  gitRoot: repoRoot,
67
79
  repoUrl: originUrl,
68
80
  branch: branch || null,
69
81
  state: projects.length ? "resolved" : "unresolved",
70
82
  projects,
83
+ // Whether THIS server lets a developer hold more than one working copy of a project. It is a
84
+ // property of the server's configuration, not of the match, and it was being dropped here: the
85
+ // payload carries it on every resolve precisely so the CLI can stop applying a limit the server
86
+ // no longer enforces, and nothing in the CLI read it. The effect was that the server-side
87
+ // multi-worktree support could not be reached from any client.
88
+ multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
71
89
  };
90
+ if (projects.length) return byUrl;
91
+ // The remote matched nothing. That is the normal answer for a folder onboarded before it had this
92
+ // remote, whose stored URL is the `local://` form. Same fallback as the no-origin case.
93
+ return resolveByLocalGitId(repoRoot, branch, opts, byUrl);
94
+ }
95
+
96
+ /**
97
+ * Resolve by the repo id this machine already recorded for the folder, and return `unresolved`
98
+ * unchanged when that cannot be done. Only ever called after the URL join came back empty, so it can
99
+ * add a match but never replace one.
100
+ *
101
+ * The id is exact where a URL is a guess: `local://demo` is derived from the folder name, so two
102
+ * unrelated folders both called `demo` would resolve to each other, while the id names one repo.
103
+ */
104
+ async function resolveByLocalGitId(repoRoot, branch, opts, unresolved) {
105
+ const apiUrl = opts.apiUrl || api.currentBaseUrl();
106
+ for (const gitId of localGitIdCandidates(repoRoot, apiUrl)) {
107
+ const query = { git_id: gitId };
108
+ if (branch) query.branch = branch;
109
+ let data;
110
+ try {
111
+ data = await api.get("/api/projects/resolve", { query, apiUrl: opts.apiUrl });
112
+ } catch {
113
+ // A server that predates `git_id` rejects the whole request, and would reject it for every
114
+ // candidate. Give back exactly what this folder resolved to before the parameter existed.
115
+ return unresolved;
116
+ }
117
+ const projects = normalizeProjects(data);
118
+ if (projects.length) {
119
+ return {
120
+ ...unresolved,
121
+ state: "resolved",
122
+ projects,
123
+ multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
124
+ };
125
+ }
126
+ }
127
+ return unresolved;
128
+ }
129
+
130
+ /**
131
+ * Repo ids worth asking the server about for this folder, most specific first.
132
+ *
133
+ * Two folders are consulted: this one, and the MAIN working tree when this is a linked worktree. The
134
+ * second is the one that matters. A worktree is never itself onboarded, so it has no entry of its own,
135
+ * and looking only at the folder we are standing in is why a worktree could not be resolved at all.
136
+ *
137
+ * A folder that has already been taken over holds the COPY's gitId in `gitId`; the repo to resolve is
138
+ * the source it was taken from, which is why `takeover.sourceGitId` wins where it is present. Without
139
+ * that a re-run would ask about a temporary project's own repo, which the server correctly refuses.
140
+ */
141
+ function localGitIdCandidates(repoRoot, apiUrl) {
142
+ const folders = [path.resolve(repoRoot)];
143
+ const main = git.mainWorktreeRoot(repoRoot);
144
+ if (main && path.resolve(main) !== folders[0]) folders.push(path.resolve(main));
145
+
146
+ const ids = [];
147
+ for (const folder of folders) {
148
+ const entry = watchState.get(folder, apiUrl);
149
+ if (!entry) continue;
150
+ const id = (entry.takeover && entry.takeover.sourceGitId) || entry.gitId;
151
+ if (id && !ids.includes(id)) ids.push(id);
152
+ }
153
+ return ids;
72
154
  }
73
155
 
74
156
  /**
@@ -129,4 +211,4 @@ function projectIdFromCreateResponse(project) {
129
211
  return id.trim();
130
212
  }
131
213
 
132
- module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse };
214
+ module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse, localGitIdCandidates };