@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,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro watch [path]` — resume watching a registered local repo.
|
|
3
|
+
*
|
|
4
|
+
* On startup:
|
|
5
|
+
* 1. Load gitId + patterns + fileHashes from ~/.niro/projects.json
|
|
6
|
+
* 2. Delta sync: walk included paths, compare file hashes, upload changed files
|
|
7
|
+
* 3. Start fs.watch loop (recursive) for ongoing changes
|
|
8
|
+
* 4. Heartbeat every 30s
|
|
9
|
+
*
|
|
10
|
+
* Also exported as `startWatch(dir)` so `niro add repo` can transition into
|
|
11
|
+
* watch mode automatically after initial sync.
|
|
12
|
+
*/
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const api = require("../lib/api");
|
|
16
|
+
const agentApi = require("../lib/agentApi");
|
|
17
|
+
const watchState = require("../lib/watchState");
|
|
18
|
+
const credentials = require("../lib/credentials");
|
|
19
|
+
const prompt = require("../lib/prompt");
|
|
20
|
+
const color = require("../lib/color");
|
|
21
|
+
const folderSync = require("../lib/folderSync");
|
|
22
|
+
const {
|
|
23
|
+
SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
|
|
24
|
+
IGNORED_DIRS, isConfigName, chunk,
|
|
25
|
+
} = folderSync;
|
|
26
|
+
|
|
27
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
28
|
+
const DEBOUNCE_MS = 2_000;
|
|
29
|
+
// Folders with a deltaSyncOnResume in flight. The 60s backstop, live file events, and the startup sync can
|
|
30
|
+
// otherwise run concurrently for the same folder; each ends with a whole-map `watchState.update({fileHashes})`,
|
|
31
|
+
// so a slower run would clobber a faster one's hash updates. A per-folder guard serializes them (a skipped
|
|
32
|
+
// run is safe — the in-progress one re-hashes the whole tree and so already covers the skipped trigger).
|
|
33
|
+
const syncInFlight = new Set();
|
|
34
|
+
// The state file is rewritten several times during a `niro takeover` (per-file hashes), so debounce
|
|
35
|
+
// before reconciling the watch set. Also the interval for the mtime backstop re-scan below.
|
|
36
|
+
const STATE_RELOAD_DEBOUNCE_MS = 1_500;
|
|
37
|
+
const RESCAN_INTERVAL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
async function watch(pathArg, opts = {}) {
|
|
40
|
+
const creds = credentials.requireCreds();
|
|
41
|
+
|
|
42
|
+
if (!pathArg) {
|
|
43
|
+
// Watch all registered local repos for the CURRENT backend (entries from
|
|
44
|
+
// another stack are skipped — their gitIds are invalid here).
|
|
45
|
+
const all = watchState.allLocal(api.currentBaseUrl());
|
|
46
|
+
if (all.length === 0) {
|
|
47
|
+
console.error("No registered local repos found. Run `niro add repo` first.");
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Daemon safety net: ONE bad entry or one stray fs/network error must never kill the
|
|
52
|
+
// whole daemon. A crash-looping daemon (PM2 auto-restart) re-syncs and re-marks every
|
|
53
|
+
// project stale on each restart, causing endless rebuild churn server-side.
|
|
54
|
+
process.on("uncaughtException", (err) =>
|
|
55
|
+
console.error(color.red(`Watch daemon error (recovered): ${err && err.message ? err.message : err}`)));
|
|
56
|
+
process.on("unhandledRejection", (err) =>
|
|
57
|
+
console.error(color.red(`Watch daemon error (recovered): ${err && err.message ? err.message : err}`)));
|
|
58
|
+
const watched = new Set();
|
|
59
|
+
for (const entry of all) {
|
|
60
|
+
watched.add(path.resolve(entry.folderPath));
|
|
61
|
+
startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
|
|
62
|
+
}
|
|
63
|
+
// Pick up folders added by `niro takeover` / `niro add repo` WITHOUT a daemon restart. Those commands
|
|
64
|
+
// rewrite the state file, so watch it and start watching any NEW entry live. (A takeover that only
|
|
65
|
+
// re-points an ALREADY-watched folder's gitId is handled per-event by handleFileEvent re-reading
|
|
66
|
+
// watchState; this covers the other half — a brand-new folder the daemon wasn't started with.)
|
|
67
|
+
let reconcileTimer = null;
|
|
68
|
+
const reconcile = () => {
|
|
69
|
+
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
70
|
+
reconcileTimer = setTimeout(() => {
|
|
71
|
+
for (const entry of watchState.allLocal(api.currentBaseUrl())) {
|
|
72
|
+
const resolved = path.resolve(entry.folderPath);
|
|
73
|
+
if (!watched.has(resolved)) {
|
|
74
|
+
watched.add(resolved);
|
|
75
|
+
console.log(` ${color.green("↻")} Now watching new folder: ${entry.folderPath}`);
|
|
76
|
+
startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}, STATE_RELOAD_DEBOUNCE_MS);
|
|
80
|
+
};
|
|
81
|
+
// Watch the DIRECTORY, not the file: watchState writes projects.json via atomic tmp+rename, which
|
|
82
|
+
// swaps the inode — an fs.watch bound to the FILE stops firing after the first rewrite (and the daemon's
|
|
83
|
+
// own 60s backstop guarantees a rewrite), so new folders added mid-session were never picked up. A watch
|
|
84
|
+
// on the parent dir has a stable inode and survives the rename; filter events to the state file's name.
|
|
85
|
+
try {
|
|
86
|
+
const stateDir = path.dirname(watchState.FILE);
|
|
87
|
+
const stateName = path.basename(watchState.FILE);
|
|
88
|
+
fs.watch(stateDir, { persistent: false }, (_evt, filename) => {
|
|
89
|
+
if (!filename || filename === stateName) reconcile();
|
|
90
|
+
}).on("error", (err) =>
|
|
91
|
+
console.error(color.yellow(` State-file watcher error (recovered): ${err.message}`)));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
|
|
94
|
+
}
|
|
95
|
+
// Keep process alive
|
|
96
|
+
await new Promise(() => {});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const targetDir = path.isAbsolute(pathArg) ? path.resolve(pathArg) : path.resolve(process.cwd(), pathArg);
|
|
101
|
+
await startWatch(targetDir, opts);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One watch entry failed to start (its startup delta sync threw). Contain the failure to that
|
|
106
|
+
* entry — and when the cause is "the repo no longer exists on this backend" (a poison entry left
|
|
107
|
+
* by a deleted/rebuilt repo), SELF-HEAL by removing the entry, same policy as `niro init`'s
|
|
108
|
+
* repoExists re-run guard. Everything else is kept for retry on the next daemon start.
|
|
109
|
+
* Exported for tests.
|
|
110
|
+
*/
|
|
111
|
+
async function handleEntryFailure(entry, err, watched) {
|
|
112
|
+
if (agentApi.isRepoNotFound(err)) {
|
|
113
|
+
try {
|
|
114
|
+
const exists = await agentApi.repoExists(entry.gitId);
|
|
115
|
+
if (!exists) {
|
|
116
|
+
// Compare-and-delete by gitId: during the repoExists round-trip a concurrent new-temp-project may
|
|
117
|
+
// have re-pointed this folder to a NEW valid gitId — don't clobber that fresh registration.
|
|
118
|
+
const removed = watchState.remove(entry.folderPath, entry.gitId);
|
|
119
|
+
if (!removed) {
|
|
120
|
+
console.error(color.yellow(
|
|
121
|
+
` Self-heal skipped for ${entry.folderPath}: it was re-pointed to a new repo mid-check — kept.`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// CRITICAL: also drop the folder from the daemon's live `watched` set. Otherwise, when the same
|
|
125
|
+
// folder is re-added with a NEW gitId (e.g. `niro sandbox` after the old sandbox was discarded),
|
|
126
|
+
// the reconcile loop sees it as "already watched" and never re-watches it — the folder's edits
|
|
127
|
+
// silently stop syncing until a full daemon restart. Pruning here lets the reconcile re-adopt it.
|
|
128
|
+
if (watched) watched.delete(path.resolve(entry.folderPath));
|
|
129
|
+
console.error(color.yellow(
|
|
130
|
+
` Self-healed: ${entry.folderPath} pointed at a repo that no longer exists on this ` +
|
|
131
|
+
`backend (${entry.gitId}) — entry removed; other folders unaffected.`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Can't verify — treat as transient below.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
console.error(color.red(
|
|
139
|
+
`Watch error for ${entry.folderPath}: ${err.message} — entry kept, will retry on next daemon start.`));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function startWatch(targetDir, opts = {}) {
|
|
143
|
+
// Scope to the current backend: a folder registered against a different stack
|
|
144
|
+
// has a gitId that is invalid here, so resume must refuse rather than 404 on
|
|
145
|
+
// every sync. (The daemon's allLocal() already filters; this also guards the
|
|
146
|
+
// explicit `niro watch <path>` entry point.)
|
|
147
|
+
const state = watchState.get(targetDir, api.currentBaseUrl());
|
|
148
|
+
if (!state || !state.gitId) {
|
|
149
|
+
console.error(color.red(`No registration found for ${targetDir} on this backend. Run \`niro add repo\` first.`));
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { gitId } = state;
|
|
155
|
+
|
|
156
|
+
console.log(`\n ${color.cyan("Watching")} ${targetDir}`);
|
|
157
|
+
console.log(` gitId: ${color.dim(gitId)}`);
|
|
158
|
+
|
|
159
|
+
// Delta sync on startup. A repo-not-found error must still propagate so the caller's handleEntryFailure
|
|
160
|
+
// can self-heal a poison entry. But a TRANSIENT failure (network blip at daemon start) must NOT prevent
|
|
161
|
+
// the watcher + backstop from being installed below — otherwise the folder is left permanently unwatched
|
|
162
|
+
// for the whole session, and the reconcile loop treats it as "already watched" and never retries it. Catch
|
|
163
|
+
// transient errors here and let the periodic backstop re-sync once connectivity returns.
|
|
164
|
+
try {
|
|
165
|
+
await deltaSyncOnResume(gitId, targetDir, state);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (agentApi.isRepoNotFound(err)) throw err; // poison entry — let handleEntryFailure remove it
|
|
168
|
+
console.error(color.yellow(
|
|
169
|
+
` Initial sync failed for ${targetDir} (${err.message}) — watching anyway; the backstop will retry.`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Start heartbeat. Re-read fresh state each tick exactly like the backstop below: a `niro
|
|
173
|
+
// new-temp-project` / `niro discard-temp-project` re-point mid-session changes the folder's gitId, and a
|
|
174
|
+
// discard removes the entry entirely — heartbeating the startup gitId would ping the WRONG repo forever
|
|
175
|
+
// (or a now-deleted one every 30s after a discard).
|
|
176
|
+
const heartbeatTimer = setInterval(async () => {
|
|
177
|
+
try {
|
|
178
|
+
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
179
|
+
if (!fresh || !fresh.gitId) return; // folder released — nothing to heartbeat
|
|
180
|
+
await agentApi.heartbeat(fresh.gitId);
|
|
181
|
+
} catch {
|
|
182
|
+
// Non-fatal — network may be temporarily unavailable
|
|
183
|
+
}
|
|
184
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
185
|
+
|
|
186
|
+
// Periodic backstop re-scan. fs.watch silently DROPS events (recursive watches especially, and on
|
|
187
|
+
// some editors/filesystems), so a real edit can be missed and never synced until the next daemon
|
|
188
|
+
// restart. Every RESCAN_INTERVAL_MS, re-hash the tree against the recorded hashes and sync anything
|
|
189
|
+
// that drifted — the reliable, content-checked form of "also pick up changes we didn't get a live
|
|
190
|
+
// event for". Re-reads fresh state, so a folder re-pointed by `niro takeover` or removed by `niro
|
|
191
|
+
// release` mid-session is handled (or skipped) rather than synced to a stale gitId.
|
|
192
|
+
const rescanTimer = setInterval(() => {
|
|
193
|
+
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
194
|
+
if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
|
|
195
|
+
deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true }).catch(err =>
|
|
196
|
+
console.error(color.red(` Backstop re-scan failed for ${targetDir}: ${err.message}`)));
|
|
197
|
+
}, RESCAN_INTERVAL_MS);
|
|
198
|
+
|
|
199
|
+
// Start file watcher
|
|
200
|
+
const debounceTimers = new Map();
|
|
201
|
+
|
|
202
|
+
const watcher = fs.watch(targetDir, { recursive: true }, (eventType, filename) => {
|
|
203
|
+
if (!filename) return;
|
|
204
|
+
const relativePath = filename.replace(/\\/g, "/");
|
|
205
|
+
if (shouldIgnore(relativePath)) return;
|
|
206
|
+
|
|
207
|
+
// Clear existing debounce for this path
|
|
208
|
+
if (debounceTimers.has(relativePath)) {
|
|
209
|
+
clearTimeout(debounceTimers.get(relativePath));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
debounceTimers.set(relativePath, setTimeout(() => {
|
|
213
|
+
debounceTimers.delete(relativePath);
|
|
214
|
+
// A rejection here would be an unhandled promise rejection = process death in
|
|
215
|
+
// modern Node. One file's failure must never take the daemon down.
|
|
216
|
+
handleFileEvent(gitId, targetDir, relativePath, state).catch(err =>
|
|
217
|
+
console.error(color.red(` Watch event failed for ${relativePath}: ${err.message}`)));
|
|
218
|
+
}, DEBOUNCE_MS));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// fs.watch emits 'error' events (EMFILE, watched dir removed, …); with no listener
|
|
222
|
+
// an EventEmitter 'error' THROWS and kills the process.
|
|
223
|
+
watcher.on("error", (err) =>
|
|
224
|
+
console.error(color.red(` Watcher error for ${targetDir}: ${err.message}`)));
|
|
225
|
+
|
|
226
|
+
console.log(` ${color.green("✓")} Watching for changes. Press Ctrl+C to stop.\n`);
|
|
227
|
+
|
|
228
|
+
// Graceful shutdown
|
|
229
|
+
process.on("SIGINT", () => {
|
|
230
|
+
clearInterval(heartbeatTimer);
|
|
231
|
+
clearInterval(rescanTimer);
|
|
232
|
+
watcher.close();
|
|
233
|
+
for (const t of debounceTimers.values()) clearTimeout(t);
|
|
234
|
+
console.log("\n Watch stopped.");
|
|
235
|
+
process.exit(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Keep alive
|
|
239
|
+
await new Promise(() => {});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
243
|
+
const inFlightKey = path.resolve(targetDir);
|
|
244
|
+
if (syncInFlight.has(inFlightKey)) {
|
|
245
|
+
// A sync (startup, live event, or backstop) is already running for this folder — skip to avoid the
|
|
246
|
+
// whole-map fileHashes clobber race; the in-progress run re-hashes the whole tree and covers this trigger.
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
syncInFlight.add(inFlightKey);
|
|
250
|
+
try {
|
|
251
|
+
const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
|
|
252
|
+
const storedHashes = state.fileHashes || {};
|
|
253
|
+
const currentHashes = {};
|
|
254
|
+
const changed = [];
|
|
255
|
+
const deleted = [];
|
|
256
|
+
|
|
257
|
+
// Walk all currently included files
|
|
258
|
+
const manifest = buildMinimalManifest(targetDir, state.includePatterns, state.excludePatterns);
|
|
259
|
+
|
|
260
|
+
for (const relativePath of manifest) {
|
|
261
|
+
const absPath = path.join(targetDir, relativePath);
|
|
262
|
+
const hash = watchState.hashFile(absPath);
|
|
263
|
+
if (!hash) continue;
|
|
264
|
+
currentHashes[relativePath] = hash;
|
|
265
|
+
if (storedHashes[relativePath] !== hash) {
|
|
266
|
+
changed.push(relativePath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Files that were in stored hashes but no longer exist
|
|
271
|
+
for (const rp of Object.keys(storedHashes)) {
|
|
272
|
+
if (!currentHashes[rp]) {
|
|
273
|
+
deleted.push(rp);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (changed.length === 0 && deleted.length === 0) {
|
|
278
|
+
if (!quiet) console.log(` No changes since last sync.`);
|
|
279
|
+
watchState.update(targetDir, { fileHashes: currentHashes });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A quiet backstop run only speaks up when it actually found something fs.watch missed.
|
|
284
|
+
console.log(` ${quiet ? color.yellow("↻ backstop: ") : ""}Delta: ${changed.length} changed, ${deleted.length} deleted — syncing...`);
|
|
285
|
+
|
|
286
|
+
// Honor .gitignore (+ fail-closed-for-config) on the changed set BEFORE upload,
|
|
287
|
+
// using the same helper as init/add-repo. A gitignored secret edited after
|
|
288
|
+
// onboarding (e.g. a raw .env) must never be re-uploaded here.
|
|
289
|
+
let changedToUpload = changed;
|
|
290
|
+
if (changed.length > 0) {
|
|
291
|
+
const drops = folderSync.gitignoreDrops(targetDir, changed);
|
|
292
|
+
if (drops.size > 0) {
|
|
293
|
+
changedToUpload = changed.filter(rp => !drops.has(rp));
|
|
294
|
+
// Don't persist hashes for dropped (gitignored / unverifiable) files — they
|
|
295
|
+
// were never uploaded, so leave them out of state so they're re-evaluated
|
|
296
|
+
// rather than recorded as synced.
|
|
297
|
+
for (const rp of drops) delete currentHashes[rp];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Use a sync session for the delta (atomic move on complete)
|
|
302
|
+
if (changedToUpload.length > 0) {
|
|
303
|
+
const sessionId = await agentApi.startSync(gitId);
|
|
304
|
+
const batches = chunk(changedToUpload, folderSync.BATCH_SIZE);
|
|
305
|
+
let uploaded = 0;
|
|
306
|
+
for (const batch of batches) {
|
|
307
|
+
const fileBatch = batch.map(rp => ({ relativePath: rp, absolutePath: path.join(targetDir, rp) }));
|
|
308
|
+
await agentApi.uploadFileBatch(gitId, sessionId, fileBatch);
|
|
309
|
+
uploaded += fileBatch.length;
|
|
310
|
+
process.stdout.write(`\r Uploading delta... ${uploaded}/${changedToUpload.length}`);
|
|
311
|
+
}
|
|
312
|
+
process.stdout.write("\n");
|
|
313
|
+
await agentApi.completeSync(gitId, sessionId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const rp of deleted) {
|
|
317
|
+
try {
|
|
318
|
+
await agentApi.deleteFile(gitId, rp);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
watchState.update(targetDir, { fileHashes: currentHashes, lastSyncedAt: new Date().toISOString() });
|
|
325
|
+
console.log(` ${color.green("✓")} Delta sync complete.`);
|
|
326
|
+
} finally {
|
|
327
|
+
syncInFlight.delete(inFlightKey);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
332
|
+
// Re-read this folder's current state on every event instead of trusting the value
|
|
333
|
+
// captured when the daemon started. A mid-session `niro takeover` re-points the folder
|
|
334
|
+
// at a NEW working-copy gitId (and `niro release` removes it); without this re-read the
|
|
335
|
+
// daemon kept uploading to the OLD gitId — every edit failed with "Repository not found"
|
|
336
|
+
// until the daemon was manually restarted. Reading fresh also picks up include/exclude
|
|
337
|
+
// pattern changes from `niro edit`.
|
|
338
|
+
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
339
|
+
if (!fresh || !fresh.gitId) {
|
|
340
|
+
// Folder was released / is no longer tracked for this backend — stop syncing it.
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
gitId = fresh.gitId;
|
|
344
|
+
state = fresh;
|
|
345
|
+
|
|
346
|
+
if (!matchesPatterns(relativePath, state.includePatterns, state.excludePatterns)) {
|
|
347
|
+
// New path — check if it's a directory we haven't seen before
|
|
348
|
+
const absPath = path.join(targetDir, relativePath);
|
|
349
|
+
let isDirectory = false;
|
|
350
|
+
try {
|
|
351
|
+
// existsSync/statSync race: the path can vanish between the two calls (editor temp
|
|
352
|
+
// files do this constantly) — a throw here must not escape the event handler.
|
|
353
|
+
isDirectory = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory();
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (isDirectory) {
|
|
358
|
+
const topDir = relativePath.split("/")[0];
|
|
359
|
+
if (topDir && !state.excludePatterns.some(p => p.startsWith(topDir))) {
|
|
360
|
+
if (prompt.isNonInteractive()) {
|
|
361
|
+
// A daemon has no TTY — never block on (or crash in) a prompt. Leave the
|
|
362
|
+
// directory unsynced and tell the user how to include it.
|
|
363
|
+
console.log(color.yellow(
|
|
364
|
+
` New directory '${relativePath}' detected — not synced. ` +
|
|
365
|
+
`Run \`niro watch\` interactively (or \`niro edit\`) to include it.`));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const add = await prompt.confirm(
|
|
369
|
+
`\n New directory '${relativePath}' detected. Add to sync?`,
|
|
370
|
+
false
|
|
371
|
+
);
|
|
372
|
+
if (add) {
|
|
373
|
+
const newPattern = relativePath + "/**";
|
|
374
|
+
state.includePatterns.push(newPattern);
|
|
375
|
+
watchState.update(targetDir, { includePatterns: state.includePatterns });
|
|
376
|
+
console.log(` Added include pattern: ${color.cyan(newPattern)}`);
|
|
377
|
+
} else {
|
|
378
|
+
state.excludePatterns.push(relativePath + "/**");
|
|
379
|
+
watchState.update(targetDir, { excludePatterns: state.excludePatterns });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
return; // not a source file or not matched
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const absPath = path.join(targetDir, relativePath);
|
|
388
|
+
|
|
389
|
+
if (!fs.existsSync(absPath)) {
|
|
390
|
+
// File deleted
|
|
391
|
+
try {
|
|
392
|
+
await agentApi.deleteFile(gitId, relativePath);
|
|
393
|
+
const hashes = { ...(watchState.get(targetDir) || {}).fileHashes };
|
|
394
|
+
delete hashes[relativePath];
|
|
395
|
+
watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
|
|
396
|
+
console.log(` ${color.yellow("−")} ${relativePath}`);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(color.red(` Failed to delete ${relativePath}: ${err.message}`));
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Only upload files in the accept set (source / structural / committed config).
|
|
404
|
+
// matchesPatterns can pass anything when the server-supplied includePatterns are
|
|
405
|
+
// empty; this keeps the per-file event from uploading stray files (e.g. a *.txt
|
|
406
|
+
// that could carry secrets), matching what the manifest walk would offer.
|
|
407
|
+
if (!folderSync.isUploadableName(relativePath)) return;
|
|
408
|
+
|
|
409
|
+
// File changed or created
|
|
410
|
+
const newHash = watchState.hashFile(absPath);
|
|
411
|
+
const storedHashes = (watchState.get(targetDir) || {}).fileHashes || {};
|
|
412
|
+
if (newHash && storedHashes[relativePath] === newHash) return; // no actual change
|
|
413
|
+
|
|
414
|
+
// Honor .gitignore (+ fail-closed-for-config) before uploading this one file,
|
|
415
|
+
// same policy as init/add-repo and the delta resume. A gitignored file (or, if
|
|
416
|
+
// git can't answer on a work tree, an unverifiable config/env file) is skipped
|
|
417
|
+
// so a secret is never leaked. We do NOT record its hash, so it is re-evaluated
|
|
418
|
+
// on the next event rather than silently treated as synced.
|
|
419
|
+
if (folderSync.gitignoreDrops(targetDir, [relativePath]).has(relativePath)) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
await agentApi.upsertFile(gitId, relativePath, absPath);
|
|
425
|
+
const hashes = { ...storedHashes, [relativePath]: newHash };
|
|
426
|
+
watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
|
|
427
|
+
console.log(` ${color.green("+")} ${relativePath}`);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function shouldIgnore(relativePath) {
|
|
434
|
+
const parts = relativePath.replace(/\\/g, "/").split("/");
|
|
435
|
+
return parts.some(p => IGNORED_DIRS.has(p) || p.startsWith("."));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function matchesPatterns(relativePath, includePatterns, excludePatterns) {
|
|
439
|
+
if (!includePatterns || includePatterns.length === 0) return true;
|
|
440
|
+
const matched = includePatterns.some(p => minimatch(relativePath, p));
|
|
441
|
+
if (!matched) return false;
|
|
442
|
+
if (!excludePatterns || excludePatterns.length === 0) return true;
|
|
443
|
+
return !excludePatterns.some(p => minimatch(relativePath, p));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Simple glob matching without a dependency: handles ** and * patterns. */
|
|
447
|
+
function minimatch(filePath, pattern) {
|
|
448
|
+
const regexStr = pattern
|
|
449
|
+
.replace(/\./g, "\\.")
|
|
450
|
+
.replace(/\*\*/g, "§§") // temp placeholder
|
|
451
|
+
.replace(/\*/g, "[^/]*")
|
|
452
|
+
.replace(/§§/g, ".*");
|
|
453
|
+
return new RegExp("^" + regexStr + "$").test(filePath);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Walk the directory respecting stored patterns to find all currently included files. */
|
|
457
|
+
function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
458
|
+
const results = [];
|
|
459
|
+
|
|
460
|
+
function walk(dir) {
|
|
461
|
+
let entries;
|
|
462
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
const fullPath = path.join(dir, entry.name);
|
|
465
|
+
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
466
|
+
if (entry.isDirectory()) {
|
|
467
|
+
if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
468
|
+
walk(fullPath);
|
|
469
|
+
}
|
|
470
|
+
} else if (entry.isFile()) {
|
|
471
|
+
// Same accept SET as the init/add-repo walk (folderSync.walkDir): source +
|
|
472
|
+
// structural + committed config files. Without the config/structural files
|
|
473
|
+
// here, a behavioural default edited AFTER onboarding (application.yml,
|
|
474
|
+
// Dockerfile, ...) would never re-sync. These are gitignore-filtered (and
|
|
475
|
+
// fail-closed) before upload in deltaSyncOnResume, same as init.
|
|
476
|
+
const lower = entry.name.toLowerCase();
|
|
477
|
+
const ext = path.extname(lower);
|
|
478
|
+
if (
|
|
479
|
+
SOURCE_EXTENSIONS.has(ext) ||
|
|
480
|
+
STRUCTURAL_EXTENSIONS.has(ext) ||
|
|
481
|
+
STRUCTURAL_FILENAMES.has(lower) ||
|
|
482
|
+
isConfigName(lower)
|
|
483
|
+
) {
|
|
484
|
+
if (!includePatterns || includePatterns.length === 0 ||
|
|
485
|
+
matchesPatterns(relativePath, includePatterns, excludePatterns)) {
|
|
486
|
+
results.push(relativePath);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
walk(rootDir);
|
|
493
|
+
return results;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// handleFileEvent + deltaSyncOnResume are exported for unit testing the
|
|
497
|
+
// gitignore/fail-closed guards without a live server or fs.watch loop.
|
|
498
|
+
module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const api = require("../lib/api");
|
|
2
|
+
const credentials = require("../lib/credentials");
|
|
3
|
+
const color = require("../lib/color");
|
|
4
|
+
|
|
5
|
+
async function whoami(opts = {}) {
|
|
6
|
+
if (opts.json) return whoamiJson();
|
|
7
|
+
|
|
8
|
+
const creds = credentials.requireCreds();
|
|
9
|
+
try {
|
|
10
|
+
const data = await api.get("/api/users/details");
|
|
11
|
+
const email = data.email || creds.email || "(unknown)";
|
|
12
|
+
const userId = data.id || creds.userId || "(unknown)";
|
|
13
|
+
const accountId = data.account_id || data.accountId || "(unknown)";
|
|
14
|
+
const label = (s) => color.dim(s.padEnd(9));
|
|
15
|
+
console.log(`${label("Email:")} ${color.bold(email)}`);
|
|
16
|
+
console.log(`${label("User ID:")} ${userId}`);
|
|
17
|
+
console.log(`${label("Account:")} ${accountId}`);
|
|
18
|
+
console.log(`${label("API URL:")} ${color.cyan(creds.apiUrl)}`);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err.status === 401) {
|
|
21
|
+
console.error(color.red("Credentials rejected by server (401).") + ` Run ${color.cyan("niro login")} to refresh.`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Machine-readable identity for agents.
|
|
31
|
+
* - Unauthed: short-circuit BEFORE requireCreds's throwing path so we can emit
|
|
32
|
+
* {authed:false} and exit 0, letting an agent branch instead of crash.
|
|
33
|
+
* - Authed: emit the identity (server-confirmed where possible) and exit 0.
|
|
34
|
+
* Never emits tokens or api keys.
|
|
35
|
+
*/
|
|
36
|
+
async function whoamiJson() {
|
|
37
|
+
const creds = credentials.load();
|
|
38
|
+
const durable = creds && (creds.cliToken || creds.niroApiKey);
|
|
39
|
+
if (!creds || (!creds.token && !durable)) {
|
|
40
|
+
console.log(JSON.stringify({ authed: false }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let email = creds.email || null;
|
|
45
|
+
let userId = creds.userId || null;
|
|
46
|
+
let accountId = null;
|
|
47
|
+
try {
|
|
48
|
+
const data = await api.get("/api/users/details");
|
|
49
|
+
email = data.email || email;
|
|
50
|
+
userId = data.id || userId;
|
|
51
|
+
accountId = data.account_id || data.accountId || null;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// A 401 means the server reached us and REJECTED the credential (revoked/expired/
|
|
54
|
+
// locked out) — report authed:false even if a durable token is stored locally. A
|
|
55
|
+
// network error (no err.status) just means offline: fall back to local creds below
|
|
56
|
+
// and report authed:true so the agent can still proceed.
|
|
57
|
+
if (err && err.status === 401) {
|
|
58
|
+
console.log(JSON.stringify({ authed: false }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
email,
|
|
66
|
+
user_id: userId,
|
|
67
|
+
account_id: accountId,
|
|
68
|
+
api_url: creds.apiUrl || api.DEFAULT_API_URL,
|
|
69
|
+
authed: true,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { whoami };
|