@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,1166 @@
1
+ /**
2
+ * `niro init [path]` — end-to-end onboarding from a local folder.
3
+ *
4
+ * Onboards via AGENT_UPLOAD (ADR-011 Phase 1): the folder's source files are
5
+ * UPLOADED to the server (no workspace-root constraint, no LOCAL_PATH). The
6
+ * sequence mirrors `niro add repo` exactly so the two share one code path:
7
+ *
8
+ * 1. Ensure credentials (auto-login if missing).
9
+ * 2. Detect origin URL + branch from .git (branch may be null — detached HEAD
10
+ * onboards fine).
11
+ * 3. Resolve a free project name (probe before any mutation).
12
+ * 4. Register the repo with NO project_dir → server sets sourceType=AGENT_UPLOAD
13
+ * and returns a gitId. Record it in watchState BEFORE uploading (orphan-safe).
14
+ * Re-run guard: if this folder already has a stored gitId, reuse it and skip
15
+ * registration (but continue the rest of init — no early return).
16
+ * 5. Offer module-exclude suggestions, then /filter the manifest, then PUT the
17
+ * include/exclude module-resolution and upload the included files
18
+ * (folderSync.doInitialSync — concurrent + resilient).
19
+ * 6. POST /api/projects with git_id_to_mapping → project + discovered env vars.
20
+ * 7. Write .niro-project at the repo root so MCP tools from this repo can
21
+ * resolve the project immediately — before the (potentially long) build,
22
+ * so a Ctrl-C or build failure still leaves the repo pinned.
23
+ * 8. Prompt user values for each discovered env var; PUT /env-vars.
24
+ * 9. Review indexing scope per module (include/exclude globs); PUT /module-resolution.
25
+ * 10. Prompt auto-build on/off and quiet period; PUT /auto-sync.
26
+ * 11. Confirm, then POST /api/graph/{pid}/build → watch /status until terminal.
27
+ */
28
+ const path = require("path");
29
+ const fs = require("fs");
30
+ const api = require("../lib/api");
31
+ const agentApi = require("../lib/agentApi");
32
+ const watchState = require("../lib/watchState");
33
+ const { resolveContext } = require("../lib/resolveContext");
34
+ const folderSync = require("../lib/folderSync");
35
+ const credentials = require("../lib/credentials");
36
+ const prompt = require("../lib/prompt");
37
+ const git = require("../lib/git");
38
+ const niroProjectFile = require("../lib/niroProjectFile");
39
+ const color = require("../lib/color");
40
+ const watchDaemon = require("../lib/watchDaemon");
41
+ const { login } = require("./login");
42
+ const { watchStatus } = require("./build");
43
+ const { fetchPreview, buildCandidates } = require("./edit");
44
+ const { resolveProjectId } = require("./_resolveProject");
45
+ const { projectsToRows } = require("./projects");
46
+ const { isSecretKey, looksSecretValue } = require("../lib/secret");
47
+
48
+ const step = (n) => color.bold(color.cyan(n));
49
+ const ok = (s) => color.green(s);
50
+ const warn = (s) => color.yellow(s);
51
+ const fail = (s) => color.red(s);
52
+
53
+ async function init(pathArg, opts = {}) {
54
+ const targetDir = resolveTargetDir(pathArg);
55
+ // Validate flag values up-front so a bad --auto-build/--confirm-build/--quiet-period
56
+ // fails BEFORE any server-side mutation (otherwise a typo leaves an orphan project).
57
+ parseOnOff(opts.autoBuild);
58
+ parseYesNo(opts.confirmBuild);
59
+ parseQuietPeriod(opts.quietPeriod, 30);
60
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
61
+ console.error(`Not a directory: ${targetDir}`);
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ let creds = credentials.load();
67
+ if (!creds || !creds.token) {
68
+ console.log("Not logged in — starting login flow.");
69
+ await login({ apiUrl: opts.apiUrl });
70
+ creds = credentials.load();
71
+ if (!creds || !creds.token) return;
72
+ }
73
+
74
+ // The backend the CLI is pointed at. watchState entries (cached gitIds) are
75
+ // scoped to this so a backend switch never reuses a gitId from another stack.
76
+ const currentApiUrl = api.currentBaseUrl();
77
+
78
+ const gitInfo = git.inspect(targetDir);
79
+ const repoRoot = gitInfo.repoRoot || targetDir;
80
+ // Detached HEAD or no git → branch is null; AGENT_UPLOAD onboarding is fine
81
+ // with a null branch (the server defaults it).
82
+ const defaultBranch = gitInfo.branch || null;
83
+ const defaultName = git.deriveRepoName(gitInfo.originUrl, repoRoot);
84
+
85
+ // Conflicting intent: --add-to (attach to existing) and --project-name (create
86
+ // new) are mutually exclusive. Fail BEFORE any server-side mutation.
87
+ if (opts.addTo && opts.projectName) {
88
+ console.error(fail(
89
+ "Cannot use --add-to and --project-name together.\n" +
90
+ " --add-to attaches this folder to an EXISTING project; --project-name creates a NEW one."
91
+ ));
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ // Resolve what the server already knows about this folder's git url+branch ONCE,
97
+ // up front — BEFORE we ask new-vs-existing — so we can lead with "already indexed"
98
+ // instead of blandly prompting for a folder Niro already knows. Reused by the
99
+ // per-target dedupe further below.
100
+ let ctx = { projects: [], state: "error" };
101
+ let resolveFailed = false;
102
+ try {
103
+ ctx = await resolveContext(targetDir, opts);
104
+ } catch {
105
+ resolveFailed = true;
106
+ ctx = { projects: [], state: "error" };
107
+ }
108
+ const ctxProjects = Array.isArray(ctx.projects) ? ctx.projects : [];
109
+
110
+ // If the folder HAS a git origin but resolution FAILED, the duplicate check is
111
+ // inconclusive — make the user (or an agent, via --force) confirm rather than proceed.
112
+ if (gitInfo.originUrl && (resolveFailed || ctx.state === "error")) {
113
+ console.log(warn(
114
+ `\n ⚠ Couldn't verify whether this repo is already registered` +
115
+ `${ctx.error ? ` (${ctx.error})` : ""}.`
116
+ ));
117
+ if (!(await requireProceed(" Proceed without the duplicate check?", opts))) {
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ }
122
+
123
+ // Lead with "already indexed": if this folder's repo is already in one or more
124
+ // projects, say so first. Unless the user explicitly targets another project
125
+ // (--add-to) or opts in interactively, do NOTHING — don't ask new-vs-existing for a
126
+ // folder Niro already knows. (The per-target dedupe below still enforces the rules
127
+ // once a target is chosen.)
128
+ //
129
+ // Identity = url+branch (ADR-017): "already indexed" only counts projects whose INDEXED branch is the
130
+ // one you're on. A match on a DIFFERENT branch means the repo is known but YOUR branch isn't indexed —
131
+ // that's a `niro sandbox`, NOT "nothing to do". Split the matches on branch_match.
132
+ if (ctxProjects.length) {
133
+ const onIndexedBranch = ctxProjects.filter((p) => p.branch_match !== false);
134
+ const otherBranchOnly = ctxProjects.filter((p) => p.branch_match === false);
135
+
136
+ // Track this folder locally (no sandbox) so Niro's bridge/daemon KNOW it exists — that's what lets a
137
+ // session in a SIBLING repo of the same project enumerate this one, detect its branch, and nudge a
138
+ // sandbox (ADR-017 multi-repo detection). Deliberately writes NO gitId, so the watch daemon's
139
+ // allLocal() skips it and never streams edits to the shared source. Skips a folder that ALREADY has an
140
+ // entry on ANY backend (peek, not backend-scoped get): the store is path-keyed, so a backend-scoped get
141
+ // returns null for an entry tagged to a DIFFERENT backend, and writing would then CLOBBER that entry —
142
+ // destroying a takeover backup / gitId and disabling the other backend's sync (cross-backend data loss).
143
+ const matchedProjectId = (onIndexedBranch[0] || otherBranchOnly[0]).project_id;
144
+ if (matchedProjectId && !watchState.peek(repoRoot)) {
145
+ watchState.set(repoRoot, {
146
+ folderPath: repoRoot,
147
+ includePatterns: [], excludePatterns: [], fileHashes: {}, lastSyncedAt: null,
148
+ projectId: matchedProjectId,
149
+ branch: defaultBranch || null,
150
+ tracked: true, // locally-tracked for detection only; no gitId => daemon never syncs it
151
+ }, currentApiUrl);
152
+ console.log(color.dim(" Niro is now tracking this folder locally (no sandbox) so cross-repo answers can include it."));
153
+ }
154
+
155
+ if (onIndexedBranch.length) {
156
+ const where = onIndexedBranch
157
+ .map((p) => `"${p.alias || p.project_id}"${p.source_type ? ` (${p.source_type})` : ""}`)
158
+ .join(", ");
159
+ console.log(`\n${color.green("✓")} This folder is already indexed in: ${where}.`);
160
+ if (!opts.addTo) {
161
+ if (prompt.isNonInteractive()) {
162
+ console.log(color.dim(
163
+ " Already indexed — nothing to do. Pass --add-to <project> to also map it elsewhere, or run 'niro build' to re-index."
164
+ ));
165
+ return;
166
+ }
167
+ if (!(await prompt.confirm(" Add it to a DIFFERENT project too?", false))) {
168
+ console.log(color.dim(" Nothing to do — use 'niro build' to re-index or 'niro edit' to manage it."));
169
+ return;
170
+ }
171
+ }
172
+ } else if (otherBranchOnly.length) {
173
+ // Known repo, but you're on a branch Niro hasn't indexed → the honest answer is "sandbox it",
174
+ // not "already indexed". A sandbox indexes your branch privately (teammates unaffected).
175
+ const where = otherBranchOnly
176
+ .map((p) => `"${p.alias || p.project_id}"${p.indexed_branch ? ` (indexed on ${color.cyan(p.indexed_branch)})` : ""}`)
177
+ .join(", ");
178
+ const yourBranch = defaultBranch ? `\`${defaultBranch}\`` : "your current branch";
179
+ console.log(`\n${color.green("✓")} This repo is part of: ${where}.`);
180
+ console.log(` But ${yourBranch} isn't indexed by Niro — answers would reflect the indexed branch, not your local work.`);
181
+ console.log(` ${color.cyan("Run `niro sandbox` here")} to index your branch privately ` +
182
+ color.dim("(teammates unaffected; `niro discard` to undo)."));
183
+ if (!opts.addTo) {
184
+ if (prompt.isNonInteractive()) {
185
+ console.log(color.dim(
186
+ " Pass --add-to <project> to map this repo into a DIFFERENT project, or run 'niro sandbox' for your branch."
187
+ ));
188
+ return;
189
+ }
190
+ if (!(await prompt.confirm(" Add this repo to a DIFFERENT project instead?", false))) {
191
+ return;
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ // Parent-folder overlap: this folder may CONTAIN repos already indexed in Niro (a
198
+ // monorepo root over several service repos). watchState only knows folders THIS CLI
199
+ // onboarded, so resolve each immediate git-repo child against the server too — this
200
+ // catches children indexed via the web UI / as separate repos.
201
+ const childScan = await scanIndexedChildren(targetDir, opts);
202
+ if (childScan.indexed.length) {
203
+ console.error(fail(`\n This folder contains ${childScan.indexed.length} repo(s) already indexed in Niro:`));
204
+ for (const c of childScan.indexed) {
205
+ const where = c.projects.map((p) => `"${p.alias || p.project_id}"`).join(", ");
206
+ console.error(` ${c.name} -> ${where}`);
207
+ }
208
+ console.error(" A parent of already-indexed repos can't be onboarded — it would upload that content again.");
209
+ console.error(" Onboard each repo from its own folder instead.");
210
+ // Hard refuse: there's no legitimate reason to upload a parent over indexed repos.
211
+ // --force stays as a deliberate escape hatch for scripts/agents that really mean it.
212
+ if (!opts.force) {
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ console.log(warn(" --force set — onboarding the parent anyway."));
217
+ }
218
+ if (childScan.unverified.length) {
219
+ console.log(warn(
220
+ ` ⚠ Couldn't check ${childScan.unverified.length} nested repo(s) ` +
221
+ `(${childScan.unverified.map((c) => c.name).join(", ")}) — they may also be indexed.`
222
+ ));
223
+ }
224
+ if (childScan.truncated) {
225
+ console.log(color.dim(` (Stopped after the first ${CHILD_SCAN_MAX} nested repos; deeper ones weren't checked.)`));
226
+ }
227
+ const indexedChildPaths = new Set(childScan.indexed.map((c) => path.resolve(c.path)));
228
+
229
+ const target = await resolveTarget({ defaultName, opts });
230
+ if (!target) {
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ // EXISTING mode attaches the new repo's gitId to target.projectId; NEW mode
235
+ // POSTs /api/projects to create it. The repo register/upload steps below are
236
+ // identical for both — they don't depend on new-vs-existing.
237
+ const isExisting = target.isExisting;
238
+ const projectId = target.projectId;
239
+
240
+ const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
241
+ const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
242
+
243
+ // Per-TARGET dedupe (ctx/ctxProjects were resolved up front, before resolveTarget,
244
+ // and the resolve-failed/inconclusive case was already handled there):
245
+ // - SAME repo already in the TARGET project -> hard refuse (no upload, no attach).
246
+ // - SAME repo in ANOTHER project as AGENT_UPLOAD -> reuse its gitId, skip upload.
247
+ // - SAME repo in ANOTHER project as REMOTE_GIT -> warn + require confirmation.
248
+ // - ancestor/descendant path overlap -> refuse (same project) / confirm (cross).
249
+
250
+ // b. SAME repo already in the TARGET project -> hard refuse.
251
+ const inTarget = ctxProjects.find((p) => p.project_id === projectId);
252
+ if (inTarget) {
253
+ console.error(fail(
254
+ `This repo is already in project "${projectId}"` +
255
+ `${inTarget.source_type ? ` (as ${inTarget.source_type})` : ""}.\n` +
256
+ ` Use 'niro build' to re-index or 'niro edit' to manage it.`
257
+ ));
258
+ process.exitCode = 1;
259
+ return;
260
+ }
261
+
262
+ // c. CROSS-PROJECT reuse: the same git url+branch is already registered in
263
+ // ANOTHER project. Never re-create the repo — reuse its gitId.
264
+ let reuseGitId = null;
265
+ let skipUpload = false;
266
+ // Note: for a monorepo, resolve returns a CHILD gitId; reusing it attaches that
267
+ // one child (not all sibling deployables). Acceptable for the common single-repo
268
+ // case; cross-project reuse of a monorepo child is a known partial behaviour.
269
+ const crossUpload = ctxProjects.find((p) => p.source_type === "AGENT_UPLOAD" && p.git_id);
270
+ const crossRemote = ctxProjects.find((p) => p.source_type === "REMOTE_GIT");
271
+ if (crossUpload) {
272
+ console.log(warn(
273
+ `\n ⚠ This repo is already registered in project "${crossUpload.project_id}"; ` +
274
+ `reusing that repo, attaching to "${projectId}" without re-uploading.`
275
+ ));
276
+ reuseGitId = crossUpload.git_id;
277
+ skipUpload = true;
278
+ } else if (crossRemote) {
279
+ console.log(warn(
280
+ `\n ⚠ This repo is already a REMOTE repo in project "${crossRemote.project_id}"; ` +
281
+ `proceeding uploads a SEPARATE local copy.`
282
+ ));
283
+ if (!(await requireProceed(" Proceed and upload a separate local copy?", opts))) {
284
+ process.exitCode = 1;
285
+ return;
286
+ }
287
+ }
288
+
289
+ // d. watchState ancestor/descendant overlap (covers non-git / path-only overlap).
290
+ for (const o of watchState.findOverlapping(targetDir, currentApiUrl)) {
291
+ if (indexedChildPaths.has(path.resolve(o.path))) continue; // already surfaced by the parent-scan
292
+ if (o.projectId === projectId) {
293
+ console.error(fail(
294
+ `This folder overlaps "${o.path}", which is already in project "${projectId}".`
295
+ ));
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+ console.log(warn(
300
+ `\n ⚠ This folder overlaps "${o.path}"` +
301
+ `${o.projectId ? ` (in project "${o.projectId}")` : ""}.`
302
+ ));
303
+ if (!(await requireProceed(" Proceed despite the folder overlap?", opts))) {
304
+ process.exitCode = 1;
305
+ return;
306
+ }
307
+ }
308
+
309
+ // Re-run guard: if this folder already has a registered gitId, reuse it and
310
+ // SKIP registration — but DO NOT early-return; the rest of init still runs so
311
+ // a partial onboarding can be completed on a second pass.
312
+ //
313
+ // reuseGitId precedence: a cross-project AGENT_UPLOAD match (above, skipUpload=true)
314
+ // takes priority; otherwise the exact-folder re-run guard supplies the gitId.
315
+ const existing = watchState.get(targetDir, currentApiUrl);
316
+ let gitId = null;
317
+ if (reuseGitId) {
318
+ // Cross-project reuse of an already-onboarded repo: reuse the gitId, never
319
+ // re-register, and skip the upload (the server already has its files).
320
+ // reuseGitId came straight from resolveContext (the current backend), so it
321
+ // is known-good — no existence check needed.
322
+ gitId = reuseGitId;
323
+ console.log(`\n ${step("●")} Reusing already-registered repo (gitId ${color.dim(gitId)}); skipping re-upload.`);
324
+ } else if (existing && existing.gitId) {
325
+ if (ctxProjects.some((p) => p.git_id === existing.gitId)) {
326
+ // Already mapped to >=1 project on THIS backend -> fully onboarded, and the
327
+ // server just vouched for it via resolveContext. Reuse, skip the upload.
328
+ gitId = existing.gitId;
329
+ skipUpload = true;
330
+ console.log(`\n ${step("●")} Reusing already-registered repo (gitId ${color.dim(gitId)}); skipping re-upload.`);
331
+ } else if (await agentApi.repoExists(existing.gitId)) {
332
+ // Registered but not yet in any project (partial onboarding interrupted
333
+ // before the project POST). The repo still exists on this backend -> finish
334
+ // it by uploading.
335
+ gitId = existing.gitId;
336
+ console.log(`\n ${step("●")} Reusing already-registered repo (gitId ${color.dim(gitId)}).`);
337
+ } else {
338
+ // Self-heal: the cached gitId does NOT exist on the current backend — the
339
+ // backend was switched (cached gitId belongs to another stack) or the repo
340
+ // was deleted. Drop the stale entry and register fresh instead of crashing
341
+ // on the first call with a dead gitId.
342
+ console.log(warn(
343
+ `\n ⚠ Cached repo (gitId ${existing.gitId}) is not on this backend — ` +
344
+ `re-registering it here.`
345
+ ));
346
+ watchState.remove(targetDir);
347
+ // gitId stays null -> fresh registration below.
348
+ }
349
+ }
350
+
351
+ if (!gitId) {
352
+ // NO project_dir → the server sets sourceType=AGENT_UPLOAD.
353
+ const repoUrl = gitInfo.originUrl || `local://${defaultName}`;
354
+ gitId = await agentApi.registerRepo({
355
+ displayName: defaultName,
356
+ repositoryUrl: repoUrl,
357
+ branch: defaultBranch,
358
+ moduleExcludePatterns: cliModuleExcludes,
359
+ fileExcludePatterns: cliFileExcludes,
360
+ });
361
+ // Record BEFORE upload (mirrors add-repo): if the upload or a later step is
362
+ // interrupted, the gitId is not orphaned — a re-run reuses it. Tag it with the
363
+ // backend so a later backend switch won't reuse this gitId where it is invalid.
364
+ watchState.set(targetDir, {
365
+ gitId,
366
+ projectId: null,
367
+ branch: defaultBranch,
368
+ includePatterns: [],
369
+ excludePatterns: [],
370
+ fileHashes: {},
371
+ lastSyncedAt: null,
372
+ }, currentApiUrl);
373
+ console.log(`\n ${step("●")} Registered repo for upload (gitId ${color.dim(gitId)}).`);
374
+ }
375
+
376
+ // UPLOAD STEP — skipped entirely when reusing a repo that is ALREADY mapped to a
377
+ // project (cross-project AGENT_UPLOAD reuse, or a re-run on an already-onboarded
378
+ // folder). The server already holds its files, so we go straight to attach/create.
379
+ if (!skipUpload) {
380
+ const { offerModuleExcludeSuggestions } = require("../lib/moduleExcludeSuggestions");
381
+ await offerModuleExcludeSuggestions(gitId, opts, cliModuleExcludes);
382
+
383
+ // Build the manifest, surface oversize files (the 256 KiB server cap skips
384
+ // them), then ask the server which files to include.
385
+ process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
386
+ const manifest = folderSync.buildManifest(targetDir);
387
+ process.stdout.write(` ${manifest.length} files found.\n`);
388
+ for (const m of manifest) {
389
+ if (m.size_bytes > folderSync.PER_FILE_CAP_BYTES) {
390
+ console.log(warn(
391
+ ` ⚠ ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) exceeds the ` +
392
+ `256 KiB per-file cap — it will be skipped and not indexed.`
393
+ ));
394
+ }
395
+ }
396
+
397
+ const filterResult = await agentApi.filterManifest(gitId, manifest);
398
+ const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
399
+ const includedCount = filterResult.included_count || filterResult.includedCount || includedPaths.length;
400
+ const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
401
+ const includePatterns = filterResult.include_patterns || filterResult.includePatterns || [];
402
+ const excludePatterns = filterResult.exclude_patterns || filterResult.excludePatterns || [];
403
+ console.log(` ${ok("Will sync:")} ${includedCount} files ${warn("Excluded:")} ${excludedCount} files`);
404
+
405
+ // Push include/exclude patterns to the server so the build pipeline respects
406
+ // them. module_path "" (root) — there is no build-file-detected module yet.
407
+ if (includePatterns.length > 0 || excludePatterns.length > 0) {
408
+ try {
409
+ await api.put(`/api/git/${encodeURIComponent(gitId)}/module-resolution`, {
410
+ module_path: "",
411
+ include_dirs: includePatterns,
412
+ excluded: excludePatterns,
413
+ });
414
+ } catch (err) {
415
+ console.log(warn(` ⚠ Could not save patterns to server: ${err.message}`));
416
+ }
417
+ }
418
+ watchState.update(targetDir, { includePatterns, excludePatterns });
419
+
420
+ console.log(`\n ${step("1/3")} Uploading ${includedPaths.length} files (branch ${color.cyan(defaultBranch || "detached HEAD")})...`);
421
+ // Reuse the sizes the manifest already computed so the aggregate guard does not re-stat every file.
422
+ const sizes = {};
423
+ for (const m of manifest) sizes[m.path] = m.size_bytes;
424
+ await folderSync.doInitialSync(gitId, targetDir, includedPaths, { sizes });
425
+ } else {
426
+ console.log(` ${step("1/3")} ${ok("Skipping upload — the server already has this repo's files.")}`);
427
+ }
428
+
429
+ let project;
430
+ if (isExisting) {
431
+ // EXISTING mode: attach this repo to the already-existing project via the
432
+ // same git-mappings endpoint `niro edit --add-repo` uses. Do NOT create a
433
+ // project. The discovered env vars come back via the preview fetch below,
434
+ // not from this response (POST git-mappings returns the updated project).
435
+ console.log(` ${step("2/3")} Adding repo to existing project "${color.bold(projectId)}" and scanning for environment variables...`);
436
+ project = await api.post(
437
+ `/api/projects/${encodeURIComponent(projectId)}/git-mappings`,
438
+ { [gitId]: {} }
439
+ );
440
+ } else {
441
+ console.log(` ${step("2/3")} Creating project "${color.bold(projectId)}" and scanning for environment variables...`);
442
+ project = await api.post("/api/projects", {
443
+ project_id: projectId,
444
+ git_id_to_mapping: { [gitId]: {} },
445
+ });
446
+ }
447
+
448
+ // If this folder was already onboarded to a different project, note the re-pin so
449
+ // it isn't silent — the repo is now mapped to both projects, but the pin (and MCP
450
+ // tools / `niro build` defaults) follow the most recent one.
451
+ if (existing && existing.projectId && existing.projectId !== projectId) {
452
+ console.log(warn(
453
+ ` ⚠ This folder was pinned to "${existing.projectId}"; it stays mapped there, ` +
454
+ `but is now also attached to "${projectId}" and re-pinned to it.`
455
+ ));
456
+ }
457
+ const { path: pinPath } = niroProjectFile.write(repoRoot, projectId, { overwrite: true });
458
+ watchState.update(targetDir, { projectId });
459
+ console.log(` ${ok("✓")} Pinned project to ${color.dim(pinPath)}`);
460
+
461
+ let envVars = extractEnvVars(project, gitId);
462
+ // EXISTING mode attaches via POST /git-mappings, whose response may not carry the
463
+ // discovered env vars (a NEW-project POST normally does). When the response has
464
+ // none, fall back to the authoritative env-var preview so EXISTING mode still
465
+ // surfaces vars. Capture varsByFile here so the prompt below doesn't re-fetch.
466
+ let previewVarsByFile = null;
467
+ let previewActivePropertiesFiles = null;
468
+ if (envVars.length === 0) {
469
+ try {
470
+ const preview = await fetchPreview(projectId, gitId, { apiUrl: opts.apiUrl });
471
+ previewVarsByFile = preview.varsByFile || {};
472
+ previewActivePropertiesFiles = preview.activePropertiesFiles || [];
473
+ if (Array.isArray(preview.currentDiscoveredEnvVars) && preview.currentDiscoveredEnvVars.length) {
474
+ envVars = preview.currentDiscoveredEnvVars;
475
+ }
476
+ } catch {
477
+ // Could not load the preview — treat as no env vars; they can be set later
478
+ // with `niro edit --list-env` / `--set-env`.
479
+ }
480
+ }
481
+ let filledVars = envVars;
482
+ if (envVars.length === 0) {
483
+ console.log(" No env vars detected in this repo — skipping.");
484
+ } else if (opts.defaults) {
485
+ console.log(` Found ${envVars.length} env var(s); leaving all at defaults (--defaults).`);
486
+ } else if (prompt.isNonInteractive()) {
487
+ console.error(fail(
488
+ `Found ${envVars.length} env var(s) that need values and no terminal is available to prompt.\n` +
489
+ " Run with --defaults to accept discovered defaults, or set them later with `niro edit --set-env`."
490
+ ));
491
+ process.exitCode = 1;
492
+ return;
493
+ } else {
494
+ console.log(` Found ${envVars.length} env var(s). When a var appears in several files, pick which file's value to use.`);
495
+ // Show, per var, the candidate value each committed file carries (source-file
496
+ // parity with the web UI). Reuse the preview fetched above when available; if a
497
+ // fresh fetch fails, fall back to a plain prompt rather than blocking onboarding.
498
+ let varsByFile = previewVarsByFile;
499
+ let activePropertiesFiles = previewActivePropertiesFiles;
500
+ if (varsByFile == null) {
501
+ try {
502
+ const preview = await fetchPreview(projectId, gitId, { apiUrl: opts.apiUrl });
503
+ varsByFile = preview.varsByFile;
504
+ activePropertiesFiles = preview.activePropertiesFiles;
505
+ } catch (err) {
506
+ varsByFile = {};
507
+ console.log(warn(` ⚠ Could not load env-var source files (${err.message}) — prompting without candidates.`));
508
+ }
509
+ }
510
+
511
+ // PROPERTY-FILE SELECTION (interactive, web-UI parity): when Niro found >=2
512
+ // property files, let the user IGNORE some so their values don't drive defaults.
513
+ // The chosen ACTIVE set then filters the per-var candidate menu AND is persisted
514
+ // alongside the env values, so the selection sticks. With 0-1 files there is
515
+ // nothing meaningful to choose, so we skip it (server default unchanged).
516
+ const activeFiles = await promptActivePropertyFiles(varsByFile, activePropertiesFiles);
517
+
518
+ filledVars = await promptEnvVars(envVars, varsByFile, activeFiles);
519
+ // Persist active_properties_files only for a real RESTRICTION (a non-empty proper
520
+ // subset). "Keep all" equals the server default, so we leave it alone and send the
521
+ // bare array — avoids storing a redundant/ambiguous selection.
522
+ const totalFiles = Object.keys(varsByFile || {}).length;
523
+ const isRestriction = Array.isArray(activeFiles) && activeFiles.length > 0 && activeFiles.length < totalFiles;
524
+ const body = isRestriction
525
+ ? { env_vars: filledVars, active_properties_files: activeFiles }
526
+ : filledVars;
527
+ await api.put(
528
+ `/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars`,
529
+ body
530
+ );
531
+ }
532
+
533
+ if (opts.defaults) {
534
+ console.log(" Skipping indexing-scope review (--defaults).");
535
+ } else if (prompt.isNonInteractive()) {
536
+ console.log(" Skipping indexing-scope review (no terminal to prompt; using detected defaults).");
537
+ } else {
538
+ await reviewIndexingScope(gitId);
539
+ }
540
+
541
+ // Auto-build: an explicit --auto-build flag (or --defaults) skips the prompt.
542
+ const autoBuildFlag = parseOnOff(opts.autoBuild);
543
+ if (autoBuildFlag !== null) {
544
+ const quietPeriodSeconds = parseQuietPeriod(opts.quietPeriod, 30);
545
+ await saveAutoBuild(projectId, autoBuildFlag, quietPeriodSeconds);
546
+ } else if (opts.defaults) {
547
+ console.log(" Leaving auto-build disabled (--defaults).");
548
+ } else if (prompt.isNonInteractive()) {
549
+ console.error(fail(
550
+ "Auto-build setting is required and no terminal is available to prompt.\n" +
551
+ " Pass --auto-build <on|off> (and optionally --quiet-period <seconds>), or run with --defaults."
552
+ ));
553
+ process.exitCode = 1;
554
+ return;
555
+ } else {
556
+ await promptAutoBuild(projectId);
557
+ }
558
+
559
+ // Trigger-build-now: --confirm-build flag (or --defaults) skips the prompt.
560
+ const confirmBuildFlag = parseYesNo(opts.confirmBuild);
561
+ let shouldBuild;
562
+ if (confirmBuildFlag !== null) {
563
+ shouldBuild = confirmBuildFlag;
564
+ } else if (opts.defaults) {
565
+ shouldBuild = true;
566
+ } else if (prompt.isNonInteractive()) {
567
+ console.error(fail(
568
+ "Build confirmation is required and no terminal is available to prompt.\n" +
569
+ " Pass --confirm-build <yes|no>, or run with --defaults."
570
+ ));
571
+ process.exitCode = 1;
572
+ return;
573
+ } else {
574
+ shouldBuild = await prompt.confirm(" Trigger a full index build now?", true);
575
+ }
576
+ if (!shouldBuild) {
577
+ console.log(` ${warn("⚠")} Skipping build. Run ${color.cyan("niro build")} when you're ready.`);
578
+ } else {
579
+ console.log(` ${step("3/3")} Triggering full index build...`);
580
+ await api.post(
581
+ `/api/graph/${encodeURIComponent(projectId)}/build`,
582
+ {},
583
+ { query: { force_full_rebuild: "true" } }
584
+ );
585
+
586
+ if (opts.watch === false) {
587
+ console.log(` Build triggered. Run ${color.cyan("niro status")} to check progress.`);
588
+ } else {
589
+ await watchStatus(projectId);
590
+ }
591
+ }
592
+
593
+ // --defaults promised "no prompts": never surface the PM2 install offer there.
594
+ const daemon = await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
595
+
596
+ // The closing banner must not read as an all-clear when auto-sync is dead —
597
+ // customers were only discovering the missing daemon after a reboot.
598
+ if (daemon.ok) {
599
+ console.log(`\n${ok("✓ Done.")} Niro MCP tools launched from this repo will now use this project automatically.`);
600
+ } else {
601
+ console.log(`\n${warn("✓ Done, with 1 warning:")} auto-sync is OFF — this folder will not stay in sync (see above for the fix).`);
602
+ console.log(`Niro MCP tools launched from this repo will still use this project automatically.`);
603
+ }
604
+ console.log(`Next: ${color.cyan("niro status")}, or ${color.cyan("niro mcp install")} to configure Claude Code / Cursor / Windsurf.`);
605
+ }
606
+
607
+ /**
608
+ * Gate a cross-project / overlap WARNING behind explicit consent.
609
+ *
610
+ * - --force -> proceed silently (agents / scripts opt in here).
611
+ * - interactive shell -> prompt.confirm(msg, default NO); a "no" means stop.
612
+ * - non-interactive -> refuse with guidance (no terminal to confirm on).
613
+ *
614
+ * Returns true to proceed, false to stop (the caller sets exitCode=1 and returns).
615
+ */
616
+ async function requireProceed(msg, opts = {}) {
617
+ if (opts.force) return true;
618
+ if (prompt.isNonInteractive()) {
619
+ console.error(fail(" Pass --force to proceed past this warning."));
620
+ return false;
621
+ }
622
+ return await prompt.confirm(msg, false);
623
+ }
624
+
625
+ // Directories we never treat as nested repos / never descend into (build output, deps).
626
+ const CHILD_SCAN_SKIP = new Set([
627
+ "node_modules", "target", "dist", "build", "out", ".next", "vendor", "__pycache__",
628
+ ]);
629
+ const CHILD_SCAN_MAX = 60; // bound the number of nested repos we resolve
630
+ const CHILD_SCAN_MAX_DEPTH = 2; // immediate children + one level of grouping dirs (services/, packages/, apps/)
631
+
632
+ /**
633
+ * Collect directories under `root` that are their OWN git repo (have a `.git` entry),
634
+ * scanning immediate children AND one level of NON-git grouping dirs (so `services/foo`,
635
+ * `packages/foo` are found), but never descending INTO a git repo. Returns absolute
636
+ * paths plus a `truncated` flag when the CHILD_SCAN_MAX cap is hit.
637
+ *
638
+ * NOTE: a `.git` FILE (submodule/worktree pointer) is detected here, but resolving its
639
+ * origin currently needs a `.git` DIRECTORY — submodule/worktree children are surfaced
640
+ * as "unverified" by the caller rather than confirmed. Full clones (the common case) work.
641
+ */
642
+ function collectNestedGitRepos(root) {
643
+ const repos = [];
644
+ let truncated = false;
645
+ const walk = (dir, depth) => {
646
+ if (repos.length >= CHILD_SCAN_MAX) { truncated = true; return; }
647
+ let entries;
648
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
649
+ for (const e of entries) {
650
+ if (!e.isDirectory() || e.name.startsWith(".") || CHILD_SCAN_SKIP.has(e.name)) continue;
651
+ const child = path.join(dir, e.name);
652
+ if (fs.existsSync(path.join(child, ".git"))) {
653
+ repos.push(child); // its own repo — record it, don't descend into it
654
+ if (repos.length >= CHILD_SCAN_MAX) { truncated = true; return; }
655
+ } else if (depth < CHILD_SCAN_MAX_DEPTH) {
656
+ walk(child, depth + 1); // a grouping dir (services/, packages/) — look one level deeper
657
+ }
658
+ }
659
+ };
660
+ walk(root, 1);
661
+ return { repos, truncated };
662
+ }
663
+
664
+ /**
665
+ * Detect that THIS folder is a PARENT of repos already indexed in Niro — e.g. running
666
+ * init in a monorepo root containing several service git repos. watchState only knows
667
+ * folders THIS CLI onboarded, so we resolve each nested git repo against the SERVER
668
+ * (covers children indexed via the web UI / as separate repos too).
669
+ *
670
+ * Returns { indexed:[{path,name,projects}], unverified:[{path,name}], truncated }.
671
+ * - indexed: children that resolve to >=1 project (the overlap we warn about).
672
+ * - unverified: children we couldn't check (resolve error / unreadable origin) — they
673
+ * MIGHT also be indexed, so we surface a softer note (no silent drop).
674
+ * Cheap for a normal repo (no nested git repos => no server calls); only a true parent
675
+ * pays for the resolves, which run with bounded concurrency.
676
+ */
677
+ async function scanIndexedChildren(targetDir, opts, resolve = resolveContext) {
678
+ const { repos: gitChildren, truncated } = collectNestedGitRepos(targetDir);
679
+ if (!gitChildren.length) return { indexed: [], unverified: [], truncated };
680
+
681
+ const indexed = [];
682
+ const unverified = [];
683
+ const CONCURRENCY = 8;
684
+ for (let i = 0; i < gitChildren.length; i += CONCURRENCY) {
685
+ const batch = gitChildren.slice(i, i + CONCURRENCY);
686
+ const resolved = await Promise.all(batch.map(async (child) => {
687
+ const name = path.basename(child);
688
+ try {
689
+ const ctx = await resolve(child, opts);
690
+ const projects = Array.isArray(ctx.projects) ? ctx.projects : [];
691
+ if (projects.length) return { kind: "indexed", path: child, name, projects };
692
+ // A server/network hiccup must not be read as "not indexed" — flag it.
693
+ if (ctx && ctx.state === "error") return { kind: "unverified", path: child, name };
694
+ return null;
695
+ } catch {
696
+ return { kind: "unverified", path: child, name };
697
+ }
698
+ }));
699
+ for (const r of resolved) {
700
+ if (!r) continue;
701
+ if (r.kind === "indexed") indexed.push(r);
702
+ else unverified.push(r);
703
+ }
704
+ }
705
+ return { indexed, unverified, truncated };
706
+ }
707
+
708
+ /**
709
+ * Decide the TARGET of this onboarding up front, before any repo registration:
710
+ * - --add-to <alias|id> -> EXISTING: resolve to a real projectId (throws on
711
+ * ambiguity/not-found via resolveProjectId), attach the new repo to it later.
712
+ * - --project-name / --defaults / non-interactive -> NEW: derive a free name
713
+ * exactly as before (unchanged behaviour for scripts and the default path).
714
+ * - INTERACTIVE with >=1 existing project -> show a numbered menu (new vs each
715
+ * existing project) so the user can attach instead of always creating new.
716
+ *
717
+ * Returns { projectId, isExisting } or null when the user/flags didn't yield a
718
+ * usable target (caller sets exitCode=1).
719
+ */
720
+ async function resolveTarget({ defaultName, opts }) {
721
+ if (opts.addTo) {
722
+ let projectId;
723
+ try {
724
+ projectId = await resolveProjectId(opts.addTo, { apiUrl: opts.apiUrl });
725
+ } catch (err) {
726
+ console.error(fail(`Could not resolve --add-to "${opts.addTo}": ${err.message}`));
727
+ return null;
728
+ }
729
+ return { projectId, isExisting: true };
730
+ }
731
+
732
+ // Explicit name, --defaults, or a non-interactive shell -> NEW (current path).
733
+ // We only offer the existing-project menu in an interactive shell with at
734
+ // least one project to pick.
735
+ if (!opts.projectName && !opts.defaults && !prompt.isNonInteractive()) {
736
+ const existing = await fetchExistingProjects(opts);
737
+ if (existing.length > 0) {
738
+ const chosen = await chooseTarget(existing);
739
+ if (chosen === null) return null; // no valid selection
740
+ if (chosen !== "NEW") return { projectId: chosen, isExisting: true };
741
+ // fall through to NEW name derivation
742
+ }
743
+ }
744
+
745
+ const projectId = await resolveAvailableProjectName({ defaultName, opts });
746
+ if (!projectId) return null;
747
+ return { projectId, isExisting: false };
748
+ }
749
+
750
+ /** Fetch the account's projects as sorted rows; empty list on any failure. */
751
+ async function fetchExistingProjects(opts) {
752
+ try {
753
+ const data = await api.get("/api/projects", { apiUrl: opts.apiUrl });
754
+ return projectsToRows(data);
755
+ } catch (err) {
756
+ console.log(warn(` ⚠ Could not load your existing projects (${err.message}) — creating a new project.`));
757
+ return [];
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Numbered menu: [n] create new, [1..N] attach to an existing project. Returns
763
+ * "NEW", an existing project_id, or null when the answer is invalid.
764
+ */
765
+ async function chooseTarget(rows) {
766
+ console.log(`\n ${color.bold("New project, or add to an existing one?")}`);
767
+ console.log(` [n] create a new project`);
768
+ rows.forEach((r, i) => {
769
+ const label = r.alias ? `${r.alias} (${color.dim(r.project_id)})` : color.dim(r.project_id);
770
+ console.log(` [${i + 1}] ${label}`);
771
+ });
772
+ const answer = (await prompt.ask(` Choose 'n' for new, or 1-${rows.length} to add to an existing project`, "n")).trim();
773
+ if (!answer || answer.toLowerCase() === "n") return "NEW";
774
+ const idx = Number.parseInt(answer, 10) - 1;
775
+ if (Number.isInteger(idx) && idx >= 0 && idx < rows.length) {
776
+ return rows[idx].project_id;
777
+ }
778
+ console.error(fail(`Invalid choice "${answer}" — pick 'n' or a number between 1 and ${rows.length}.`));
779
+ return null;
780
+ }
781
+
782
+ const GLOB_INCLUDE_EXAMPLES = "e.g. src/**/*.py, lib/**/*.ts";
783
+ const GLOB_EXCLUDE_EXAMPLES = "e.g. **/test/**, **/*.generated.*, vendor/**";
784
+
785
+ /**
786
+ * Resolve a project name that is guaranteed to be free on this account.
787
+ *
788
+ * We probe GET /api/projects/{id} before registering anything — previously
789
+ * the CLI only discovered a collision after the POST, by which point the
790
+ * repo had already been registered in step 1 and we left an orphan.
791
+ *
792
+ * Two modes:
793
+ * - --project-name supplied: check once; if taken, fail fast with guidance.
794
+ * Scripts shouldn't be surprised by an interactive loop.
795
+ * - Interactive: prompt, check, and loop on conflict. The first proposal
796
+ * defaults to the repo basename; subsequent loops keep whatever the user
797
+ * just typed as the default so a small tweak is one keystroke away.
798
+ */
799
+ async function resolveAvailableProjectName({ defaultName, opts }) {
800
+ if (opts.projectName) {
801
+ if (await projectNameTaken(opts.projectName)) {
802
+ console.error(
803
+ fail(`Project "${opts.projectName}" already exists on this account.`) +
804
+ ` Pick a different name or delete it first (niro delete ${opts.projectName}).`
805
+ );
806
+ return null;
807
+ }
808
+ return opts.projectName;
809
+ }
810
+
811
+ // --defaults: accept the derived repo name without prompting (it is checked
812
+ // for collisions below). Non-interactive without --defaults/--project-name:
813
+ // fail loudly rather than hang on a prompt.
814
+ if (opts.defaults) {
815
+ if (await projectNameTaken(defaultName)) {
816
+ console.error(
817
+ fail(`Project "${defaultName}" already exists on this account.`) +
818
+ ` Pass --project-name <name> or delete it first (niro delete ${defaultName}).`
819
+ );
820
+ return null;
821
+ }
822
+ return defaultName;
823
+ }
824
+ if (prompt.isNonInteractive()) {
825
+ console.error(fail(
826
+ "Project name is required and no terminal is available to prompt.\n" +
827
+ " Pass --project-name <name>, or run with --defaults to accept the derived name."
828
+ ));
829
+ return null;
830
+ }
831
+
832
+ let candidate = defaultName;
833
+ while (true) {
834
+ const answer = await prompt.ask("Project name", candidate);
835
+ if (!answer) {
836
+ console.error(fail("Project name is required."));
837
+ return null;
838
+ }
839
+ if (!(await projectNameTaken(answer))) return answer;
840
+ console.log(` ${warn("⚠")} "${color.bold(answer)}" already exists on this account — pick another.`);
841
+ candidate = answer;
842
+ }
843
+ }
844
+
845
+ async function projectNameTaken(projectId) {
846
+ try {
847
+ await api.get(`/api/projects/${encodeURIComponent(projectId)}`);
848
+ return true;
849
+ } catch (err) {
850
+ const body = err && err.body;
851
+ const msg = (body && (body.error || body.message)) || err.message || "";
852
+ if (/not found/i.test(String(msg))) return false;
853
+ // Any other error (network, 401, 500) we surface — don't silently treat
854
+ // as "available" or we'll create a project we can't manage.
855
+ throw err;
856
+ }
857
+ }
858
+
859
+ /**
860
+ * Fetch per-module include/exclude resolutions the server detected during repo
861
+ * registration, display them, and let the user extend either set. We only send
862
+ * a PUT when the user actually added patterns — an empty answer leaves the
863
+ * server's detected defaults untouched.
864
+ *
865
+ * The server stores these on GitRepoDetails.projectResolutions, keyed by
866
+ * module_path (e.g. "src/server" for a monorepo subproject). Each resolution
867
+ * has its own include/exclude set, so we walk the map module by module.
868
+ */
869
+ async function reviewIndexingScope(gitId) {
870
+ let details;
871
+ try {
872
+ details = await api.get(`/api/git/${encodeURIComponent(gitId)}`);
873
+ } catch (err) {
874
+ console.log(` Could not fetch project resolutions (${err.message}) — skipping scope review.`);
875
+ return;
876
+ }
877
+ const resolutions = (details && (details.project_resolutions || details.projectResolutions)) || {};
878
+ const modulePaths = Object.keys(resolutions);
879
+ if (modulePaths.length === 0) {
880
+ console.log(" No module resolutions detected — skipping scope review.");
881
+ return;
882
+ }
883
+
884
+ console.log(`\n ${color.bold("Review indexing scope")} (${modulePaths.length} module(s) detected).`);
885
+ console.log(color.dim(` Glob patterns use ** for recursive match. Press Enter to keep the detected set.`));
886
+
887
+ for (const modulePath of modulePaths) {
888
+ const resolution = resolutions[modulePath] || {};
889
+ const includeDirs = toArray(resolution.include_dirs || resolution.includeDirs);
890
+ const excluded = toArray(resolution.excluded);
891
+ const pkg = resolution.package_name || resolution.packageName;
892
+ const label = pkg ? `${modulePath} (${color.dim(pkg)})` : modulePath;
893
+
894
+ console.log(`\n ${color.bold("[" + label + "]")}`);
895
+ console.log(` ${color.green("Included")}: ${formatList(includeDirs)}`);
896
+ console.log(` ${color.yellow("Excluded")}: ${formatList(excluded)}`);
897
+
898
+ const addedIncludes = await promptGlobList(" Add inclusion patterns", GLOB_INCLUDE_EXAMPLES);
899
+ const addedExcludes = await promptGlobList(" Add exclusion patterns", GLOB_EXCLUDE_EXAMPLES);
900
+
901
+ if (addedIncludes.length === 0 && addedExcludes.length === 0) continue;
902
+
903
+ const nextIncludes = uniq([...includeDirs, ...addedIncludes]);
904
+ const nextExcluded = uniq([...excluded, ...addedExcludes]);
905
+
906
+ try {
907
+ await api.put(`/api/git/${encodeURIComponent(gitId)}/module-resolution`, {
908
+ module_path: modulePath,
909
+ include_dirs: nextIncludes,
910
+ excluded: nextExcluded,
911
+ });
912
+ console.log(` ${ok("✓")} Updated.`);
913
+ } catch (err) {
914
+ console.log(` ${fail("✗")} Failed to update scope: ${err.message}`);
915
+ }
916
+ }
917
+ }
918
+
919
+ async function promptGlobList(label, exampleHint) {
920
+ const answer = await prompt.ask(`${label} ${color.dim("(" + exampleHint + ")")}`, "");
921
+ if (!answer) return [];
922
+ return answer
923
+ .split(",")
924
+ .map((s) => s.trim())
925
+ .filter(Boolean);
926
+ }
927
+
928
+ async function promptAutoBuild(projectId) {
929
+ const enabled = await prompt.confirm(" Enable auto-build when files change?", false);
930
+ let quietPeriodSeconds = 30;
931
+ if (enabled) {
932
+ const raw = await prompt.ask(" Quiet period in seconds before rebuild", "30");
933
+ const parsed = Number.parseInt(raw, 10);
934
+ if (Number.isFinite(parsed) && parsed >= 0) quietPeriodSeconds = parsed;
935
+ }
936
+ await saveAutoBuild(projectId, enabled, quietPeriodSeconds);
937
+ }
938
+
939
+ async function saveAutoBuild(projectId, enabled, quietPeriodSeconds) {
940
+ try {
941
+ await api.put(`/api/projects/${encodeURIComponent(projectId)}/auto-sync`, {
942
+ enabled,
943
+ quiet_period_seconds: quietPeriodSeconds,
944
+ });
945
+ const statusLabel = enabled ? ok(`ON (quiet ${quietPeriodSeconds}s)`) : color.dim("off");
946
+ console.log(` Auto-build: ${statusLabel}.`);
947
+ } catch (err) {
948
+ console.log(` ${fail("✗")} Failed to save auto-build setting: ${err.message}`);
949
+ }
950
+ }
951
+
952
+ /** Parse --auto-build <on|off>. Returns true/false, or null if unset. Throws on garbage. */
953
+ function parseOnOff(v) {
954
+ if (v === undefined || v === null || v === "") return null;
955
+ const s = String(v).trim().toLowerCase();
956
+ if (s === "on" || s === "true" || s === "yes" || s === "1") return true;
957
+ if (s === "off" || s === "false" || s === "no" || s === "0") return false;
958
+ throw new Error(`Invalid --auto-build value "${v}". Use "on" or "off".`);
959
+ }
960
+
961
+ /** Parse --confirm-build <yes|no>. Returns true/false, or null if unset. Throws on garbage. */
962
+ function parseYesNo(v) {
963
+ if (v === undefined || v === null || v === "") return null;
964
+ const s = String(v).trim().toLowerCase();
965
+ if (s === "yes" || s === "y" || s === "true" || s === "1") return true;
966
+ if (s === "no" || s === "n" || s === "false" || s === "0") return false;
967
+ throw new Error(`Invalid --confirm-build value "${v}". Use "yes" or "no".`);
968
+ }
969
+
970
+ /** Parse --quiet-period <seconds>; fall back to the default when unset, throw on garbage. */
971
+ function parseQuietPeriod(v, fallback) {
972
+ if (v === undefined || v === null || v === "") return fallback;
973
+ const parsed = Number.parseInt(String(v), 10);
974
+ if (!Number.isFinite(parsed) || parsed < 0) {
975
+ throw new Error(`Invalid --quiet-period value "${v}". Use a non-negative number of seconds.`);
976
+ }
977
+ return parsed;
978
+ }
979
+
980
+ function toArray(v) {
981
+ if (!v) return [];
982
+ if (Array.isArray(v)) return v;
983
+ if (v instanceof Set) return [...v];
984
+ if (typeof v === "object") return Object.values(v);
985
+ return [];
986
+ }
987
+
988
+ function uniq(list) {
989
+ return [...new Set(list)];
990
+ }
991
+
992
+ function formatList(list) {
993
+ if (!list || list.length === 0) return "(none)";
994
+ return list.join(", ");
995
+ }
996
+
997
+ function extractEnvVars(project, gitId) {
998
+ if (!project) return [];
999
+ const mapping = (project.git_id_to_mapping || project.gitIdToMapping || {})[gitId];
1000
+ if (!mapping) return [];
1001
+ const props = mapping.git_properties || mapping.gitProperties;
1002
+ if (!props) return [];
1003
+ const list = props.discovered_env_vars || props.discoveredEnvVars;
1004
+ return Array.isArray(list) ? list : [];
1005
+ }
1006
+
1007
+ /**
1008
+ * PROPERTY-FILE SELECTION (web-UI parity). When Niro discovered >=2 property files,
1009
+ * list them with their current active state and let the user pick which to IGNORE
1010
+ * (Enter = use ALL). Returns the resulting ACTIVE set as an array, or null when there
1011
+ * is nothing to choose (0-1 files) — in which case the caller leaves the server's
1012
+ * stored selection untouched.
1013
+ *
1014
+ * `currentActive` is the preview's activePropertiesFiles (empty/absent => all active).
1015
+ * The user lists FILE NUMBERS to ignore; we drop those, keeping the rest active. The
1016
+ * value of each file is never shown here — this step is purely about file selection.
1017
+ */
1018
+ async function promptActivePropertyFiles(varsByFile, currentActive) {
1019
+ const files = Object.keys(varsByFile || {}).sort();
1020
+ if (files.length < 2) return null; // nothing meaningful to choose
1021
+
1022
+ const activeSet = Array.isArray(currentActive) && currentActive.length
1023
+ ? new Set(currentActive)
1024
+ : null; // null => all active
1025
+ const isActive = (f) => activeSet == null || activeSet.has(f);
1026
+
1027
+ console.log(`\n ${color.bold("Property files Niro found")} (their values become env-var defaults):`);
1028
+ files.forEach((f, i) => {
1029
+ const state = isActive(f) ? ok("[active]") : warn("[ignored]");
1030
+ console.log(` [${i + 1}] ${f} ${state}`);
1031
+ });
1032
+ const answer = (await prompt.ask(
1033
+ " Enter to use ALL, or list numbers to IGNORE (e.g. 2,3)",
1034
+ ""
1035
+ )).trim();
1036
+
1037
+ if (!answer) {
1038
+ // Use ALL discovered files (every file active).
1039
+ return files.slice();
1040
+ }
1041
+
1042
+ const ignoreIdx = new Set(
1043
+ answer
1044
+ .split(",")
1045
+ .map((s) => Number.parseInt(s.trim(), 10) - 1)
1046
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < files.length)
1047
+ );
1048
+ const active = files.filter((_f, i) => !ignoreIdx.has(i));
1049
+ if (active.length === files.length) {
1050
+ console.log(color.dim(" No valid numbers recognised — keeping all property files active."));
1051
+ return files.slice();
1052
+ }
1053
+ if (active.length === 0) {
1054
+ // Ignoring every file isn't a durable state (an empty active set reads back as
1055
+ // "all active"). Keep all; the user can still leave values blank and set them
1056
+ // later with `niro edit --set-env`.
1057
+ console.log(warn(" Can't ignore every property file — keeping all active."));
1058
+ return files.slice();
1059
+ }
1060
+ const ignored = files.filter((_f, i) => ignoreIdx.has(i));
1061
+ console.log(` ${warn("Ignoring")}: ${ignored.join(", ")}`);
1062
+ return active;
1063
+ }
1064
+
1065
+ /**
1066
+ * Prompt a value for each discovered env var. When the same key was discovered in
1067
+ * SEVERAL files (candidates reconstructed from the preview's varsByFile, exactly as
1068
+ * the web UI does), render a numbered menu so the user can pick which file's value
1069
+ * drives the var — adopting a file sets user_value to that file's value and points
1070
+ * source_file at it. Otherwise fall back to a single prompt.
1071
+ *
1072
+ * `activeFiles` (optional array): when provided, candidate values are restricted to
1073
+ * those property files (web-UI "considered files" selection) — ignored files do not
1074
+ * appear in the menu.
1075
+ *
1076
+ * SECRETS: secret-pattern keys never echo a value. Candidate values are masked in
1077
+ * the menu, and a custom value is typed hidden (askHidden). Non-interactive callers
1078
+ * never reach here (init gates this behind an interactive check); even so, an empty
1079
+ * answer keeps the current value rather than wiping it.
1080
+ */
1081
+ async function promptEnvVars(envVars, varsByFile = {}, activeFiles = null) {
1082
+ const out = [];
1083
+ for (const v of envVars) {
1084
+ const key = v.key;
1085
+ const secret = isSecretKey(key);
1086
+ const currentValue = v.user_value || v.resolved_value || "";
1087
+ const currentSource = v.source_file || v.sourceFile || null;
1088
+ // Restrict candidates to the ACTIVE property files when a selection was made
1089
+ // (ignored files contribute no defaults — web-UI parity).
1090
+ const candidates = buildCandidates(varsByFile, key, v, activeFiles);
1091
+
1092
+ if (candidates.length > 1) {
1093
+ console.log(`\n ${color.bold(key)}${currentSource ? color.dim(` (current: ${currentSource})`) : ""}`);
1094
+ candidates.forEach((c, i) => {
1095
+ // Mask when the key looks secret OR the value embeds credentials.
1096
+ const hide = secret || looksSecretValue(c.value);
1097
+ const shown = c.value == null ? "(empty)" : (hide ? "****" : c.value);
1098
+ const marker = (c.is_current || c.is_default) ? color.green(" [current default]") : "";
1099
+ console.log(` [${i + 1}] ${shown} ${color.dim("from " + c.source_file)}${marker}`);
1100
+ });
1101
+ console.log(` [c] enter a custom value`);
1102
+ const choice = (await prompt.ask(` Choose 1-${candidates.length}, c, or Enter to keep current`, "")).trim();
1103
+ if (!choice) {
1104
+ out.push({ ...v });
1105
+ continue;
1106
+ }
1107
+ if (choice.toLowerCase() === "c") {
1108
+ const answer = secret
1109
+ ? await prompt.askHidden(` Value for ${key}`)
1110
+ : await prompt.ask(` Value for ${key}`, "");
1111
+ out.push({ ...v, user_value: answer || null });
1112
+ continue;
1113
+ }
1114
+ const idx = Number.parseInt(choice, 10) - 1;
1115
+ if (Number.isInteger(idx) && idx >= 0 && idx < candidates.length) {
1116
+ const chosen = candidates[idx];
1117
+ out.push({ ...v, user_value: chosen.value, source_file: chosen.source_file });
1118
+ } else {
1119
+ out.push({ ...v }); // unrecognised -> keep current, never guess
1120
+ }
1121
+ continue;
1122
+ }
1123
+
1124
+ // Single (or no) candidate: one prompt. Tell the user where it came from, and —
1125
+ // when no default value was found in a committed config/property file — that the
1126
+ // value is needed (its value may have lived only in a gitignored .env or inline
1127
+ // in code, which we never scrape).
1128
+ let hint = currentSource ? ` (from ${currentSource})` : "";
1129
+ if (!currentValue) hint += " (no default — please enter a value)";
1130
+ if (secret) {
1131
+ const answer = await prompt.askHidden(` ${key}${hint} (hidden, Enter to keep current)`);
1132
+ out.push({ ...v, user_value: answer || (v.user_value || null) });
1133
+ } else {
1134
+ const answer = await prompt.ask(` ${key}${hint}`, currentValue);
1135
+ out.push({ ...v, user_value: answer || null });
1136
+ }
1137
+ }
1138
+ return out;
1139
+ }
1140
+
1141
+ /**
1142
+ * Return the user-intended target directory, preferring the shell's logical
1143
+ * cwd ($PWD) over process.cwd() when they point at the same inode. This keeps
1144
+ * paths that transit through symlinks (a common Docker-mount setup) in their
1145
+ * logical form rather than collapsing them to the symlink target.
1146
+ */
1147
+ function resolveTargetDir(pathArg) {
1148
+ const base = logicalCwd();
1149
+ if (!pathArg) return base;
1150
+ return path.isAbsolute(pathArg) ? path.resolve(pathArg) : path.resolve(base, pathArg);
1151
+ }
1152
+
1153
+ function logicalCwd() {
1154
+ const pwd = process.env.PWD;
1155
+ if (!pwd) return process.cwd();
1156
+ try {
1157
+ const a = fs.statSync(pwd);
1158
+ const b = fs.statSync(process.cwd());
1159
+ if (a.ino === b.ino && a.dev === b.dev) return pwd;
1160
+ } catch {
1161
+ // $PWD stale or unreadable — fall through
1162
+ }
1163
+ return process.cwd();
1164
+ }
1165
+
1166
+ module.exports = { init, parseOnOff, parseYesNo, parseQuietPeriod, promptEnvVars, promptActivePropertyFiles, scanIndexedChildren };