@niroai/niro 0.8.0 → 0.9.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 +41 -0
- package/package.json +1 -1
- package/src/commands/release.js +76 -10
- package/src/commands/takeover.js +86 -4
- package/src/lib/git.js +21 -3
- package/src/lib/tempProject.js +25 -1
- package/src/mcp/server.js +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@niroai/niro`. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.9.0 — 2026-08-26
|
|
6
|
+
|
|
7
|
+
One change to how the client waits, and two fixes for cases where Niro and your machine
|
|
8
|
+
disagreed about what still exists.
|
|
9
|
+
|
|
10
|
+
### Upgrading
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @niroai/niro@latest
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Nothing extra is needed from 0.8.0. Against an older server the waiting change simply does not
|
|
17
|
+
engage, because the client decides from the server's answer rather than from a version.
|
|
18
|
+
|
|
19
|
+
**Creating a working copy no longer times out.** It takes 44 to 60 seconds, and it used to run
|
|
20
|
+
inside a single request, against a gateway that gives up at 60. Three at once on 26 August all
|
|
21
|
+
came back as gateway error pages, and because such an error says nothing about whether the work
|
|
22
|
+
finished, the real cause underneath went unread for an afternoon. The client now asks the server
|
|
23
|
+
to do it in the background and waits for it to finish, showing progress. A dropped connection
|
|
24
|
+
while waiting is not treated as a failure, because the copy is still being made on the server; an
|
|
25
|
+
expired login is, because waiting cannot fix it.
|
|
26
|
+
|
|
27
|
+
**Discarding a working copy the server had already deleted.** A working copy can go away
|
|
28
|
+
without your machine being told: someone deletes it in the web UI, or the server reaps it
|
|
29
|
+
after it has sat idle for two weeks. The folder on your machine kept a registration pointing
|
|
30
|
+
at it, and `niro discard-temp-project`, which is what clears that registration, failed before
|
|
31
|
+
it got that far. The registration is also what makes `niro new-temp-project` in a second
|
|
32
|
+
worktree refuse, advising you to run discard in the other folder, so the advice named the one
|
|
33
|
+
command that could not work. Discard now treats a project the server says is missing as
|
|
34
|
+
nothing left to delete: it skips the server call and does the local half, pointing the folder
|
|
35
|
+
back at the original project. A server it could not reach, or one that refuses because the
|
|
36
|
+
copy belongs to another developer, still fails loudly and changes nothing, which matters
|
|
37
|
+
because the server answers both of those with the same status code as a deleted project.
|
|
38
|
+
|
|
39
|
+
**A detached checkout stopped Niro answering.** During `git bisect`, or after checking out a
|
|
40
|
+
tag, your repo is on no branch. `git rev-parse --abbrev-ref HEAD` does not fail there, it
|
|
41
|
+
prints the word `HEAD`, and Niro passed that on as though it were a branch name. Nothing
|
|
42
|
+
indexes a branch called `HEAD`, so the server treated every such question as coming from a
|
|
43
|
+
diverged branch and declined to answer it, for the whole bisect. Niro now reports no branch,
|
|
44
|
+
which is what the server already had a rule for, and answers from the indexed project.
|
|
45
|
+
|
|
5
46
|
## 0.8.0 — 2026-08-26
|
|
6
47
|
|
|
7
48
|
Needed before a server has the layered store turned on. On an older client the new
|
package/package.json
CHANGED
package/src/commands/release.js
CHANGED
|
@@ -22,6 +22,7 @@ const git = require("../lib/git");
|
|
|
22
22
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
23
23
|
const prompt = require("../lib/prompt");
|
|
24
24
|
const watchState = require("../lib/watchState");
|
|
25
|
+
const tempProject = require("../lib/tempProject");
|
|
25
26
|
|
|
26
27
|
const ok = (s) => color.green(s);
|
|
27
28
|
const warn = (s) => color.yellow(s);
|
|
@@ -56,26 +57,80 @@ async function release(pathArg, opts = {}) {
|
|
|
56
57
|
"`niro discard-temp-project --project <temp-project-id>`. The server refuses to delete anything " +
|
|
57
58
|
"that is not a temporary project, so this is always safe.");
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// The temporary project may ALREADY be gone from the server, and nothing tells this machine when
|
|
61
|
+
// that happens: the idle sweeper reaps it after N days, and deleting it in the UI goes down the
|
|
62
|
+
// ordinary project-delete path. This fetch used to throw on that, BEFORE any local cleanup ran, so
|
|
63
|
+
// the folder kept a registration pointing at a project that no longer exists — and the only advice
|
|
64
|
+
// anything offered ("run `niro discard-temp-project` in that folder first") named this very command,
|
|
65
|
+
// which then failed the same way. A definitive not-found means there is nothing left to delete, so
|
|
66
|
+
// skip the server half and do the local half, which is the half that was still needed.
|
|
67
|
+
let project = null;
|
|
68
|
+
let cloneOf = null;
|
|
69
|
+
let alreadyGone = false;
|
|
70
|
+
try {
|
|
71
|
+
project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// Only a definitive "no such project" counts. A network blip, a 401, a 500 or a genuine
|
|
74
|
+
// permissions refusal must still fail loudly — rewriting local registrations because the server
|
|
75
|
+
// could not be reached is exactly the mistake this command exists to undo.
|
|
76
|
+
if (!tempProject.isProjectNotFound(err)) throw err;
|
|
77
|
+
alreadyGone = true;
|
|
78
|
+
}
|
|
79
|
+
if (alreadyGone && !opts.project && !(entry && entry.takeover)) {
|
|
80
|
+
// The id did not come from a takeover backup or from the user — it came from a `.niro-project`
|
|
81
|
+
// pin that happens to name a missing project. Nothing here says this folder was ever taken over,
|
|
82
|
+
// so there is no warrant to touch its registration.
|
|
83
|
+
throw new Error(`Project ${tempProjectId} does not exist on this backend, and nothing on this ` +
|
|
84
|
+
"machine records a temporary project for this folder — refusing to change anything. " +
|
|
85
|
+
"If you know the temporary project's id, pass it explicitly: " +
|
|
86
|
+
"`niro discard-temp-project --project <temp-project-id>`.");
|
|
87
|
+
}
|
|
88
|
+
if (!alreadyGone) {
|
|
89
|
+
cloneOf = project.clone_of_project_id || project.cloneOfProjectId;
|
|
90
|
+
if (!cloneOf) {
|
|
91
|
+
throw new Error(`Project ${tempProjectId} is not a temporary project — refusing. ` +
|
|
92
|
+
"(discard only deletes projects created by `niro new-temp-project`)");
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
// Every folder registered against this temp project (multi-repo takeover, ADR-016).
|
|
67
97
|
const currentDir = path.resolve(targetDir);
|
|
68
|
-
|
|
98
|
+
let folders = watchState.allLocal(baseUrl).filter((e) => e.projectId === tempProjectId);
|
|
69
99
|
if (!folders.some((e) => path.resolve(e.folderPath) === currentDir)) {
|
|
70
100
|
folders.push({ folderPath: targetDir, ...(entry || {}) });
|
|
71
101
|
}
|
|
102
|
+
if (alreadyGone) {
|
|
103
|
+
// Two corrections that only matter once the server is no longer there to check the id against.
|
|
104
|
+
//
|
|
105
|
+
// Drop any folder not actually registered against THIS project. The line above adds the current
|
|
106
|
+
// folder unconditionally, which is right when the id came from that folder, and wrong when it came
|
|
107
|
+
// from `--project`: a mistyped or already-discarded id, run from a folder holding a DIFFERENT live
|
|
108
|
+
// working copy, would rewrite that copy's registration and report it as cleaned up. On the alive
|
|
109
|
+
// path the server's own answer bounds the damage; here nothing does.
|
|
110
|
+
folders = folders.filter((e) => e.projectId === tempProjectId);
|
|
111
|
+
// Then recover the source project from whichever folder recorded it. Every folder in one working
|
|
112
|
+
// copy is a clone of the SAME source project (ADR-016 extends a copy with more repos, it never
|
|
113
|
+
// spans two sources), so any one backup answers for all of them — including a sibling that has no
|
|
114
|
+
// backup of its own, which would otherwise be left pointing at the deleted project while the alive
|
|
115
|
+
// path restores it from `clone_of_project_id`.
|
|
116
|
+
const witness = folders.find((e) => e.takeover && e.takeover.sourceProjectId);
|
|
117
|
+
if (!witness) {
|
|
118
|
+
throw new Error(`Project ${tempProjectId} does not exist on this backend, and no folder on this ` +
|
|
119
|
+
"machine records which project it was a working copy of — refusing to guess. " +
|
|
120
|
+
"Nothing has been changed.");
|
|
121
|
+
}
|
|
122
|
+
cloneOf = witness.takeover.sourceProjectId;
|
|
123
|
+
}
|
|
72
124
|
|
|
73
125
|
// ADR-018 discard guard: deleting the temp project kills coverage for EVERY repo in it. Check each
|
|
74
126
|
// folder's CURRENT branch against its recorded source branch; a folder still on a feature branch is
|
|
75
127
|
// in-flight work whose coverage this discard would destroy. Old entries without sourceBranch can't be
|
|
76
128
|
// verified client-side (the server-side nudge guard still covers the agent-driven path).
|
|
129
|
+
// Skipped when the copy is already gone: this guard exists to warn BEFORE destroying coverage, and
|
|
130
|
+
// there is no coverage left to destroy. Leaving it on would ask the developer to confirm losing
|
|
131
|
+
// something they have already lost, and then refuse non-interactively over it.
|
|
77
132
|
const stillDiverged = [];
|
|
78
|
-
for (const e of folders) {
|
|
133
|
+
for (const e of alreadyGone ? [] : folders) {
|
|
79
134
|
const sourceBranch = e.takeover && e.takeover.sourceBranch;
|
|
80
135
|
if (!sourceBranch || !fs.existsSync(e.folderPath)) continue;
|
|
81
136
|
try {
|
|
@@ -103,7 +158,7 @@ async function release(pathArg, opts = {}) {
|
|
|
103
158
|
return;
|
|
104
159
|
}
|
|
105
160
|
}
|
|
106
|
-
} else if (process.stdout.isTTY && !opts.yes) {
|
|
161
|
+
} else if (!alreadyGone && process.stdout.isTTY && !opts.yes) {
|
|
107
162
|
const sure = await prompt.confirm(
|
|
108
163
|
`Discard temporary project ${color.bold(project.alias || tempProjectId)}?`);
|
|
109
164
|
if (!sure) {
|
|
@@ -112,7 +167,9 @@ async function release(pathArg, opts = {}) {
|
|
|
112
167
|
}
|
|
113
168
|
}
|
|
114
169
|
|
|
115
|
-
|
|
170
|
+
if (!alreadyGone) {
|
|
171
|
+
await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
|
|
172
|
+
}
|
|
116
173
|
|
|
117
174
|
// Restore each folder's local registration. ADR-018: the .niro-project file is left ALONE unless it
|
|
118
175
|
// still carries the OLD-STYLE swapped pin (pre-ADR-018 takeover) — then it points at a now-deleted
|
|
@@ -175,7 +232,16 @@ async function release(pathArg, opts = {}) {
|
|
|
175
232
|
}
|
|
176
233
|
}
|
|
177
234
|
|
|
178
|
-
|
|
235
|
+
if (alreadyGone) {
|
|
236
|
+
console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
|
|
237
|
+
color.dim(" (deleted from the UI, or reaped after sitting idle)."));
|
|
238
|
+
console.log(repinned.length
|
|
239
|
+
? ` ${ok("✓")} Cleaned up ${repinned.length} local registration(s) that still pointed at it. ` +
|
|
240
|
+
"Niro routes back to the original project automatically."
|
|
241
|
+
: ` ${ok("✓")} Nothing left to clean up locally.`);
|
|
242
|
+
} else {
|
|
243
|
+
console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
|
|
244
|
+
}
|
|
179
245
|
if (healed.length) {
|
|
180
246
|
console.log(color.dim(" Healed old-style pins:"));
|
|
181
247
|
for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
|
package/src/commands/takeover.js
CHANGED
|
@@ -22,6 +22,7 @@ const git = require("../lib/git");
|
|
|
22
22
|
const prompt = require("../lib/prompt");
|
|
23
23
|
const watchState = require("../lib/watchState");
|
|
24
24
|
const resolveCtxLib = require("../lib/resolveContext");
|
|
25
|
+
const spinner = require("../lib/spinner");
|
|
25
26
|
|
|
26
27
|
const ok = (s) => color.green(s);
|
|
27
28
|
const warn = (s) => color.yellow(s);
|
|
@@ -146,9 +147,7 @@ async function takeover(pathArg, opts = {}) {
|
|
|
146
147
|
// 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
|
|
147
148
|
// the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
|
|
148
149
|
// cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
|
|
149
|
-
const outcome = await
|
|
150
|
-
`/api/projects/${encodeURIComponent(source.project_id)}/clone`,
|
|
151
|
-
{ local_git_ids: [source.git_id], branch });
|
|
150
|
+
const outcome = await requestClone(source.project_id, source.git_id, branch);
|
|
152
151
|
const newProjectId = outcome.new_project_id || outcome.newProjectId;
|
|
153
152
|
const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
|
|
154
153
|
const newGitId = gitIdMap[source.git_id];
|
|
@@ -273,4 +272,87 @@ async function takeover(pathArg, opts = {}) {
|
|
|
273
272
|
console.log(color.dim(" routing falls back to the original project by itself."));
|
|
274
273
|
}
|
|
275
274
|
|
|
276
|
-
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ask the server for a working copy, and wait for it without holding the request open.
|
|
278
|
+
*
|
|
279
|
+
* WHY. The copy takes 44 to 60 seconds. It used to run inside the POST, and the gateway in front of
|
|
280
|
+
* the server gives up at 60, so three requests at once on 2026-08-26 all came back as HTML 504 pages.
|
|
281
|
+
* A 504 also says nothing about whether the work finished: the real error underneath, a duplicate key
|
|
282
|
+
* from a migration that had not been run, went unread for an afternoon because of it.
|
|
283
|
+
*
|
|
284
|
+
* WORKS AGAINST BOTH SERVERS, and that is why it branches on the RESPONSE rather than on a version.
|
|
285
|
+
* A server that understands `async` returns 202 and a job id to poll. An older one ignores the unknown
|
|
286
|
+
* field and returns the outcome inline exactly as before, which is the shape this already handled. So
|
|
287
|
+
* there is no capability check to keep in step, and no flag day.
|
|
288
|
+
*/
|
|
289
|
+
async function requestClone(projectId, gitId, branch, opts = {}) {
|
|
290
|
+
const started = await api.post(
|
|
291
|
+
`/api/projects/${encodeURIComponent(projectId)}/clone`,
|
|
292
|
+
{ local_git_ids: [gitId], branch, async: true });
|
|
293
|
+
|
|
294
|
+
const jobId = started && (started.job_id || started.jobId);
|
|
295
|
+
if (!jobId) {
|
|
296
|
+
return started; // older server: it did the work inline and this IS the outcome
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Poll until it finishes. The interval is deliberately not tight: nothing is waiting on the answer
|
|
300
|
+
// faster than a person reads, and a shorter one only adds load to the server doing the copy.
|
|
301
|
+
// A seam, not a knob: tests pass a small value so they do not sit through real polls.
|
|
302
|
+
const POLL_MS = opts.pollMs || 2000;
|
|
303
|
+
const GIVE_UP_MS = opts.giveUpMs || 15 * 60 * 1000;
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
const deadline = startedAt + GIVE_UP_MS;
|
|
306
|
+
const sp = spinner.start("Building your temporary project...");
|
|
307
|
+
try {
|
|
308
|
+
for (;;) {
|
|
309
|
+
await sleep(POLL_MS);
|
|
310
|
+
let job;
|
|
311
|
+
try {
|
|
312
|
+
job = await api.get(`/api/projects/clone/job/${encodeURIComponent(jobId)}`);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
// A blip while polling is not a failed clone. The copy is running on the server regardless of
|
|
315
|
+
// whether this one request reached it, so keep waiting rather than reporting a failure that
|
|
316
|
+
// has not happened. A 404 is different: the job is gone, and waiting cannot bring it back.
|
|
317
|
+
// Waiting cannot fix an expired token or a revoked account, and 15 minutes of silent retrying
|
|
318
|
+
// before finally reporting it is worse than saying so at once.
|
|
319
|
+
if (e && (e.status === 401 || e.status === 403)) {
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
if (e && e.status === 404) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`The server has no record of clone job ${jobId}. It may have expired, or the server was `
|
|
325
|
+
+ "restarted mid-copy. Run `niro new-temp-project` again.");
|
|
326
|
+
}
|
|
327
|
+
if (Date.now() > deadline) throw e;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const state = job && job.state;
|
|
331
|
+
if (state === "SUCCEEDED") {
|
|
332
|
+
sp.stop();
|
|
333
|
+
return job.outcome;
|
|
334
|
+
}
|
|
335
|
+
if (state === "FAILED") {
|
|
336
|
+
sp.stop();
|
|
337
|
+
throw new Error(job.error || "The server could not create the temporary project.");
|
|
338
|
+
}
|
|
339
|
+
if (Date.now() > deadline) {
|
|
340
|
+
sp.stop();
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Still building after ${Math.round(GIVE_UP_MS / 60000)} minutes. The server may still finish; `
|
|
343
|
+
+ `check with \`niro status\` before running this again.`);
|
|
344
|
+
}
|
|
345
|
+
// Deliberately NOT re-labelled with the elapsed time. Without a TTY the spinner prints a fresh
|
|
346
|
+
// line every time the label CHANGES, so putting the seconds in it produced one line per poll:
|
|
347
|
+
// measured at 10 lines in 11 seconds. The spinner already shows elapsed time where it can.
|
|
348
|
+
}
|
|
349
|
+
} finally {
|
|
350
|
+
sp.stop();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sleep(ms) {
|
|
355
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = { takeover, requestClone};
|
package/src/lib/git.js
CHANGED
|
@@ -7,6 +7,25 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const { execSync } = require("child_process");
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The branch name from `git rev-parse --abbrev-ref HEAD`, or null when there is no branch.
|
|
12
|
+
*
|
|
13
|
+
* On a detached HEAD (a bisect, a tag checkout, a plain commit checkout) that command does not
|
|
14
|
+
* fail — it prints the literal string "HEAD". A caller that trusts it verbatim therefore reports a
|
|
15
|
+
* branch named "HEAD", which no repository has, and everything downstream reads the checkout as
|
|
16
|
+
* being on an unknown branch rather than on no branch at all.
|
|
17
|
+
*
|
|
18
|
+
* `inspect` below has always collapsed that to null, matching what the `.git/HEAD` file read
|
|
19
|
+
* returns. This is that one rule, shared, so a second caller cannot drift from it. One already had:
|
|
20
|
+
* the MCP bridge sent "HEAD" straight through, so the server's routing ladder took every detached
|
|
21
|
+
* checkout for a diverged branch and refused to answer it, even though the ladder has an explicit
|
|
22
|
+
* guard meant to route exactly that case to the indexed project.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeBranch(raw) {
|
|
25
|
+
const b = typeof raw === "string" ? raw.trim() : "";
|
|
26
|
+
return b && b !== "HEAD" ? b : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
function findGitDir(startDir) {
|
|
11
30
|
let dir = path.resolve(startDir);
|
|
12
31
|
const root = path.parse(dir).root;
|
|
@@ -93,8 +112,7 @@ function inspect(startDir) {
|
|
|
93
112
|
// unavailable the calls return null and behaviour is identical to before.
|
|
94
113
|
if (originUrl === null) originUrl = redactRemoteCreds(gitOut("git remote get-url origin", found.repoRoot));
|
|
95
114
|
if (branch === null) {
|
|
96
|
-
|
|
97
|
-
branch = b && b !== "HEAD" ? b : null; // "HEAD" = detached; keep null like the file read
|
|
115
|
+
branch = normalizeBranch(gitOut("git rev-parse --abbrev-ref HEAD", found.repoRoot));
|
|
98
116
|
}
|
|
99
117
|
return { repoRoot: found.repoRoot, originUrl, branch };
|
|
100
118
|
}
|
|
@@ -210,4 +228,4 @@ function isTracked(repoRoot, relativePath) {
|
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
module.exports = { inspect, mainWorktreeRoot, deriveRepoName, checkIgnored, checkIgnoredPaths,
|
|
213
|
-
redactRemoteCreds, isTracked };
|
|
231
|
+
redactRemoteCreds, isTracked, normalizeBranch };
|
package/src/lib/tempProject.js
CHANGED
|
@@ -34,4 +34,28 @@ async function currentBranch(tempProjectId, gitId, opts = {}) {
|
|
|
34
34
|
return match && match.branch ? match.branch : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* True when an error from a project read means "this project does not exist on this backend".
|
|
39
|
+
*
|
|
40
|
+
* Status alone cannot answer it. A deleted project fails its access check before anything looks for
|
|
41
|
+
* the row, and that check raises AccessDeniedException, which the backend maps to 403 — the SAME
|
|
42
|
+
* status it returns when the project is alive but belongs to someone else
|
|
43
|
+
* (AccessControlService.validateProjectAccess, GlobalExceptionHandler). Treating every 403 as "gone"
|
|
44
|
+
* would let a permissions problem wipe a healthy local registration, and treating none of them as
|
|
45
|
+
* gone is what left dead registrations behind in the first place. So the message decides: only a
|
|
46
|
+
* missing row produces "Project not found", while the other two say "User does not have access to
|
|
47
|
+
* project" and "User not found".
|
|
48
|
+
*
|
|
49
|
+
* A bare status is deliberately NOT enough, not even a 404. This route does not produce one today,
|
|
50
|
+
* so a 404 here means something other than the API answered: a proxy, or an HTML error page. Its
|
|
51
|
+
* sibling agentApi.isRepoNotFound does accept a bare 404, and can afford to — being wrong there
|
|
52
|
+
* costs a re-registration. Being wrong here destroys the record of how to undo a takeover, so this
|
|
53
|
+
* one fails toward keeping it: an unrecognised failure throws and changes nothing.
|
|
54
|
+
*/
|
|
55
|
+
function isProjectNotFound(err) {
|
|
56
|
+
if (!err) return false;
|
|
57
|
+
const text = `${err.message || ""} ${typeof err.body === "string" ? err.body : ""}`;
|
|
58
|
+
return /project not found/i.test(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { currentBranch, isProjectNotFound };
|
package/src/mcp/server.js
CHANGED
|
@@ -41,7 +41,7 @@ const path = require("path");
|
|
|
41
41
|
const readline = require("readline");
|
|
42
42
|
const { execSync } = require("child_process");
|
|
43
43
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
44
|
-
const { redactRemoteCreds } = require("../lib/git");
|
|
44
|
+
const { redactRemoteCreds, normalizeBranch } = require("../lib/git");
|
|
45
45
|
const receiptStore = require("../lib/receiptStore");
|
|
46
46
|
|
|
47
47
|
/** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
|
|
@@ -161,8 +161,13 @@ function detectGitContext(dir) {
|
|
|
161
161
|
if (dir) opts.cwd = dir;
|
|
162
162
|
const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
|
|
163
163
|
if (!remote) return null;
|
|
164
|
+
// normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
|
|
165
|
+
// failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
|
|
166
|
+
// Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
|
|
167
|
+
// diverged and refused to answer it — past its own guard for detached HEAD, which only fires on
|
|
168
|
+
// a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
|
|
164
169
|
let branch = null;
|
|
165
|
-
try { branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)
|
|
170
|
+
try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
|
|
166
171
|
// The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
|
|
167
172
|
// the bridge must report them — without git_commit the server-side check no-ops entirely.
|
|
168
173
|
let commit = null;
|