@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,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro edit` — mutate an existing Niro project from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Target project resolution (same precedence as status/build/delete):
|
|
5
|
+
* explicit --project <alias|id> > .niro-project walk-up from cwd.
|
|
6
|
+
* resolveProjectId THROWS on ambiguity (alias/url maps to >1 project), so we
|
|
7
|
+
* never silently pick the wrong one.
|
|
8
|
+
*
|
|
9
|
+
* Sub-actions (at least one required; multiple may be combined and run in a
|
|
10
|
+
* stable order):
|
|
11
|
+
* --add-repo <gitId> POST /api/projects/{projectId}/git-mappings
|
|
12
|
+
* --remove-repo <gitId> DELETE /api/projects/{projectId}/git/{gitId} (non-destructive unmap)
|
|
13
|
+
* --set-env KEY=VALUE... PUT /api/projects/{projectId}/git/{gitId}/env-vars
|
|
14
|
+
* --list-env GET /api/projects/{projectId}/git/{gitId}/env-vars/preview
|
|
15
|
+
* --rescan-env POST /api/projects/{projectId}/git/{gitId}/env-vars/rescan
|
|
16
|
+
* --alias <newAlias> PUT /api/projects/{projectId} (phase-1 rename)
|
|
17
|
+
*
|
|
18
|
+
* Env-var sub-actions are per-REPO (the server keys env vars by gitId). The
|
|
19
|
+
* target repo is --repo <gitId>, or auto-selected when the project has exactly
|
|
20
|
+
* one repo mapped, or — on a multi-repo project — the repo matching the current
|
|
21
|
+
* folder's git url+branch (only when it provably belongs to this project).
|
|
22
|
+
*
|
|
23
|
+
* SECRETS: --set-env never echoes a VALUE (only the changed key NAMES are
|
|
24
|
+
* printed). --set-env-from never echoes a VALUE either — it prints "KEY <- file".
|
|
25
|
+
* --list-env shows non-secret candidate values inline but masks values for
|
|
26
|
+
* secret-pattern keys via secret.js before printing (terminal AND --json).
|
|
27
|
+
*
|
|
28
|
+
* SOURCE-FILE PARITY (web UI): the same KEY can be discovered in MULTIPLE files
|
|
29
|
+
* (varsByFile) with different values — those are the CANDIDATES. We reconstruct
|
|
30
|
+
* them client-side (exactly as the UI's EnvVarsRepoSection does) so the user can
|
|
31
|
+
* see "this var appears in N files" and choose which file drives it. Choosing a
|
|
32
|
+
* file = setting that var's user_value to the file's value (and source_file to
|
|
33
|
+
* the chosen file), then PUTting the legacy env-var array. No backend change.
|
|
34
|
+
*/
|
|
35
|
+
const api = require("../lib/api");
|
|
36
|
+
const credentials = require("../lib/credentials");
|
|
37
|
+
const color = require("../lib/color");
|
|
38
|
+
const { resolveProjectId } = require("./_resolveProject");
|
|
39
|
+
const { resolveContext } = require("../lib/resolveContext");
|
|
40
|
+
const { stripSecrets, isSecretKey, looksSecretValue } = require("../lib/secret");
|
|
41
|
+
|
|
42
|
+
async function edit(opts = {}) {
|
|
43
|
+
// --active-files may be an empty string (which setActiveFiles rejects with a clear
|
|
44
|
+
// error) — guard on "was the flag provided" (!== undefined), not truthiness, so the
|
|
45
|
+
// empty case still runs and reports the error rather than being silently ignored.
|
|
46
|
+
const hasActiveFiles = opts.activeFiles !== undefined && opts.activeFiles !== null;
|
|
47
|
+
const actions = [
|
|
48
|
+
opts.addRepo, opts.removeRepo,
|
|
49
|
+
opts.setEnv && opts.setEnv.length,
|
|
50
|
+
opts.setEnvFrom && opts.setEnvFrom.length,
|
|
51
|
+
hasActiveFiles,
|
|
52
|
+
opts.listEnv,
|
|
53
|
+
opts.rescanEnv, opts.alias,
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
if (actions.length === 0) {
|
|
56
|
+
printUsage();
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
credentials.requireCreds();
|
|
62
|
+
const projectId = await resolveProjectId(opts.project, { apiUrl: opts.apiUrl });
|
|
63
|
+
|
|
64
|
+
// Run sub-actions in a stable, sensible order: structural mappings first,
|
|
65
|
+
// then env edits, then a rescan, then read-only listing, then rename last.
|
|
66
|
+
if (opts.addRepo) await addRepo(projectId, opts.addRepo, opts);
|
|
67
|
+
if (opts.removeRepo) await removeRepo(projectId, opts.removeRepo, opts);
|
|
68
|
+
if (opts.setEnv && opts.setEnv.length) await setEnv(projectId, opts.setEnv, opts);
|
|
69
|
+
if (opts.setEnvFrom && opts.setEnvFrom.length) {
|
|
70
|
+
const gitId = await resolveRepo(projectId, opts);
|
|
71
|
+
await setEnvFrom(projectId, gitId, opts.setEnvFrom, opts);
|
|
72
|
+
}
|
|
73
|
+
if (hasActiveFiles) await setActiveFiles(projectId, opts.activeFiles, opts);
|
|
74
|
+
if (opts.rescanEnv) await rescanEnv(projectId, opts);
|
|
75
|
+
if (opts.listEnv) await listEnv(projectId, opts);
|
|
76
|
+
if (opts.alias) await setAlias(projectId, opts.alias, opts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function addRepo(projectId, gitId, opts) {
|
|
80
|
+
// The endpoint takes a map of gitId -> GitMappingRequestDto. The DTO carries
|
|
81
|
+
// no user fields (baseUrl is discovered from env scanning), so an empty
|
|
82
|
+
// object is the correct body.
|
|
83
|
+
await api.post(
|
|
84
|
+
`/api/projects/${encodeURIComponent(projectId)}/git-mappings`,
|
|
85
|
+
{ [gitId]: {} },
|
|
86
|
+
{ apiUrl: opts.apiUrl }
|
|
87
|
+
);
|
|
88
|
+
console.log(`${color.green("✓")} Added repo ${color.dim(gitId)} to project ${color.dim(projectId)}.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function removeRepo(projectId, gitId, opts) {
|
|
92
|
+
await api.del(
|
|
93
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}`,
|
|
94
|
+
{ apiUrl: opts.apiUrl }
|
|
95
|
+
);
|
|
96
|
+
console.log(`${color.green("✓")} Unmapped repo ${color.dim(gitId)} from project ${color.dim(projectId)} (repo itself is kept).`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function setAlias(projectId, alias, opts) {
|
|
100
|
+
const trimmed = String(alias).trim();
|
|
101
|
+
if (!trimmed) {
|
|
102
|
+
console.error(`${color.red("✗")} --alias cannot be empty.`);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await api.put(
|
|
107
|
+
`/api/projects/${encodeURIComponent(projectId)}`,
|
|
108
|
+
{ alias: trimmed },
|
|
109
|
+
{ apiUrl: opts.apiUrl }
|
|
110
|
+
);
|
|
111
|
+
console.log(`${color.green("✓")} Renamed project ${color.dim(projectId)} to "${color.bold(trimmed)}".`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve which repo (gitId) the env sub-actions target:
|
|
116
|
+
* - explicit --repo <gitId> wins;
|
|
117
|
+
* - else if the project maps exactly one repo, use it;
|
|
118
|
+
* - else (multiple repos) try the CURRENT FOLDER: resolve this folder's git
|
|
119
|
+
* url+branch to its project+repo (same signal `niro info` uses) and, IF that
|
|
120
|
+
* repo is one of THIS project's repos, target it — so a monorepo (ADR-005:
|
|
121
|
+
* one project, many repos) just works from inside a repo folder;
|
|
122
|
+
* - else fail clearly listing the candidates (never guess).
|
|
123
|
+
*
|
|
124
|
+
* SAFETY: we only use the folder-resolved git_id when it belongs to THIS project's
|
|
125
|
+
* mapped repos. If the folder resolves to a different project, has no git_id, or
|
|
126
|
+
* resolves to a repo not in this project, we DO NOT guess — writing env vars to the
|
|
127
|
+
* wrong repo must be impossible — and fall back to the clear --repo error.
|
|
128
|
+
*/
|
|
129
|
+
async function resolveRepo(projectId, opts) {
|
|
130
|
+
if (opts.repo) return opts.repo;
|
|
131
|
+
const project = await api.get(`/api/projects/${encodeURIComponent(projectId)}`, { apiUrl: opts.apiUrl });
|
|
132
|
+
const gitDetails = (project && (project.git_details || project.gitDetails)) || [];
|
|
133
|
+
if (gitDetails.length === 1) return gitDetails[0].id;
|
|
134
|
+
if (gitDetails.length === 0) {
|
|
135
|
+
const e = new Error("This project has no repos mapped. Add one with --add-repo <gitId> first.");
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Multiple repos: try to pick the one matching the current folder, but only if
|
|
140
|
+
// it is provably one of this project's repos. Never pick arbitrarily.
|
|
141
|
+
const repoIds = new Set(gitDetails.map((g) => g.id).filter(Boolean));
|
|
142
|
+
const folderGitId = await folderRepoGitId(projectId, opts);
|
|
143
|
+
if (folderGitId && repoIds.has(folderGitId)) {
|
|
144
|
+
return folderGitId;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const list = gitDetails.map((g) => ` - ${g.id}${g.repo ? ` (${g.repo})` : ""}`).join("\n");
|
|
148
|
+
const e = new Error(`This project has multiple repos — pass --repo <gitId> to choose:\n${list}`);
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the current folder's repo (gitId) WITHIN the given project, or null.
|
|
154
|
+
*
|
|
155
|
+
* Asks the server which project(s)+repo this folder's git url+branch maps to, then
|
|
156
|
+
* returns the git_id only when one of those matches resolves to exactly THIS
|
|
157
|
+
* project and carries a git_id. Returns null on anything ambiguous, missing, or
|
|
158
|
+
* pointing at a different project — the caller then refuses to guess.
|
|
159
|
+
*/
|
|
160
|
+
async function folderRepoGitId(projectId, opts) {
|
|
161
|
+
let ctx;
|
|
162
|
+
try {
|
|
163
|
+
ctx = await resolveContext(process.cwd(), { apiUrl: opts.apiUrl });
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!ctx || ctx.state !== "resolved" || !Array.isArray(ctx.projects)) return null;
|
|
168
|
+
const match = ctx.projects.find((p) => p && p.project_id === projectId && p.git_id);
|
|
169
|
+
return match ? match.git_id : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* --set-env KEY=VALUE (repeatable). Reads the current env vars (preview), merges
|
|
174
|
+
* the new user values, and PUTs the full set back. NEVER echoes a value — prints
|
|
175
|
+
* only the changed key names on success.
|
|
176
|
+
*/
|
|
177
|
+
async function setEnv(projectId, pairs, opts) {
|
|
178
|
+
// Validate ALL input before any network call, so a malformed KEY=VALUE fails fast with
|
|
179
|
+
// zero server side effects and the masked parse error is always reachable.
|
|
180
|
+
const parsed = [];
|
|
181
|
+
for (const pair of pairs) {
|
|
182
|
+
const eq = pair.indexOf("=");
|
|
183
|
+
if (eq <= 0) {
|
|
184
|
+
console.error(`${color.red("✗")} --set-env expects KEY=VALUE (got "${maskPair(pair)}").`);
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
parsed.push({ key: pair.slice(0, eq).trim(), value: pair.slice(eq + 1) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const gitId = await resolveRepo(projectId, opts);
|
|
192
|
+
|
|
193
|
+
// Start from the project's currently-stored env vars so we don't drop any.
|
|
194
|
+
const preview = await fetchPreview(projectId, gitId, opts);
|
|
195
|
+
const current = preview.currentDiscoveredEnvVars;
|
|
196
|
+
|
|
197
|
+
// Index by key for an in-place merge; preserve other fields the server sent back.
|
|
198
|
+
const byKey = new Map();
|
|
199
|
+
for (const v of current) {
|
|
200
|
+
if (v && v.key) byKey.set(v.key, { ...v });
|
|
201
|
+
}
|
|
202
|
+
const changedKeys = [];
|
|
203
|
+
for (const { key, value } of parsed) {
|
|
204
|
+
const existing = byKey.get(key) || { key };
|
|
205
|
+
existing.user_value = value;
|
|
206
|
+
byKey.set(key, existing);
|
|
207
|
+
changedKeys.push(key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const envVars = Array.from(byKey.values());
|
|
211
|
+
await api.put(
|
|
212
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars`,
|
|
213
|
+
{ env_vars: envVars },
|
|
214
|
+
{ apiUrl: opts.apiUrl }
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (opts.json) {
|
|
218
|
+
console.log(JSON.stringify({ project_id: projectId, git_id: gitId, changed_keys: changedKeys }));
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`${color.green("✓")} Updated env var(s) on repo ${color.dim(gitId)}: ${changedKeys.map((k) => color.bold(k)).join(", ")}`);
|
|
221
|
+
console.log(color.dim(" (values are not shown)"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* --list-env: show each env var KEY, its current source file, and the per-var
|
|
227
|
+
* CANDIDATES — i.e. which committed files the same key was discovered in, and the
|
|
228
|
+
* value each file carries. This mirrors the web UI: the same KEY in multiple files
|
|
229
|
+
* is a choice of "which file drives this var". Non-secret candidate values are
|
|
230
|
+
* shown inline; secret-pattern keys have their values masked (terminal AND --json).
|
|
231
|
+
*/
|
|
232
|
+
async function listEnv(projectId, opts) {
|
|
233
|
+
const gitId = await resolveRepo(projectId, opts);
|
|
234
|
+
const preview = await fetchPreview(projectId, gitId, opts);
|
|
235
|
+
const summary = summarizeEnvVars(preview);
|
|
236
|
+
|
|
237
|
+
// The discovered PROPERTY FILES are the keys of varsByFile. The ACTIVE set is
|
|
238
|
+
// activePropertiesFiles; an EMPTY/absent active set means "all files active".
|
|
239
|
+
const discoveredFiles = Object.keys(preview.varsByFile || {}).sort();
|
|
240
|
+
const activeSet = toActiveSet(preview.activePropertiesFiles);
|
|
241
|
+
const isActive = (f) => activeSet == null || activeSet.has(f);
|
|
242
|
+
|
|
243
|
+
if (opts.json) {
|
|
244
|
+
// Secret candidate VALUES are already masked in summarizeEnvVars (primary
|
|
245
|
+
// defence — when the KEY name looks secret OR the VALUE embeds credentials).
|
|
246
|
+
// stripSecrets is a belt-and-braces pass over the candidate objects to redact
|
|
247
|
+
// any secret-NAMED field if the shape ever changes. We DON'T strip the
|
|
248
|
+
// surrounding metadata: those fields (key, source files, is_secret, appears_in)
|
|
249
|
+
// are deliberately public and carry no secret values — and field names like
|
|
250
|
+
// "is_secret" or a file path containing "secret" would otherwise be clobbered.
|
|
251
|
+
const env = summary.map((s) => ({
|
|
252
|
+
key: s.key,
|
|
253
|
+
source: s.chosen_source_file,
|
|
254
|
+
chosen_source_file: s.chosen_source_file,
|
|
255
|
+
has_user_value: s.has_user_value,
|
|
256
|
+
has_resolved_value: s.has_resolved_value,
|
|
257
|
+
is_secret: s.is_secret,
|
|
258
|
+
appears_in: s.appears_in,
|
|
259
|
+
candidates: stripSecrets(s.candidates, { maskValues: false }),
|
|
260
|
+
}));
|
|
261
|
+
console.log(JSON.stringify({
|
|
262
|
+
project_id: projectId,
|
|
263
|
+
git_id: gitId,
|
|
264
|
+
// The property files Niro discovered, and which of them are ACTIVE (drive
|
|
265
|
+
// env-var defaults). active_properties_files is the raw stored selection
|
|
266
|
+
// (empty array => all files active).
|
|
267
|
+
active_properties_files: toActiveSet(preview.activePropertiesFiles) == null ? [] : discoveredFiles.filter(isActive),
|
|
268
|
+
discovered_property_files: discoveredFiles.map((f) => ({ file: f, active: isActive(f) })),
|
|
269
|
+
env,
|
|
270
|
+
}));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Show the discovered property files and which are active, so the user can decide
|
|
275
|
+
// whether to ignore any (via `niro edit --active-files`).
|
|
276
|
+
if (discoveredFiles.length) {
|
|
277
|
+
const rendered = discoveredFiles
|
|
278
|
+
.map((f) => `${f} ${isActive(f) ? color.green("[active]") : color.yellow("[ignored]")}`)
|
|
279
|
+
.join(", ");
|
|
280
|
+
console.log(`Property files: ${rendered}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (summary.length === 0) {
|
|
284
|
+
console.log(color.dim("No env vars discovered for this repo."));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
console.log(`Env vars for repo ${color.dim(gitId)}:`);
|
|
288
|
+
for (const s of summary) {
|
|
289
|
+
const set = s.has_user_value ? color.green(" [set]") : "";
|
|
290
|
+
const src = s.chosen_source_file ? color.dim(` from ${s.chosen_source_file}`) : "";
|
|
291
|
+
const appears = s.appears_in > 1
|
|
292
|
+
? color.yellow(` (appears in ${s.appears_in} files)`)
|
|
293
|
+
: (s.appears_in === 1 ? color.dim(" (1 file)") : "");
|
|
294
|
+
console.log(` - ${color.bold(s.key)}${set}${src}${appears}`);
|
|
295
|
+
if (s.appears_in > 1) {
|
|
296
|
+
for (const c of s.candidates) {
|
|
297
|
+
const shown = c.value == null ? color.dim("(empty)") : c.value;
|
|
298
|
+
const marker = c.is_current ? color.green(" [current]") : "";
|
|
299
|
+
console.log(` ${color.dim("•")} ${c.source_file}: ${shown}${marker}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* --set-env-from KEY=FILE (repeatable). Adopt the value a specific committed file
|
|
307
|
+
* carries for KEY: set the var's user_value to that file's value and point its
|
|
308
|
+
* source_file at the chosen file, then PUT the legacy env-var array.
|
|
309
|
+
*
|
|
310
|
+
* HARD ERROR (no PUT for ANY key) when KEY is not present in FILE — we validate
|
|
311
|
+
* every pair against the preview's varsByFile BEFORE mutating anything, so a typo
|
|
312
|
+
* fails fast with zero server side effects. NEVER echoes a VALUE — prints only
|
|
313
|
+
* "KEY <- file".
|
|
314
|
+
*/
|
|
315
|
+
async function setEnvFrom(projectId, gitId, pairs, opts) {
|
|
316
|
+
const parsed = [];
|
|
317
|
+
for (const pair of pairs) {
|
|
318
|
+
const eq = pair.indexOf("=");
|
|
319
|
+
if (eq <= 0) {
|
|
320
|
+
throw new Error(`--set-env-from expects KEY=FILE (got "${maskPair(pair)}").`);
|
|
321
|
+
}
|
|
322
|
+
parsed.push({ key: pair.slice(0, eq).trim(), file: pair.slice(eq + 1).trim() });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const preview = await fetchPreview(projectId, gitId, opts);
|
|
326
|
+
const varsByFile = preview.varsByFile;
|
|
327
|
+
|
|
328
|
+
// Validate ALL pairs first — a single missing KEY-in-FILE aborts with NO PUT.
|
|
329
|
+
const resolved = [];
|
|
330
|
+
for (const { key, file } of parsed) {
|
|
331
|
+
const entries = varsByFile[file];
|
|
332
|
+
if (!Array.isArray(entries)) {
|
|
333
|
+
const known = Object.keys(varsByFile).sort();
|
|
334
|
+
const list = known.length ? `\n Files with discovered vars:\n${known.map((f) => ` - ${f}`).join("\n")}` : "";
|
|
335
|
+
throw new Error(`--set-env-from: file "${file}" has no discovered env vars for this repo.${list}`);
|
|
336
|
+
}
|
|
337
|
+
const match = entries.find((e) => e && e.key === key);
|
|
338
|
+
if (!match) {
|
|
339
|
+
throw new Error(`--set-env-from: key "${key}" is not present in "${file}". Pick a file that defines ${key} (see niro edit --list-env).`);
|
|
340
|
+
}
|
|
341
|
+
resolved.push({ key, file, value: pickResolved(match) });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Merge over the project's currently-stored env vars so we don't drop any.
|
|
345
|
+
const byKey = new Map();
|
|
346
|
+
for (const v of preview.currentDiscoveredEnvVars) {
|
|
347
|
+
if (v && v.key) byKey.set(v.key, { ...v });
|
|
348
|
+
}
|
|
349
|
+
const changed = [];
|
|
350
|
+
for (const { key, file, value } of resolved) {
|
|
351
|
+
const existing = byKey.get(key) || { key };
|
|
352
|
+
existing.user_value = value;
|
|
353
|
+
existing.source_file = file;
|
|
354
|
+
byKey.set(key, existing);
|
|
355
|
+
changed.push({ key, source_file: file });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const envVars = Array.from(byKey.values());
|
|
359
|
+
await api.put(
|
|
360
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars`,
|
|
361
|
+
envVars,
|
|
362
|
+
{ apiUrl: opts.apiUrl }
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (opts.json) {
|
|
366
|
+
console.log(JSON.stringify({ project_id: projectId, git_id: gitId, changed }));
|
|
367
|
+
} else {
|
|
368
|
+
for (const c of changed) {
|
|
369
|
+
console.log(`${color.green("✓")} ${color.bold(c.key)} ${color.dim("<-")} ${c.source_file}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* --active-files <a,b,c>: choose which discovered PROPERTY FILES Niro considers for
|
|
376
|
+
* env-var default values (the rest are ignored). Mirrors the web UI's file-selection
|
|
377
|
+
* step. The server persists the selection via SaveEnvVarsRequest.active_properties_files
|
|
378
|
+
* (GitProperties.setActivePropertiesFiles).
|
|
379
|
+
*
|
|
380
|
+
* Validation: each named file is checked against the discovered property files
|
|
381
|
+
* (Object.keys(varsByFile)). An UNKNOWN name is WARNed (listing the known files) and
|
|
382
|
+
* excluded — we proceed with the valid subset. If NONE of the named files match, we
|
|
383
|
+
* HARD ERROR (no PUT) rather than silently wiping the selection.
|
|
384
|
+
*
|
|
385
|
+
* At least one file is REQUIRED. By default all discovered property files are active,
|
|
386
|
+
* and an empty active set is indistinguishable from that default on the wire (both
|
|
387
|
+
* serialize to []), so "ignore all" can't be a durable state — an empty list errors.
|
|
388
|
+
*
|
|
389
|
+
* Prints only the chosen FILE NAMES — never any value.
|
|
390
|
+
*/
|
|
391
|
+
async function setActiveFiles(projectId, filesCsv, opts) {
|
|
392
|
+
const gitId = await resolveRepo(projectId, opts);
|
|
393
|
+
const preview = await fetchPreview(projectId, gitId, opts);
|
|
394
|
+
const known = Object.keys(preview.varsByFile || {});
|
|
395
|
+
const knownSet = new Set(known);
|
|
396
|
+
|
|
397
|
+
const requested = String(filesCsv == null ? "" : filesCsv)
|
|
398
|
+
.split(",")
|
|
399
|
+
.map((s) => s.trim())
|
|
400
|
+
.filter(Boolean);
|
|
401
|
+
|
|
402
|
+
// At least one file is required. An empty active set is indistinguishable from the
|
|
403
|
+
// server's never-configured default (both serialize to []), so it can't represent a
|
|
404
|
+
// durable "ignore all" — and by default all property files are active anyway.
|
|
405
|
+
if (requested.length === 0) {
|
|
406
|
+
throw new Error("--active-files: name at least one property file to keep active (the others are ignored). By default all are active; see niro edit --list-env.");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const valid = [];
|
|
410
|
+
const unknown = [];
|
|
411
|
+
for (const f of requested) {
|
|
412
|
+
if (knownSet.has(f)) valid.push(f);
|
|
413
|
+
else unknown.push(f);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (unknown.length) {
|
|
417
|
+
const list = known.length ? known.slice().sort().map((f) => ` - ${f}`).join("\n") : " (none)";
|
|
418
|
+
console.error(`${color.yellow("⚠")} Unknown property file(s) ignored: ${unknown.join(", ")}\n Discovered property files:\n${list}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (valid.length === 0) {
|
|
422
|
+
throw new Error(`--active-files: none of [${requested.join(", ")}] match a discovered property file. See niro edit --list-env.`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// dedup, preserve first-seen order.
|
|
426
|
+
const active = [...new Set(valid)];
|
|
427
|
+
|
|
428
|
+
await api.put(
|
|
429
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars`,
|
|
430
|
+
{ env_vars: preview.currentDiscoveredEnvVars, active_properties_files: active },
|
|
431
|
+
{ apiUrl: opts.apiUrl }
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (opts.json) {
|
|
435
|
+
console.log(JSON.stringify({ project_id: projectId, git_id: gitId, active_properties_files: active }));
|
|
436
|
+
} else {
|
|
437
|
+
console.log(`${color.green("✓")} Active property files: ${active.join(", ")} ${color.dim("(others ignored for env-var defaults)")}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* GET the env-var preview and normalise it to a stable, snake/camel-tolerant shape.
|
|
443
|
+
* Reused by setEnv, setEnvFrom, listEnv, and init's prompt.
|
|
444
|
+
*/
|
|
445
|
+
async function fetchPreview(projectId, gitId, opts = {}) {
|
|
446
|
+
const preview = await api.get(
|
|
447
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars/preview`,
|
|
448
|
+
{ apiUrl: opts.apiUrl }
|
|
449
|
+
);
|
|
450
|
+
const p = preview || {};
|
|
451
|
+
return {
|
|
452
|
+
varsByFile: p.vars_by_file || p.varsByFile || {},
|
|
453
|
+
activePropertiesFiles: p.active_properties_files || p.activePropertiesFiles || [],
|
|
454
|
+
currentDiscoveredEnvVars: p.current_discovered_env_vars || p.currentDiscoveredEnvVars || [],
|
|
455
|
+
raw: p,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Normalise an active-properties-files filter to a Set, or null when there is no
|
|
461
|
+
* filter (empty/absent => "all files active"). Accepts an array or a Set.
|
|
462
|
+
*/
|
|
463
|
+
function toActiveSet(activeFiles) {
|
|
464
|
+
if (!activeFiles) return null;
|
|
465
|
+
const arr = activeFiles instanceof Set ? [...activeFiles] : (Array.isArray(activeFiles) ? activeFiles : []);
|
|
466
|
+
const cleaned = arr.map((f) => (f == null ? "" : String(f))).filter((f) => f !== "");
|
|
467
|
+
return cleaned.length ? new Set(cleaned) : null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** snake/camel-tolerant resolved-value accessor for a DiscoveredEnvVar. */
|
|
471
|
+
function pickResolved(v) {
|
|
472
|
+
if (!v) return null;
|
|
473
|
+
if (v.resolvedValue !== undefined) return v.resolvedValue;
|
|
474
|
+
if (v.resolved_value !== undefined) return v.resolved_value;
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Reconstruct the per-var CANDIDATES from varsByFile — exactly as the UI's
|
|
480
|
+
* EnvVarsRepoSection does: loop every file, collect each entry that matches `key`,
|
|
481
|
+
* and record {source_file, value}. Dedup identical (file,value) pairs.
|
|
482
|
+
*
|
|
483
|
+
* `current` may be a source-file string OR the current DiscoveredEnvVar object.
|
|
484
|
+
* is_current marks the candidate whose file matches the var's current source_file;
|
|
485
|
+
* is_default best-effort marks the candidate whose value matches the var's current
|
|
486
|
+
* resolved value. We do NOT try to fix Spring-profile precedence here (out of
|
|
487
|
+
* scope) — these are labels, not a re-resolution.
|
|
488
|
+
*
|
|
489
|
+
* `activeFiles` (optional, array or Set): when provided AND non-empty, only files
|
|
490
|
+
* in that set contribute candidates — this mirrors the web UI's "choose which
|
|
491
|
+
* property files to consider" selection (ignored files contribute no defaults). An
|
|
492
|
+
* EMPTY or absent active set means "all files active" (consider everything) — the
|
|
493
|
+
* original behaviour.
|
|
494
|
+
*/
|
|
495
|
+
function buildCandidates(varsByFile, key, current, activeFiles) {
|
|
496
|
+
const cur = current && typeof current === "object" ? current : { source_file: current };
|
|
497
|
+
const curSource = cur.source_file || cur.sourceFile || null;
|
|
498
|
+
const curResolved = pickResolved(cur);
|
|
499
|
+
|
|
500
|
+
// Normalise the active-file filter. Empty/absent = no filter (include all).
|
|
501
|
+
const activeSet = toActiveSet(activeFiles);
|
|
502
|
+
|
|
503
|
+
const seen = new Set();
|
|
504
|
+
const candidates = [];
|
|
505
|
+
for (const [file, vars] of Object.entries(varsByFile || {})) {
|
|
506
|
+
if (activeSet && !activeSet.has(file)) continue;
|
|
507
|
+
if (!Array.isArray(vars)) continue;
|
|
508
|
+
for (const v of vars) {
|
|
509
|
+
if (!v || v.key !== key) continue;
|
|
510
|
+
const value = pickResolved(v);
|
|
511
|
+
const sig = `${file}${value == null ? "" : String(value)}`;
|
|
512
|
+
if (seen.has(sig)) continue;
|
|
513
|
+
seen.add(sig);
|
|
514
|
+
candidates.push({ source_file: file, value: value == null ? null : value });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
candidates.sort((a, b) => String(a.source_file).localeCompare(String(b.source_file)));
|
|
518
|
+
for (const c of candidates) {
|
|
519
|
+
c.is_current = curSource != null && c.source_file === curSource;
|
|
520
|
+
c.is_default = curResolved !== null && curResolved !== undefined && c.value === curResolved;
|
|
521
|
+
}
|
|
522
|
+
return candidates;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Build a per-var view (key, chosen source file, candidates) for listing. Secret
|
|
527
|
+
* candidate values are masked via secret.js so no plaintext secret ever reaches
|
|
528
|
+
* stdout or JSON.
|
|
529
|
+
*/
|
|
530
|
+
function summarizeEnvVars(preview) {
|
|
531
|
+
const current = preview.currentDiscoveredEnvVars;
|
|
532
|
+
const varsByFile = preview.varsByFile;
|
|
533
|
+
// Only the ACTIVE property files drive candidate values — mirrors the web UI's
|
|
534
|
+
// "choose which property files to consider" selection. Empty/absent = all active.
|
|
535
|
+
const activeFiles = preview.activePropertiesFiles;
|
|
536
|
+
return current.map((v) => {
|
|
537
|
+
const key = v.key;
|
|
538
|
+
const keySecret = isSecretKey(key);
|
|
539
|
+
// Mask a candidate value when the KEY name looks secret OR the VALUE itself
|
|
540
|
+
// embeds credentials (e.g. DATABASE_URL=postgres://u:p@h). Full "****" mask:
|
|
541
|
+
// unlike a UI there's no correlation benefit to revealing the last few chars.
|
|
542
|
+
const candidates = buildCandidates(varsByFile, key, v, activeFiles).map((c) => {
|
|
543
|
+
const hide = keySecret || looksSecretValue(c.value);
|
|
544
|
+
const hasValue = c.value !== null && c.value !== undefined && c.value !== "";
|
|
545
|
+
return {
|
|
546
|
+
source_file: c.source_file,
|
|
547
|
+
value: hide && hasValue ? "****" : c.value,
|
|
548
|
+
masked: hide && hasValue,
|
|
549
|
+
is_current: c.is_current,
|
|
550
|
+
is_default: c.is_default,
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
key,
|
|
555
|
+
chosen_source_file: v.source_file || v.sourceFile || null,
|
|
556
|
+
has_user_value: Boolean(v.user_value || v.userValue),
|
|
557
|
+
has_resolved_value: Boolean(v.resolved_value || v.resolvedValue),
|
|
558
|
+
// secret if the key name is secret OR any candidate value was masked.
|
|
559
|
+
is_secret: keySecret || candidates.some((c) => c.masked),
|
|
560
|
+
appears_in: candidates.length,
|
|
561
|
+
candidates,
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function rescanEnv(projectId, opts) {
|
|
567
|
+
const gitId = await resolveRepo(projectId, opts);
|
|
568
|
+
const resolved = await api.post(
|
|
569
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(gitId)}/env-vars/rescan`,
|
|
570
|
+
{},
|
|
571
|
+
{ apiUrl: opts.apiUrl }
|
|
572
|
+
);
|
|
573
|
+
const count = Array.isArray(resolved) ? resolved.length : 0;
|
|
574
|
+
console.log(`${color.green("✓")} Rescanned env vars for repo ${color.dim(gitId)} — ${count} discovered (user values preserved).`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Mask the VALUE part of a KEY=VALUE pair when echoing a parse error. */
|
|
578
|
+
function maskPair(pair) {
|
|
579
|
+
const eq = pair.indexOf("=");
|
|
580
|
+
if (eq < 0) return pair;
|
|
581
|
+
return pair.slice(0, eq + 1) + "****";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function printUsage() {
|
|
585
|
+
console.error("niro edit — change an existing project. At least one action is required:");
|
|
586
|
+
console.error(" --add-repo <gitId> map a registered repo to the project");
|
|
587
|
+
console.error(" --remove-repo <gitId> unmap a repo (non-destructive)");
|
|
588
|
+
console.error(" --set-env KEY=VALUE set an env var (repeatable; value never printed)");
|
|
589
|
+
console.error(" --set-env-from KEY=FILE adopt the value FILE carries for KEY (repeatable; value never printed)");
|
|
590
|
+
console.error(" --active-files <files> property files to keep active for env-var defaults; the rest are ignored (default: all)");
|
|
591
|
+
console.error(" --list-env list env var keys + per-file candidates (secret values masked)");
|
|
592
|
+
console.error(" --rescan-env rescan the repo for env vars");
|
|
593
|
+
console.error(" --alias <newAlias> rename the project");
|
|
594
|
+
console.error("Target project: --project <alias|id>, or a .niro-project pin in this folder.");
|
|
595
|
+
console.error("Env actions target --repo <gitId> (auto-selected when the project has one repo).");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// resolveRepo is exported for unit testing the multi-repo folder-matching logic.
|
|
599
|
+
// fetchPreview/buildCandidates/setEnvFrom are exported for tests and for reuse by
|
|
600
|
+
// init's env-var prompt (source-file parity with the web UI).
|
|
601
|
+
module.exports = { edit, resolveRepo, fetchPreview, buildCandidates, setEnvFrom, setActiveFiles, summarizeEnvVars, listEnv };
|