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