@niroai/niro 0.3.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.
Files changed (43) hide show
  1. package/README.md +291 -0
  2. package/bin/niro.js +2 -0
  3. package/package.json +41 -0
  4. package/src/commands/_resolveProject.js +142 -0
  5. package/src/commands/add-project.js +190 -0
  6. package/src/commands/add-repo.js +270 -0
  7. package/src/commands/build.js +118 -0
  8. package/src/commands/delete.js +66 -0
  9. package/src/commands/edit.js +601 -0
  10. package/src/commands/info.js +172 -0
  11. package/src/commands/init.js +1166 -0
  12. package/src/commands/login.js +214 -0
  13. package/src/commands/logout.js +33 -0
  14. package/src/commands/mcp.js +40 -0
  15. package/src/commands/projects.js +76 -0
  16. package/src/commands/release.js +175 -0
  17. package/src/commands/remove.js +95 -0
  18. package/src/commands/status.js +75 -0
  19. package/src/commands/takeover.js +204 -0
  20. package/src/commands/watch.js +498 -0
  21. package/src/commands/whoami.js +74 -0
  22. package/src/index.js +293 -0
  23. package/src/lib/agentApi.js +276 -0
  24. package/src/lib/api.js +230 -0
  25. package/src/lib/browser.js +30 -0
  26. package/src/lib/color.js +46 -0
  27. package/src/lib/config.js +38 -0
  28. package/src/lib/credentials.js +73 -0
  29. package/src/lib/folderSync.js +503 -0
  30. package/src/lib/git.js +190 -0
  31. package/src/lib/moduleExcludeSuggestions.js +57 -0
  32. package/src/lib/niroProjectFile.js +76 -0
  33. package/src/lib/pendingRequests.js +99 -0
  34. package/src/lib/prompt.js +118 -0
  35. package/src/lib/resolveContext.js +108 -0
  36. package/src/lib/secret.js +114 -0
  37. package/src/lib/service.js +221 -0
  38. package/src/lib/spinner.js +109 -0
  39. package/src/lib/watchDaemon.js +93 -0
  40. package/src/lib/watchState.js +217 -0
  41. package/src/mcp/server.js +764 -0
  42. package/src/mcp/setup.js +1976 -0
  43. package/src/mcp/teardown.js +673 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * niro-watch daemon management via PM2.
