@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,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 };