@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,503 @@
1
+ /**
2
+ * Shared folder-upload helpers for the AGENT_UPLOAD onboarding/sync path.
3
+ *
4
+ * This is the single source of truth for:
5
+ * - the source-file walk (SOURCE_EXTENSIONS + IGNORED_DIRS) used to build a
6
+ * manifest of files to offer the server's /filter endpoint;
7
+ * - the concurrent, resilient initial uploader (doInitialSync).
8
+ *
9
+ * `niro init`, `niro add repo`, and `niro watch` all import from here so there
10
+ * is exactly one walk definition and one uploader. File content is never logged.
11
+ *
12
+ * .gitignore IS honored: after the walk, when the folder is a git work tree, the
13
+ * candidate paths are batched through `git check-ignore --stdin` and any ignored
14
+ * path is dropped. This keeps gitignored secret files (raw .env, .env.local,
15
+ * .env.production) out of the upload and matches REMOTE_GIT, which never sees
16
+ * gitignored files. (This intentionally REVERSES the ADR-011 Phase-1 "do NOT
17
+ * honor .gitignore" decision — for secret safety.) Non-git folders and any git
18
+ * failure degrade gracefully to NO filtering. The IGNORED_DIRS set stays as a
19
+ * baseline so the walk never descends into node_modules/.git/etc.
20
+ *
21
+ * Tunables (env overrides, all optional):
22
+ * NIRO_UPLOAD_CONCURRENCY max batches uploaded in parallel (default 4)
23
+ * NIRO_UPLOAD_TIMEOUT_MS per-batch upload timeout (default 60000; lives in agentApi)
24
+ * NIRO_UPLOAD_MAX_TOTAL_BYTES aggregate-guard byte threshold (default 1 GiB)
25
+ * NIRO_UPLOAD_MAX_FILES aggregate-guard file-count threshold (default 50000)
26
+ * NIRO_UPLOAD_BACKOFF_BASE_MS retry backoff base (default 500 -> 500/1000/2000)
27
+ */
28
+ const fs = require("fs");
29
+ const path = require("path");
30
+ const git = require("./git");
31
+ const agentApi = require("./agentApi");
32
+ const watchState = require("./watchState");
33
+ const prompt = require("./prompt");
34
+ const color = require("./color");
35
+
36
+ const BATCH_SIZE = 50;
37
+ const SOURCE_EXTENSIONS = new Set([
38
+ ".java", ".py", ".go", ".js", ".ts", ".tsx", ".jsx",
39
+ ".kt", ".scala", ".rs", ".rb", ".php", ".cs", ".swift",
40
+ ".c", ".cpp", ".h", ".hpp", ".dart",
41
+ ]);
42
+
43
+ // Build/config files the server's project-structure resolver needs to detect
44
+ // projects and resolve source roots, aliases and exports (package.json, pom.xml,
45
+ // go.mod, ...). Without these the server finds zero project indicators and falls
46
+ // back to a degraded bare-root index. Source files alone are NOT enough.
47
+ //
48
+ // MUST stay byte-identical to ai-orchestrator
49
+ // SourceFileFilterService.STRUCTURAL_FILENAMES / STRUCTURAL_EXTENSIONS — derived
50
+ // from the ai-orchestrator ConfigFileParser SUPPORTED_FILES sets + the route
51
+ // detectors. The server's /filter is the authoritative gate; the CLI only OFFERS
52
+ // these, so it must offer a superset or the server never sees the file. Names are
53
+ // stored lowercased because we match against entry.name.toLowerCase() (case-
54
+ // insensitive + symmetric with the server, which also lowercases the filename).
55
+ // Lockfiles (package-lock.json, go.sum, Cargo.lock, Gemfile.lock) are deliberately
56
+ // excluded — no parser reads them. Env/property files (.env, application.yml) are
57
+ // deliberately excluded — they routinely hold secrets.
58
+ const STRUCTURAL_FILENAMES = new Set([
59
+ "package.json", "tsconfig.json", "jsconfig.json", "pom.xml",
60
+ "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts",
61
+ "go.mod", "go.work", "setup.py", "pyproject.toml", "requirements.txt",
62
+ "pipfile", "cargo.toml", "composer.json", "gemfile", "project.clj",
63
+ "deps.edn", "package.swift", "build.sbt", "build.properties", "plugins.sbt",
64
+ "stack.yaml", "package.yaml", "setup.hs", "rebar.config", "erlang.mk",
65
+ "rebar.config.script", "mix.exs", "cmakelists.txt", "cmakecache.txt",
66
+ "makefile", "gnumakefile", "pubspec.yaml", "pubspec.yml", "dune-project",
67
+ "shard.yml", "project.toml", "manifest.toml", "description", "namespace",
68
+ "makefile.pl", "build.pl", "cpanfile", "build.zig", "build.zig.zon",
69
+ "project.json", "directory.build.props", "next.config.js", "next.config.mjs",
70
+ "next.config.ts", "vite.config.ts", "vite.config.js", "setupproxy.js",
71
+ "setupproxy.ts",
72
+ ]);
73
+ const STRUCTURAL_EXTENSIONS = new Set([
74
+ ".csproj", ".fsproj", ".sln", ".gemspec", ".rockspec", ".nimble", ".opam",
75
+ ]);
76
+
77
+ // COMMITTED config/property + .env TEMPLATE files. These are version-controlled
78
+ // (REMOTE_GIT already reads them) and hold the env-var DEFAULT values that
79
+ // PropertyFileService/EnvVarScanService read on the server. Without them the
80
+ // discovered env vars come back with empty defaults. SECRET-bearing files
81
+ // (raw .env, .env.local, .env.production, arbitrary *.properties) are NOT here
82
+ // and never match isConfigName(); gitignored instances of even these committed
83
+ // names are dropped earlier by the .gitignore honor pass (filterIgnored).
84
+ //
85
+ // Names are lowercased (we match entry.name.toLowerCase()). The Spring
86
+ // profile family application-<profile>.{properties,yml,yaml} is matched by the
87
+ // wildcard rule in isConfigName(), not enumerated. Dockerfile / docker-compose.*
88
+ // are scanned for ARG/ENV defaults (EnvVarScanService.INFRA_FILENAMES).
89
+ //
90
+ // MUST stay byte-identical (lists + isConfigName logic) to ai-orchestrator
91
+ // SourceFileFilterService.CONFIG_FILENAMES / isConfigName.
92
+ const CONFIG_FILENAMES = new Set([
93
+ "application.properties", "application.yml", "application.yaml",
94
+ "config.properties", "config.yml", "config.yaml",
95
+ "settings.properties", "settings.yml", "settings.yaml",
96
+ "config.json", "settings.json",
97
+ ".env.example", ".env.sample", ".env.template", ".env.dist",
98
+ "application.properties.example", "application.properties.sample",
99
+ "application.yml.example", "application.yml.sample",
100
+ "application.yaml.example", "application.yaml.sample",
101
+ "dockerfile", "docker-compose.yml", "docker-compose.yaml",
102
+ ]);
103
+
104
+ const CONFIG_WILDCARD_SUFFIXES = [".properties", ".yml", ".yaml"];
105
+
106
+ // True for committed config/property/template files that carry env-var DEFAULTS,
107
+ // false for secret-bearing files (bare .env, .env.local, .env.production, and
108
+ // arbitrary *.properties). `lower` is the already-lowercased filename.
109
+ function isConfigName(lower) {
110
+ if (CONFIG_FILENAMES.has(lower)) return true;
111
+ // Spring profile files: application-<profile>.{properties,yml,yaml}, profile
112
+ // non-empty (reject "application-.properties").
113
+ const prefix = "application-";
114
+ if (lower.startsWith(prefix)) {
115
+ for (const suffix of CONFIG_WILDCARD_SUFFIXES) {
116
+ if (lower.endsWith(suffix) && lower.length > prefix.length + suffix.length) {
117
+ return true;
118
+ }
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ const IGNORED_DIRS = new Set([
125
+ "node_modules", ".git", "target", "build", "__pycache__",
126
+ "dist", ".venv", "vendor", ".idea", ".gradle", "out",
127
+ ]);
128
+
129
+ // Server-side per-file cap (256 KiB). Oversize files are skipped server-side at
130
+ // /filter; we surface them as a warning so missing source is never silent.
131
+ const PER_FILE_CAP_BYTES = 262144;
132
+
133
+ const DEFAULT_CONCURRENCY = 4;
134
+ const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GiB
135
+ const DEFAULT_MAX_FILES = 50000;
136
+ const MAX_ATTEMPTS = 3;
137
+
138
+ function positiveIntEnv(name, fallback) {
139
+ const v = Number.parseInt(process.env[name], 10);
140
+ return Number.isFinite(v) && v > 0 ? v : fallback;
141
+ }
142
+
143
+ function uploadConcurrency() {
144
+ return positiveIntEnv("NIRO_UPLOAD_CONCURRENCY", DEFAULT_CONCURRENCY);
145
+ }
146
+ function maxTotalBytes() {
147
+ return positiveIntEnv("NIRO_UPLOAD_MAX_TOTAL_BYTES", DEFAULT_MAX_TOTAL_BYTES);
148
+ }
149
+ function maxFiles() {
150
+ return positiveIntEnv("NIRO_UPLOAD_MAX_FILES", DEFAULT_MAX_FILES);
151
+ }
152
+ function backoffMs(attempt) {
153
+ // base may legitimately be 0 in tests (instant retry), so accept >= 0.
154
+ const raw = Number.parseInt(process.env.NIRO_UPLOAD_BACKOFF_BASE_MS, 10);
155
+ const base = Number.isFinite(raw) && raw >= 0 ? raw : 500;
156
+ return base * Math.pow(2, attempt - 1); // 500, 1000, 2000
157
+ }
158
+
159
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
160
+
161
+ /**
162
+ * Walk a directory and build a manifest of source/structural/config files,
163
+ * then honor VCS exclusions: when rootDir lives in a git work tree, drop any
164
+ * candidate that git ignores. Pass { honorGitignore: false } to skip the git
165
+ * pass (used by tests that assert the raw walk).
166
+ */
167
+ function buildManifest(rootDir, opts = {}) {
168
+ const manifest = [];
169
+ walkDir(rootDir, rootDir, manifest);
170
+ if (opts.honorGitignore === false) return manifest;
171
+ return filterIgnored(rootDir, manifest);
172
+ }
173
+
174
+ // A manifest entry whose basename could carry secrets: any committed config name
175
+ // (isConfigName), any .env* file, or any *.properties/*.yml/*.yaml. Used ONLY for
176
+ // the fail-CLOSED path: when .gitignore cannot be honored on a git work tree we
177
+ // drop these (we cannot prove they are not gitignored) but keep ordinary source.
178
+ // `relPath` is a manifest/relative path; we match on its lowercased basename.
179
+ function isSecretRiskCandidate(relPath) {
180
+ const lower = path.basename(relPath).toLowerCase();
181
+ if (isConfigName(lower)) return true;
182
+ if (lower.startsWith(".env")) return true; // .env, .env.local, .env.production, ...
183
+ for (const suffix of CONFIG_WILDCARD_SUFFIXES) {
184
+ if (lower.endsWith(suffix)) return true; // *.properties / *.yml / *.yaml
185
+ }
186
+ return false;
187
+ }
188
+
189
+ // The single accept predicate: is this filename eligible for upload at all?
190
+ // SOURCE code, structural build files, or a committed config file. The manifest
191
+ // walk and the watch per-file event share it so they never diverge on what may
192
+ // be uploaded (a changed file outside this set is ignored, even if the server's
193
+ // include patterns are empty). `name` is a basename or relative path.
194
+ function isUploadableName(name) {
195
+ const lower = path.basename(name).toLowerCase();
196
+ const ext = path.extname(lower);
197
+ return (
198
+ SOURCE_EXTENSIONS.has(ext) ||
199
+ STRUCTURAL_EXTENSIONS.has(ext) ||
200
+ STRUCTURAL_FILENAMES.has(lower) ||
201
+ isConfigName(lower)
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Given paths relative to rootDir, return the Set of paths that must be DROPPED
207
+ * before upload to honor .gitignore. This is the SINGLE source of truth shared by
208
+ * the init/add-repo manifest filter AND the watch daemon (delta + per-file), so
209
+ * the secret-safety rules are identical on every upload path.
210
+ *
211
+ * rootDir may be a SUBDIRECTORY of the work tree, so paths are re-expressed
212
+ * relative to the repo root before the single `git check-ignore --stdin` call.
213
+ *
214
+ * - Non-git folder (no work tree) -> drop nothing (nothing to honor).
215
+ * - git answered -> drop exactly the gitignored paths.
216
+ * - git could NOT answer on a work tree-> FAIL LOUD + CLOSED-for-config: print a
217
+ * one-line WARNING and drop every secret-risk candidate (config/env/property)
218
+ * so a possibly-gitignored secret file is never uploaded unverified. Ordinary
219
+ * SOURCE files are kept (not the secret risk). File names are never logged.
220
+ */
221
+ function gitignoreDrops(rootDir, relPaths) {
222
+ const drop = new Set();
223
+ if (!relPaths || relPaths.length === 0) return drop;
224
+
225
+ let repoRoot = null;
226
+ try {
227
+ repoRoot = git.inspect(rootDir).repoRoot;
228
+ } catch {
229
+ repoRoot = null;
230
+ }
231
+ if (!repoRoot) return drop; // not a work tree -> nothing to honor
232
+
233
+ // git check-ignore matches against repo-root-relative POSIX paths.
234
+ const repoRelByPath = new Map();
235
+ const repoRelPaths = [];
236
+ for (const rel of relPaths) {
237
+ const abs = path.join(rootDir, rel);
238
+ const repoRel = path.relative(repoRoot, abs).split(path.sep).join("/");
239
+ repoRelByPath.set(rel, repoRel);
240
+ repoRelPaths.push(repoRel);
241
+ }
242
+
243
+ const { ok, ignored } = git.checkIgnored(repoRoot, repoRelPaths);
244
+ if (!ok) {
245
+ console.log(color.yellow(
246
+ " ⚠ WARNING: could not run `git check-ignore`, so .gitignore can't be " +
247
+ "honored — dropping config/env files from this upload to avoid leaking " +
248
+ "secrets (source files are still uploaded)."
249
+ ));
250
+ for (const rel of relPaths) {
251
+ if (isSecretRiskCandidate(rel)) drop.add(rel);
252
+ }
253
+ return drop;
254
+ }
255
+ if (ignored.size === 0) return drop;
256
+ for (const rel of relPaths) {
257
+ if (ignored.has(repoRelByPath.get(rel))) drop.add(rel);
258
+ }
259
+ return drop;
260
+ }
261
+
262
+ /**
263
+ * Drop manifest entries that git ignores (or, on a fail-closed git error, the
264
+ * secret-risk config/env entries). Thin wrapper over gitignoreDrops so the
265
+ * manifest walk and the watch daemon share one ignore policy.
266
+ */
267
+ function filterIgnored(rootDir, manifest) {
268
+ if (!manifest || manifest.length === 0) return manifest;
269
+ const drops = gitignoreDrops(rootDir, manifest.map((m) => m.path));
270
+ if (drops.size === 0) return manifest;
271
+ return manifest.filter((m) => !drops.has(m.path));
272
+ }
273
+
274
+ function walkDir(rootDir, currentDir, manifest) {
275
+ let entries;
276
+ try {
277
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
278
+ } catch {
279
+ return;
280
+ }
281
+ for (const entry of entries) {
282
+ if (entry.isDirectory()) {
283
+ if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
284
+ walkDir(rootDir, path.join(currentDir, entry.name), manifest);
285
+ }
286
+ } else if (entry.isFile()) {
287
+ if (isUploadableName(entry.name)) {
288
+ const absPath = path.join(currentDir, entry.name);
289
+ let sizeBytes = 0;
290
+ try {
291
+ sizeBytes = fs.statSync(absPath).size;
292
+ } catch {
293
+ // skip
294
+ }
295
+ manifest.push({
296
+ path: path.relative(rootDir, absPath).replace(/\\/g, "/"),
297
+ size_bytes: sizeBytes,
298
+ mime_type: "text/plain",
299
+ });
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ function chunk(arr, size) {
306
+ const result = [];
307
+ for (let i = 0; i < arr.length; i += size) {
308
+ result.push(arr.slice(i, i + size));
309
+ }
310
+ return result;
311
+ }
312
+
313
+ function formatBytes(n) {
314
+ if (n >= 1024 * 1024 * 1024) return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
315
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
316
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KiB`;
317
+ return `${n} B`;
318
+ }
319
+
320
+ /**
321
+ * Guard against an accidental enormous upload (a misconfigured walk, a giant
322
+ * monorepo, a binary directory mislabelled as source). On an interactive TTY we
323
+ * ask the user to confirm; non-interactively we proceed but print a clear
324
+ * WARNING (never silently). Throws "Upload cancelled by user." if declined.
325
+ */
326
+ async function aggregateGuard(targetDir, includedPaths, sizes) {
327
+ const count = includedPaths.length;
328
+ const overFiles = count > maxFiles();
329
+
330
+ // Sum sizes to test the byte threshold, but bound the work: prefer sizes the
331
+ // manifest already computed (no re-stat), and stop summing as soon as we pass
332
+ // the limit. When we are already over the file-count limit we still need a
333
+ // byte figure for the message, so keep the early-exit either way.
334
+ const limitBytes = maxTotalBytes();
335
+ let totalBytes = 0;
336
+ let overBytes = false;
337
+ for (const relativePath of includedPaths) {
338
+ let sz = sizes && typeof sizes[relativePath] === "number" ? sizes[relativePath] : null;
339
+ if (sz == null) {
340
+ try {
341
+ sz = fs.statSync(path.join(targetDir, relativePath)).size;
342
+ } catch {
343
+ sz = 0; // unreadable — ignore for the estimate
344
+ }
345
+ }
346
+ totalBytes += sz;
347
+ if (totalBytes > limitBytes) {
348
+ overBytes = true;
349
+ break; // no need to keep summing once over
350
+ }
351
+ }
352
+ if (!overBytes && !overFiles) return;
353
+
354
+ const reasons = [];
355
+ if (overBytes) reasons.push(`> ${formatBytes(limitBytes)}`);
356
+ if (overFiles) reasons.push(`${count} files (limit ${maxFiles()})`);
357
+ const msg = `Large upload — ${reasons.join("; ")}.`;
358
+
359
+ if (prompt.isNonInteractive()) {
360
+ console.log(color.yellow(` ⚠ WARNING: ${msg} Proceeding (non-interactive).`));
361
+ return;
362
+ }
363
+ const proceed = await prompt.confirm(` ⚠ ${msg} Continue?`, false);
364
+ if (!proceed) {
365
+ throw new Error("Upload cancelled by user.");
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Upload a single batch with retry + exponential backoff. Each attempt is
371
+ * subject to agentApi.uploadFileBatch's own per-call timeout (a timeout aborts
372
+ * the attempt and surfaces as a thrown error, which counts as a failed attempt
373
+ * here). Throws the last error after MAX_ATTEMPTS.
374
+ */
375
+ async function uploadBatchWithRetry(gitId, sessionId, fileBatch) {
376
+ let lastErr;
377
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
378
+ try {
379
+ return await agentApi.uploadFileBatch(gitId, sessionId, fileBatch);
380
+ } catch (err) {
381
+ lastErr = err;
382
+ if (attempt < MAX_ATTEMPTS) {
383
+ await sleep(backoffMs(attempt));
384
+ }
385
+ }
386
+ }
387
+ throw lastErr;
388
+ }
389
+
390
+ /**
391
+ * Initial sync: upload all included files to one server sync session using a
392
+ * bounded concurrency pool. Concurrent batches to the SAME session are safe —
393
+ * each batch writes independent files into a per-session staging dir, and
394
+ * completeSync atomically moves staging -> projectDir.
395
+ *
396
+ * Resilience: each batch is retried up to MAX_ATTEMPTS with exponential
397
+ * backoff and a per-attempt timeout. If any batch exhausts its retries, we do
398
+ * NOT call completeSync (the half-uploaded session is discarded server-side) and
399
+ * we throw loudly. There is no cross-process resume: re-running starts a fresh
400
+ * upload from scratch.
401
+ *
402
+ * On success, fileHashes + lastSyncedAt are recorded via watchState.update.
403
+ */
404
+ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
405
+ if (!includedPaths || includedPaths.length === 0) {
406
+ console.log(` ${color.yellow("No files to sync.")} Check your include/exclude patterns.`);
407
+ return;
408
+ }
409
+
410
+ await aggregateGuard(targetDir, includedPaths, sizes);
411
+
412
+ const total = includedPaths.length;
413
+ console.log(`\n Starting initial sync (${total} files)...`);
414
+ const sessionId = await agentApi.startSync(gitId);
415
+
416
+ const batches = chunk(includedPaths, BATCH_SIZE);
417
+ let uploaded = 0; // single-threaded event loop -> safe counter across batches
418
+ let dropped = 0; // files that vanished between manifest build and upload
419
+ let firstError = null;
420
+ let nextBatch = 0;
421
+
422
+ async function worker() {
423
+ while (true) {
424
+ if (firstError) return;
425
+ const idx = nextBatch++;
426
+ if (idx >= batches.length) return;
427
+ const fileBatch = batches[idx]
428
+ .map((relativePath) => ({
429
+ relativePath,
430
+ absolutePath: path.join(targetDir, relativePath),
431
+ }))
432
+ .filter((f) => fs.existsSync(f.absolutePath));
433
+ dropped += batches[idx].length - fileBatch.length;
434
+
435
+ try {
436
+ if (fileBatch.length > 0) await uploadBatchWithRetry(gitId, sessionId, fileBatch);
437
+ } catch (err) {
438
+ if (!firstError) firstError = err;
439
+ return;
440
+ }
441
+ uploaded += fileBatch.length;
442
+ // Denominator excludes files that disappeared, so progress can still reach 100%.
443
+ const pct = Math.round((uploaded / Math.max(1, total - dropped)) * 100);
444
+ process.stdout.write(`\r Uploading... ${pct}% (${uploaded}/${total - dropped})`);
445
+ }
446
+ }
447
+
448
+ const poolSize = Math.min(uploadConcurrency(), batches.length);
449
+ const workers = [];
450
+ for (let i = 0; i < poolSize; i++) workers.push(worker());
451
+ await Promise.all(workers);
452
+ process.stdout.write("\n");
453
+
454
+ // Never drop files silently — surface the count (no names/content).
455
+ if (!firstError && dropped > 0) {
456
+ console.log(color.yellow(` ⚠ ${dropped} file(s) disappeared during upload and were skipped.`));
457
+ }
458
+
459
+ if (firstError) {
460
+ // Do NOT completeSync — leave the partial session for the server to discard.
461
+ throw new Error(
462
+ `Upload did not complete: ${firstError.message}. ` +
463
+ "Re-run `niro init` to restart it — the upload starts from scratch " +
464
+ "(there is no cross-process resume)."
465
+ );
466
+ }
467
+
468
+ await agentApi.completeSync(gitId, sessionId);
469
+
470
+ // Record hashes so the watch daemon can delta-sync from here.
471
+ const hashes = {};
472
+ for (const relativePath of includedPaths) {
473
+ const h = watchState.hashFile(path.join(targetDir, relativePath));
474
+ if (h) hashes[relativePath] = h;
475
+ }
476
+ watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
477
+
478
+ console.log(` ${color.green("✓")} Initial sync complete.`);
479
+ // Return the computed hashes so a caller that registers its watchState entry AFTER the upload (takeover)
480
+ // can seed fileHashes directly — watchState.update above no-ops when no entry exists yet, so without this
481
+ // the daemon would re-upload the whole tree on its first pass.
482
+ return hashes;
483
+ }
484
+
485
+ module.exports = {
486
+ buildManifest,
487
+ walkDir,
488
+ filterIgnored,
489
+ gitignoreDrops,
490
+ isSecretRiskCandidate,
491
+ isUploadableName,
492
+ isConfigName,
493
+ chunk,
494
+ doInitialSync,
495
+ aggregateGuard,
496
+ SOURCE_EXTENSIONS,
497
+ STRUCTURAL_FILENAMES,
498
+ STRUCTURAL_EXTENSIONS,
499
+ CONFIG_FILENAMES,
500
+ IGNORED_DIRS,
501
+ BATCH_SIZE,
502
+ PER_FILE_CAP_BYTES,
503
+ };
package/src/lib/git.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Best-effort read of local .git metadata — origin URL and current branch.
3
+ * Returns null for either field when not available (no git, detached HEAD, etc.).
4
+ * Walks up from the given path to find a .git directory.
5
+ */
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { execSync } = require("child_process");
9
+
10
+ function findGitDir(startDir) {
11
+ let dir = path.resolve(startDir);
12
+ const root = path.parse(dir).root;
13
+ while (true) {
14
+ const candidate = path.join(dir, ".git");
15
+ try {
16
+ if (fs.existsSync(candidate)) return { gitDir: candidate, repoRoot: dir };
17
+ } catch {
18
+ // ignore
19
+ }
20
+ if (dir === root) return null;
21
+ dir = path.dirname(dir);
22
+ }
23
+ }
24
+
25
+ function readOriginUrl(gitDir) {
26
+ try {
27
+ const configPath = path.join(gitDir, "config");
28
+ const raw = fs.readFileSync(configPath, "utf8");
29
+ // Match [remote "origin"] ... url = ...
30
+ const match = raw.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
31
+ return match ? match[1].trim() : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function readCurrentBranch(gitDir) {
38
+ try {
39
+ const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
40
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
41
+ return m ? m[1] : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run a git command in `cwd` and return trimmed stdout, or null on ANY failure
49
+ * (git absent, not a repo, no `origin` remote, detached HEAD, timeout). Never
50
+ * throws; stderr is discarded. Bounded so a wedged git can't hang onboarding.
51
+ */
52
+ function gitOut(cmd, cwd) {
53
+ try {
54
+ const out = execSync(cmd, {
55
+ cwd,
56
+ encoding: "utf8",
57
+ timeout: 3000,
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ }).trim();
60
+ return out || null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Strip embedded credentials from an https remote (https://user:token@host/... -> https://host/...)
68
+ * and token query params (?token=..., ?access_token=...) before it is stored, logged, or sent. Leaves
69
+ * scp/ssh form (git@host:org/repo) untouched — the "git@" there is the ssh user, not a secret (the
70
+ * regex requires "//" before the "@"). Mirrors the @niroai/mcp-server bridge's redactRemoteCreds
71
+ * (lib/redact.js) so onboarding never persists a token-bearing repository_url, and keeps resolution
72
+ * consistent (the server matches on the redacted host/org/repo form the bridge also sends).
73
+ */
74
+ function redactRemoteCreds(url) {
75
+ if (typeof url !== "string") return url;
76
+ return url
77
+ .replace(/\/\/[^@/]*@/, "//")
78
+ .replace(/([?&](?:access_token|token|api_key|private[-_]?token)=)[^&\s]+/gi, "$1***");
79
+ }
80
+
81
+ function inspect(startDir) {
82
+ const found = findGitDir(startDir);
83
+ if (!found) return { repoRoot: null, originUrl: null, branch: null };
84
+ let originUrl = redactRemoteCreds(readOriginUrl(found.gitDir));
85
+ let branch = readCurrentBranch(found.gitDir);
86
+ // The reads above open `.git/config` and `.git/HEAD` as plain files. That works when
87
+ // `.git` is a DIRECTORY, but a git WORKTREE or SUBMODULE checkout has `.git` as a FILE
88
+ // (`gitdir: <path>`), so those reads hit ENOTDIR and return null — and onboarding would
89
+ // then store a synthetic `local://<name>` URL that the MCP git-remote resolver can never
90
+ // match (the exact cause of the "no project matched" onboarding→MCP break). Fall back to
91
+ // the git binary (the same detection the MCP bridge already uses in src/mcp/server.js) so
92
+ // a worktree checkout still records its real origin/branch. Best-effort: if git is
93
+ // unavailable the calls return null and behaviour is identical to before.
94
+ if (originUrl === null) originUrl = redactRemoteCreds(gitOut("git remote get-url origin", found.repoRoot));
95
+ if (branch === null) {
96
+ const b = gitOut("git rev-parse --abbrev-ref HEAD", found.repoRoot);
97
+ branch = b && b !== "HEAD" ? b : null; // "HEAD" = detached; keep null like the file read
98
+ }
99
+ return { repoRoot: found.repoRoot, originUrl, branch };
100
+ }
101
+
102
+ /**
103
+ * Run ONE `git check-ignore --stdin` in `repoRoot` and report BOTH the matched
104
+ * paths AND whether the check itself succeeded. Paths must be POSIX-relative to
105
+ * repoRoot. Returns `{ ok, ignored }`:
106
+ * - ok=true → git ran and answered authoritatively (exit 0 with matches, or
107
+ * exit 1 = no path matched). `ignored` is the matched subset.
108
+ * - ok=false → git could NOT honor .gitignore (ENOENT / exit 128 / timeout /
109
+ * any other error). `ignored` is empty. Callers decide how to fail
110
+ * (we fail CLOSED for config files — see folderSync.gitignoreDrops).
111
+ *
112
+ * Never throws. File names are passed only via stdin and are NEVER logged;
113
+ * stderr is discarded.
114
+ *
115
+ * git check-ignore exit codes: 0 = at least one path matched (printed to
116
+ * stdout), 1 = no path matched (execSync throws; stdout empty), 128 = fatal
117
+ * (not a work tree / no git).
118
+ */
119
+ function checkIgnored(repoRoot, relativePaths) {
120
+ const ignored = new Set();
121
+ if (!repoRoot || !Array.isArray(relativePaths) || relativePaths.length === 0) {
122
+ // Nothing to check — a successful, empty result.
123
+ return { ok: true, ignored };
124
+ }
125
+ const collect = (out) => {
126
+ if (typeof out !== "string") return;
127
+ for (const line of out.split("\n")) {
128
+ const p = line.trim();
129
+ if (p) ignored.add(p);
130
+ }
131
+ };
132
+ try {
133
+ const out = execSync("git check-ignore --stdin", {
134
+ cwd: repoRoot,
135
+ input: relativePaths.join("\n"),
136
+ encoding: "utf8",
137
+ timeout: 5000,
138
+ stdio: ["pipe", "pipe", "ignore"],
139
+ maxBuffer: 64 * 1024 * 1024,
140
+ });
141
+ collect(out);
142
+ return { ok: true, ignored };
143
+ } catch (err) {
144
+ // Exit 1 (no matches) is normal and execSync throws on it — the matched
145
+ // paths (if any) live on err.stdout. That is still an authoritative answer.
146
+ if (err && err.status === 1) {
147
+ collect(err.stdout);
148
+ return { ok: true, ignored };
149
+ }
150
+ // Any other status (128 / ENOENT / timeout) means git could NOT answer:
151
+ // report ok=false so the caller can fail closed for secret-bearing files.
152
+ return { ok: false, ignored: new Set() };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Backward-compatible thin wrapper: return just the matched subset as a Set.
158
+ * Treats a failed check (git unavailable) the same as "no matches" — callers
159
+ * that need the fail-closed signal should use checkIgnored() directly.
160
+ */
161
+ function checkIgnoredPaths(repoRoot, relativePaths) {
162
+ return checkIgnored(repoRoot, relativePaths).ignored;
163
+ }
164
+
165
+ function deriveRepoName(originUrl, fallbackDir) {
166
+ if (originUrl) {
167
+ const match = originUrl.match(/([^/:]+?)(?:\.git)?$/);
168
+ if (match) return match[1];
169
+ }
170
+ return path.basename(path.resolve(fallbackDir));
171
+ }
172
+
173
+ /** True if `relativePath` is tracked by git in `repoRoot` (committed/staged). Used to warn before
174
+ * overwriting a git-TRACKED `.niro-project` with a private temp-project id (which would then show as a
175
+ * local modification and could be committed to the shared repo). Best-effort: false on any error. */
176
+ function isTracked(repoRoot, relativePath) {
177
+ try {
178
+ execSync(`git ls-files --error-unmatch -- ${JSON.stringify(relativePath)}`, {
179
+ cwd: repoRoot,
180
+ encoding: "utf8",
181
+ timeout: 3000,
182
+ stdio: ["ignore", "ignore", "ignore"],
183
+ });
184
+ return true; // exit 0 => tracked
185
+ } catch {
186
+ return false; // non-zero => not tracked (or git unavailable)
187
+ }
188
+ }
189
+
190
+ module.exports = { inspect, deriveRepoName, checkIgnored, checkIgnoredPaths, redactRemoteCreds, isTracked };