3
+ *
4
+ * PM2 is OS-agnostic — it wraps systemd on Linux, launchd on macOS, and
5
+ * Task Scheduler on Windows. We never write init files ourselves.
6
+ *
7
+ * install() — start niro-watch under PM2, persist it, hook into OS startup
8
+ * uninstall() — stop and remove from PM2 + OS startup
9
+ * status() — print PM2 status for niro-watch
10
+ */
11
+ const { spawnSync } = require("child_process");
12
+ const fs = require("fs");
13
+ const os = require("os");
14
+ const path = require("path");
15
+ const color = require("./color");
16
+
17
+ const PROCESS_NAME = "niro-watch";
18
+
19
+ /**
20
+ * True when the pm2 binary is on PATH. Deliberately a PATH lookup and NOT
21
+ * `pm2 --version`: running ANY pm2 command boots PM2's God daemon as a side
22
+ * effect (1s+ on first run, leaves a background process behind). Probes like
23
+ * `niro status` must never do that; flows that go on to run pm2 anyway
24
+ * (install/uninstall) don't care either way.
25
+ */
26
+ function pm2Available() {
27
+ const probe = process.platform === "win32" ? "where pm2" : "command -v pm2";
28
+ const r = spawnSync(probe, { encoding: "utf8", shell: true, stdio: "pipe" });
29
+ return r.status === 0;
30
+ }
31
+
32
+ function pm2Home() {
33
+ return process.env.PM2_HOME || path.join(os.homedir(), ".pm2");
34
+ }
35
+
36
+ /** True when PM2's God daemon is up (live pid in ~/.pm2/pm2.pid). */
37
+ function pm2DaemonAlive() {
38
+ try {
39
+ const pid = parseInt(fs.readFileSync(path.join(pm2Home(), "pm2.pid"), "utf8").trim(), 10);
40
+ if (!pid) return false;
41
+ process.kill(pid, 0); // signal 0 = existence check, sends nothing
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** True when PM2's saved process list (pm2 save) includes niro-watch. */
49
+ function dumpHasProcess() {
50
+ try {
51
+ return fs.readFileSync(path.join(pm2Home(), "dump.pm2"), "utf8").includes(`"${PROCESS_NAME}"`);
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Install and start the niro-watch PM2 process, then wire it into the OS
59
+ * startup system. Prints an explanation before each privileged step.
60
+ *
61
+ * Returns true on success, false if PM2 is not installed.
62
+ */
63
+ function install() {
64
+ if (!pm2Available()) return false;
65
+
66
+ const niroBin = process.argv[1];
67
+
68
+ // Stop any existing instance so we can re-register cleanly
69
+ spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "pipe" });
70
+
71
+ // Start niro-watch under PM2
72
+ const startResult = spawnSync(
73
+ "pm2",
74
+ ["start", niroBin, "--name", PROCESS_NAME, "--", "watch"],
75
+ { shell: true, stdio: "inherit" }
76
+ );
77
+ if (startResult.status !== 0) {
78
+ throw new Error("pm2 start failed");
79
+ }
80
+
81
+ // Persist the process list so PM2 restores it after a manual `pm2 resurrect`
82
+ spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
83
+
84
+ // Wire into OS startup — pm2 startup detects systemd / launchd / etc.
85
+ // It prints the exact privileged command we need to run.
86
+ console.log(
87
+ `\n ${color.bold("Setting up auto-start on login")} — PM2 will ask for your password to register`
88
+ );
89
+ console.log(
90
+ ` with the system startup manager (${getInitSystem()}). This only needs to happen once.\n`
91
+ );
92
+
93
+ const startupProbe = spawnSync("pm2", ["startup"], {
94
+ shell: true,
95
+ encoding: "utf8",
96
+ });
97
+
98
+ const setupCmd = extractSetupCommand(startupProbe.stdout + startupProbe.stderr);
99
+ if (setupCmd) {
100
+ console.log(` Running: ${color.dim(setupCmd)}\n`);
101
+ const r = spawnSync(setupCmd, {
102
+ shell: true,
103
+ stdio: "inherit",
104
+ });
105
+ if (r.status !== 0) {
106
+ console.log(
107
+ color.yellow(
108
+ ` ⚠ Startup hook failed (exit ${r.status}). You can run it manually:\n ${setupCmd}`
109
+ )
110
+ );
111
+ }
112
+ } else {
113
+ // PM2 may print nothing if startup is already configured
114
+ console.log(` ${color.green("✓")} Startup already configured.`);
115
+ }
116
+
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Stop niro-watch and remove it from PM2 + OS startup hooks.
122
+ */
123
+ function uninstall() {
124
+ if (!pm2Available()) return false;
125
+
126
+ spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "inherit" });
127
+ spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
128
+
129
+ console.log(
130
+ `\n ${color.bold("Removing auto-start hook")} — PM2 may ask for your password.\n`
131
+ );
132
+ const unstartupResult = spawnSync("pm2", ["unstartup"], {
133
+ shell: true,
134
+ encoding: "utf8",
135
+ });
136
+ const setupCmd = extractSetupCommand(
137
+ unstartupResult.stdout + unstartupResult.stderr
138
+ );
139
+ if (setupCmd) {
140
+ console.log(` Running: ${color.dim(setupCmd)}\n`);
141
+ spawnSync(setupCmd, { shell: true, stdio: "inherit" });
142
+ }
143
+
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Print PM2 process status for niro-watch.
149
+ */
150
+ function status() {
151
+ if (!pm2Available()) {
152
+ console.log(color.yellow(" PM2 is not installed. Run: npm install -g pm2"));
153
+ return;
154
+ }
155
+ spawnSync("pm2", ["show", PROCESS_NAME], { shell: true, stdio: "inherit" });
156
+ }
157
+
158
+ /**
159
+ * Read-only probe of the niro-watch PM2 process.
160
+ * Returns "missing" (never registered), "online", or "stopped".
161
+ * Callers must check pm2Available() first — without PM2 everything below
162
+ * also reads as "missing", which conflates two different problems.
163
+ *
164
+ * When PM2's God daemon is down, `pm2 show` would BOOT it as a side effect —
165
+ * so answer from disk instead: a dead daemon means nothing is running, and
166
+ * the saved dump distinguishes "registered but stopped" from "never
167
+ * installed". Only a live daemon is asked directly (no boot risk then).
168
+ */
169
+ function processState() {
170
+ if (!pm2DaemonAlive()) {
171
+ return dumpHasProcess() ? "stopped" : "missing";
172
+ }
173
+ const r = spawnSync("pm2", ["show", PROCESS_NAME], {
174
+ shell: true,
175
+ encoding: "utf8",
176
+ stdio: "pipe",
177
+ });
178
+ if (r.status !== 0) return "missing";
179
+ return (r.stdout || "").includes("online") ? "online" : "stopped";
180
+ }
181
+
182
+ /**
183
+ * Install PM2 globally (`npm install -g pm2`), streaming npm's own output so
184
+ * the user sees progress and any permission error verbatim. Returns true on
185
+ * success. shell:true so npm.cmd resolves on Windows.
186
+ */
187
+ function installPm2() {
188
+ const r = spawnSync("npm", ["install", "-g", "pm2"], { shell: true, stdio: "inherit" });
189
+ return r.status === 0;
190
+ }
191
+
192
+ // ─── helpers ─────────────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * PM2 `startup` / `unstartup` prints a line like:
196
+ * [PM2] To setup the Startup Script, copy/paste the following command:
197
+ * sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd ...
198
+ *
199
+ * We extract that command so we can run it directly.
200
+ */
201
+ function extractSetupCommand(output) {
202
+ if (!output) return null;
203
+ const lines = output.split("\n");
204
+ for (const line of lines) {
205
+ const trimmed = line.trim();
206
+ if (trimmed.startsWith("sudo ") || trimmed.startsWith("& ")) {
207
+ return trimmed;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ function getInitSystem() {
214
+ switch (process.platform) {
215
+ case "darwin": return "launchd";
216
+ case "win32": return "Task Scheduler";
217
+ default: return "systemd";
218
+ }
219
+ }
220
+
221
+ module.exports = { install, uninstall, status, processState, pm2Available, installPm2 };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Tiny spinner with optional label + elapsed time. No dependencies.
3
+ *
4
+ * Two output modes, picked once at start():
5
+ *
6
+ * TTY — animated Braille spinner, redrawn in place. Every frame erases the
7
+ * whole line (`\r` + ESC[2K) and is truncated to the terminal width,
8
+ * because a line that wraps can no longer be overwritten by `\r` —
9
+ * narrow panes were printing a brand-new line every 120ms frame.
10
+ *
11
+ * non-TTY (piped output, agent panes, TERM=dumb) — no cursor control is
12
+ * available, so the label is printed ONCE, followed by a quiet
13
+ * heartbeat line every 30s. The old behaviour (print nothing) left
14
+ * users waiting in silence with no hint to check their browser.
15
+ *
16
+ * `opts.stream` / `opts.heartbeatMs` are injection seams for tests; production
17
+ * callers pass only the label.
18
+ */
19
+ const color = require("./color");
20
+
21
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+ const FRAME_MS = 120;
23
+ const HEARTBEAT_MS = 30_000;
24
+ const ERASE_LINE = "\r\x1b[2K";
25
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
26
+
27
+ /**
28
+ * Fit `line` (which may contain ANSI color codes) into `width` visible
29
+ * characters. Returns the line unchanged when it already fits; otherwise
30
+ * returns a color-stripped, "…"-terminated slice — truncating around escape
31
+ * codes is not worth the complexity for an overflow case.
32
+ */
33
+ function fit(line, width) {
34
+ const plain = line.replace(ANSI_RE, "");
35
+ if (plain.length <= width) return line;
36
+ return plain.slice(0, Math.max(0, width - 1)) + "…";
37
+ }
38
+
39
+ /**
40
+ * Labels can carry backend text (e.g. build log messages); an embedded
41
+ * newline would defeat the single-line overwrite, so flatten whitespace.
42
+ */
43
+ function sanitize(label) {
44
+ return String(label).replace(/\s+/g, " ").trim();
45
+ }
46
+
47
+ function start(label, opts = {}) {
48
+ label = sanitize(label);
49
+ const stream = opts.stream || process.stderr;
50
+ const heartbeatMs = opts.heartbeatMs || HEARTBEAT_MS;
51
+ const startedAt = Date.now();
52
+ // TERM=dumb terminals ignore \r-overwrite even when isTTY is true.
53
+ const isTty = Boolean(stream.isTTY) && process.env.TERM !== "dumb";
54
+ const elapsed = () => Math.floor((Date.now() - startedAt) / 1000);
55
+
56
+ // Callers may stop() twice (success path + a finally block); only the first
57
+ // stop draws anything.
58
+ let stopped = false;
59
+
60
+ if (!isTty) {
61
+ stream.write(`${label}\n`);
62
+ const timer = setInterval(() => {
63
+ stream.write(`${label} ${color.dim(`(still waiting — ${elapsed()}s)`)}\n`);
64
+ }, heartbeatMs);
65
+ if (timer.unref) timer.unref();
66
+ return {
67
+ update(newLabel) {
68
+ newLabel = sanitize(newLabel);
69
+ if (stopped || newLabel === label) return;
70
+ label = newLabel;
71
+ stream.write(`${label}\n`);
72
+ },
73
+ stop(finalLine) {
74
+ clearInterval(timer);
75
+ if (stopped) return;
76
+ stopped = true;
77
+ if (finalLine) stream.write(finalLine + "\n");
78
+ },
79
+ };
80
+ }
81
+
82
+ let i = 0;
83
+ const render = () => {
84
+ const frame = FRAMES[i = (i + 1) % FRAMES.length];
85
+ const line = `${color.cyan(frame)} ${label} ${color.dim("(" + elapsed() + "s)")}`;
86
+ stream.write(ERASE_LINE + fit(line, (stream.columns || 80) - 1));
87
+ };
88
+
89
+ render();
90
+ const timer = setInterval(render, FRAME_MS);
91
+ if (timer.unref) timer.unref();
92
+
93
+ return {
94
+ update(newLabel) {
95
+ if (stopped) return;
96
+ label = sanitize(newLabel);
97
+ render();
98
+ },
99
+ stop(finalLine) {
100
+ clearInterval(timer);
101
+ if (stopped) return;
102
+ stopped = true;
103
+ stream.write(ERASE_LINE);
104
+ if (finalLine) stream.write(finalLine + "\n");
105
+ },
106
+ };
107
+ }
108
+
109
+ module.exports = { start };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared "make sure the niro-watch daemon is running" flow, used by
3
+ * `niro init`, `niro add repo`, and `niro service install`.
4
+ *
5
+ * Previously init and add-repo each had their own copy that, when PM2 was
6
+ * missing, printed a small warning and moved on — customers read the final
7
+ * "✓ Done." and only discovered auto-sync was dead after a reboot. This
8
+ * helper instead OFFERS to install PM2 on the spot (interactive runs), and
9
+ * returns a result object so callers can make their closing banner honest.
10
+ *
11
+ * All service/prompt collaborators are called through their module objects so
12
+ * tests can stub them with t.mock.method (same seam the init tests use).
13
+ */
14
+ const service = require("./service");
15
+ const prompt = require("./prompt");
16
+ const color = require("./color");
17
+
18
+ /**
19
+ * Ensure PM2 exists (offering to install it when allowed to ask), then
20
+ * install/start the niro-watch daemon.
21
+ *
22
+ * `skipPrompts` MUST be passed as true by flows that promised not to ask
23
+ * (`niro init --defaults`, `niro add repo --no-confirm`) — installing global
24
+ * software needs an explicit yes, so those flows get the warning path, never
25
+ * a hanging prompt or a consent-by-default-Enter npm install.
26
+ *
27
+ * Returns { ok: true } on success, or { ok: false, reason } where reason is:
28
+ * "pm2_missing" — no PM2, and no prompt allowed (non-interactive
29
+ * or skipPrompts)
30
+ * "declined" — user said no to installing PM2
31
+ * "pm2_install_failed" — `npm install -g pm2` exited non-zero
32
+ * "daemon_install_failed" — registering niro-watch under PM2 failed
33
+ */
34
+ async function ensureWatchDaemon({ skipPrompts = false } = {}) {
35
+ if (!service.pm2Available()) {
36
+ console.log(`\n ${color.yellow("⚠")} PM2 is not installed. Niro uses PM2 to keep your synced folders`);
37
+ console.log(` up to date in the background, including after a reboot.`);
38
+
39
+ if (skipPrompts || prompt.isNonInteractive()) {
40
+ console.log(` To enable auto-sync: ${color.cyan("npm install -g pm2")} then ${color.cyan("niro service install")}`);
41
+ return { ok: false, reason: "pm2_missing" };
42
+ }
43
+
44
+ const yes = await prompt.confirm(` Install PM2 now? (runs: npm install -g pm2)`, true);
45
+ if (!yes) {
46
+ console.log(` Skipped. Enable auto-sync later with: ${color.cyan("npm install -g pm2")} then ${color.cyan("niro service install")}`);
47
+ return { ok: false, reason: "declined" };
48
+ }
49
+
50
+ console.log("");
51
+ if (!service.installPm2()) {
52
+ console.log(`\n ${color.yellow("⚠")} PM2 install failed (a permission error is the usual cause).`);
53
+ console.log(` Try manually: ${color.cyan("sudo npm install -g pm2")} then ${color.cyan("niro service install")}`);
54
+ return { ok: false, reason: "pm2_install_failed" };
55
+ }
56
+ console.log(` ${color.green("✓")} PM2 installed.`);
57
+ }
58
+
59
+ console.log(`\n ${color.cyan("●")} Installing niro-watch daemon (keeps your synced folders up to date, auto-starts on login)...`);
60
+ try {
61
+ // install() returns false when pm2 turns out not to be runnable after
62
+ // all — e.g. npm installed it into a global bin dir that isn't on PATH
63
+ // (npm exits 0 for that). Treat it as the failure it is; the old code's
64
+ // unchecked call here is how "✓ Done." lied to customers.
65
+ if (!service.install()) {
66
+ console.log(` ${color.yellow("⚠")} PM2 was installed but isn't runnable from this shell (its global bin`);
67
+ console.log(` directory may not be on your PATH). Open a NEW terminal and run ${color.cyan("niro service install")}.`);
68
+ return { ok: false, reason: "daemon_install_failed" };
69
+ }
70
+ console.log(` ${color.green("✓")} niro-watch is running and will start automatically on login.`);
71
+ return { ok: true };
72
+ } catch (err) {
73
+ console.log(` ${color.yellow("⚠")} Could not install the watch daemon: ${err.message}`);
74
+ console.log(` Run ${color.cyan("niro service install")} to try again, or ${color.cyan("niro watch")} to sync manually.`);
75
+ return { ok: false, reason: "daemon_install_failed" };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Read-only daemon probe for `niro status`:
81
+ * "pm2_missing" | "not_installed" | "stopped" | "running".
82
+ *
83
+ * Must stay side-effect free and fast — status is polled by agents. The
84
+ * underlying service calls avoid booting PM2's God daemon (see service.js).
85
+ */
86
+ function watchDaemonState() {
87
+ if (!service.pm2Available()) return "pm2_missing";
88
+ const st = service.processState();
89
+ if (st === "missing") return "not_installed";
90
+ return st === "online" ? "running" : "stopped";
91
+ }
92
+
93
+ module.exports = { ensureWatchDaemon, watchDaemonState };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Manages ~/.niro/projects.json — persists per-folder sync state so that
3
+ * niro watch can resume without re-registering or re-uploading all files.
4
+ *
5
+ * Shape of each entry (keyed by absolute folder path):
6
+ * {
7
+ * gitId: string,
8
+ * apiUrl: string | undefined, // backend the gitId was registered against
9
+ * projectId: string | null,
10
+ * branch: string | null,
11
+ * includePatterns: string[],
12
+ * excludePatterns: string[],
13
+ * fileHashes: { [relativePath]: sha256hex },
14
+ * lastSyncedAt: ISO string | null
15
+ * }
16
+ *
17
+ * `apiUrl` scopes an entry to one backend. A gitId is only valid against the
18
+ * backend that allocated it, so backend-aware reads (`get`, `allLocal`,
19
+ * `findOverlapping`) take the current backend URL and ignore entries that
20
+ * belong to a different one. Entries written before this field existed have no
21
+ * apiUrl and are treated as wildcard (match any backend) — the init self-heal
22
+ * (repoExists check) backstops a wildcard entry that turns out to be stale.
23
+ */
24
+ const fs = require("fs");
25
+ const os = require("os");
26
+ const path = require("path");
27
+ const crypto = require("crypto");
28
+
29
+ const DIR = path.join(os.homedir(), ".niro");
30
+ const FILE = path.join(DIR, "projects.json");
31
+
32
+ /**
33
+ * Canonical backend URL for comparison: trim trailing slash(es) and lowercase
34
+ * scheme+host. Kept in sync with api.normalizeUrl (duplicated, not imported, to
35
+ * keep this storage module free of the HTTP/credentials layer). In practice both
36
+ * sides of a comparison are already produced by api.currentBaseUrl(); this is the
37
+ * defensive backstop for a caller that passes a raw URL.
38
+ */
39
+ function normalizeUrl(u) {
40
+ const s = String(u || "").trim().replace(/\/+$/, "");
41
+ try {
42
+ const url = new URL(s);
43
+ url.protocol = url.protocol.toLowerCase();
44
+ url.host = url.host.toLowerCase();
45
+ return url.toString().replace(/\/+$/, "");
46
+ } catch {
47
+ return s;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * True if `entry` belongs to the backend at `apiUrl`. When `apiUrl` is omitted
53
+ * the caller does not care about the backend (legacy/internal reads) → always
54
+ * true. An entry with no `apiUrl` is wildcard → matches any backend.
55
+ */
56
+ function matchesBackend(entry, apiUrl) {
57
+ if (!apiUrl) return true;
58
+ if (!entry || !entry.apiUrl) return true;
59
+ return normalizeUrl(entry.apiUrl) === normalizeUrl(apiUrl);
60
+ }
61
+
62
+ function load() {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(FILE, "utf8"));
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ function save(state) {
71
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
72
+ // Atomic write: a concurrent reader (the niro-watch daemon runs in a SEPARATE
73
+ // process) must never observe a half-written file. Write to a per-process temp
74
+ // in the same dir, then rename — rename is atomic on the same filesystem, so a
75
+ // reader sees either the whole old file or the whole new one, never a torn one.
76
+ const tmp = `${FILE}.${process.pid}.tmp`;
77
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
78
+ fs.renameSync(tmp, FILE);
79
+ }
80
+
81
+ /**
82
+ * Look up the entry for a folder. When `apiUrl` is passed, the entry is returned
83
+ * only if it belongs to that backend (or is a legacy untagged/wildcard entry);
84
+ * an entry tagged for a different backend returns null so callers never reuse a
85
+ * foreign gitId.
86
+ */
87
+ function get(folderPath, apiUrl) {
88
+ const entry = load()[path.resolve(folderPath)] || null;
89
+ if (!entry) return null;
90
+ return matchesBackend(entry, apiUrl) ? entry : null;
91
+ }
92
+
93
+ /**
94
+ * Return the RAW entry for a folder regardless of backend, or null. The store is path-keyed (one entry per
95
+ * folder), so a backend-scoped `get` returns null for an entry tagged to a DIFFERENT backend even though
96
+ * writing would overwrite it. Callers about to `set` a path use `peek` to avoid clobbering a real
97
+ * registration / takeover backup that lives on another backend.
98
+ */
99
+ function peek(folderPath) {
100
+ return load()[path.resolve(folderPath)] || null;
101
+ }
102
+
103
+ /**
104
+ * Persist an entry for a folder. When `apiUrl` is passed it is stamped onto the
105
+ * entry (normalized) so future reads can scope it to the backend that allocated
106
+ * the gitId.
107
+ */
108
+ function set(folderPath, entry, apiUrl) {
109
+ const state = load();
110
+ const tagged = apiUrl ? { ...entry, apiUrl: normalizeUrl(apiUrl) } : entry;
111
+ state[path.resolve(folderPath)] = tagged;
112
+ save(state);
113
+ }
114
+
115
+ /**
116
+ * Merge `patch` into an EXISTING entry. If the entry is absent it is NOT created
117
+ * — `update` only ever refines an entry that `set` already established. This also
118
+ * stops a stray daemon update (e.g. a debounced file event landing just after
119
+ * `init` removed a stale entry, from a separate process) from resurrecting a
120
+ * partial, gitId-less entry on the now-wrong backend.
121
+ */
122
+ function update(folderPath, patch) {
123
+ const state = load();
124
+ const key = path.resolve(folderPath);
125
+ if (!state[key]) return;
126
+ state[key] = { ...state[key], ...patch };
127
+ save(state);
128
+ }
129
+
130
+ /**
131
+ * Remove a folder's entry. When `expectedGitId` is given, only remove if the CURRENT entry still has that
132
+ * gitId (compare-and-delete): the self-heal path removes a poison entry after an async repoExists round-trip,
133
+ * during which a concurrent `niro new-temp-project` may have re-pointed the folder to a NEW, valid gitId —
134
+ * removing it by path alone would silently drop that fresh registration. Mismatch => keep, return false.
135
+ */
136
+ function remove(folderPath, expectedGitId) {
137
+ const state = load();
138
+ const key = path.resolve(folderPath);
139
+ if (expectedGitId !== undefined && state[key] && state[key].gitId !== expectedGitId) {
140
+ return false; // re-pointed under us — do not clobber the new registration
141
+ }
142
+ delete state[key];
143
+ save(state);
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Return all entries that have a gitId (registered local repos). When `apiUrl`
149
+ * is passed, only repos belonging to that backend (plus legacy untagged ones)
150
+ * are returned — so the watch daemon never tries to sync a foreign-backend repo.
151
+ */
152
+ function allLocal(apiUrl) {
153
+ const state = load();
154
+ return Object.entries(state)
155
+ .filter(([, v]) => v.gitId)
156
+ .filter(([, v]) => matchesBackend(v, apiUrl))
157
+ .map(([folderPath, entry]) => ({ folderPath, ...entry }));
158
+ }
159
+
160
+ /**
161
+ * Return watchState entries whose resolved folder path is a STRICT ancestor OR
162
+ * descendant of `targetDir` — i.e. a nesting overlap, not the exact same folder.
163
+ *
164
+ * Used by `niro init` to refuse/warn when the folder being onboarded sits inside
165
+ * (or contains) a folder already tracked by Niro. The exact-path entry is the
166
+ * normal re-run case (handled by `get`) and is excluded here.
167
+ *
168
+ * Overlap is checked on path SEGMENT boundaries, not a bare `startsWith`, so that
169
+ * `/a/foo` is NOT treated as nested under `/a/foobar` (they only share a string
170
+ * prefix, not a directory boundary). Both paths are resolved to absolute first.
171
+ *
172
+ * Returns [{ path, gitId, projectId }] for each overlapping entry.
173
+ *
174
+ * When `apiUrl` is passed, only overlaps tracked on the current backend (plus
175
+ * legacy untagged entries) are reported — a folder tracked on a different
176
+ * backend is not a live overlap for this one.
177
+ */
178
+ function findOverlapping(targetDir, apiUrl) {
179
+ const target = path.resolve(targetDir);
180
+ const state = load();
181
+ const out = [];
182
+ for (const [folderPath, entry] of Object.entries(state)) {
183
+ const other = path.resolve(folderPath);
184
+ if (other === target) continue; // exact match is the re-run case, not an overlap
185
+ if (!matchesBackend(entry, apiUrl)) continue; // different backend → not a live overlap
186
+ if (isAncestor(other, target) || isAncestor(target, other)) {
187
+ out.push({ path: other, gitId: entry.gitId || null, projectId: entry.projectId || null });
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * True when `parent` is a STRICT directory ancestor of `child` (child lives
195
+ * inside parent). Uses path.relative so the check is on real segment boundaries:
196
+ * the relative path must be non-empty, must not start with ".." (child is not
197
+ * outside parent), and must not be absolute (different drive/root).
198
+ */
199
+ function isAncestor(parent, child) {
200
+ const rel = path.relative(parent, child);
201
+ if (!rel) return false; // same path
202
+ if (rel === ".." || rel.startsWith(".." + path.sep)) return false;
203
+ if (path.isAbsolute(rel)) return false;
204
+ return true;
205
+ }
206
+
207
+ /** Compute SHA-256 of a file's contents. Used for delta detection on resume. */
208
+ function hashFile(filePath) {
209
+ try {
210
+ const buf = fs.readFileSync(filePath);
211
+ return crypto.createHash("sha256").update(buf).digest("hex");
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile, FILE };