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