@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.
- package/README.md +291 -0
- package/bin/niro.js +2 -0
- package/package.json +41 -0
- package/src/commands/_resolveProject.js +142 -0
- package/src/commands/add-project.js +190 -0
- package/src/commands/add-repo.js +270 -0
- package/src/commands/build.js +118 -0
- package/src/commands/delete.js +66 -0
- package/src/commands/edit.js +601 -0
- package/src/commands/info.js +172 -0
- package/src/commands/init.js +1166 -0
- package/src/commands/login.js +214 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +40 -0
- package/src/commands/projects.js +76 -0
- package/src/commands/release.js +175 -0
- package/src/commands/remove.js +95 -0
- package/src/commands/status.js +75 -0
- package/src/commands/takeover.js +204 -0
- package/src/commands/watch.js +498 -0
- package/src/commands/whoami.js +74 -0
- package/src/index.js +293 -0
- package/src/lib/agentApi.js +276 -0
- package/src/lib/api.js +230 -0
- package/src/lib/browser.js +30 -0
- package/src/lib/color.js +46 -0
- package/src/lib/config.js +38 -0
- package/src/lib/credentials.js +73 -0
- package/src/lib/folderSync.js +503 -0
- package/src/lib/git.js +190 -0
- package/src/lib/moduleExcludeSuggestions.js +57 -0
- package/src/lib/niroProjectFile.js +76 -0
- package/src/lib/pendingRequests.js +99 -0
- package/src/lib/prompt.js +118 -0
- package/src/lib/resolveContext.js +108 -0
- package/src/lib/secret.js +114 -0
- package/src/lib/service.js +221 -0
- package/src/lib/spinner.js +109 -0
- package/src/lib/watchDaemon.js +93 -0
- package/src/lib/watchState.js +217 -0
- package/src/mcp/server.js +764 -0
- package/src/mcp/setup.js +1976 -0
- 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 };
|