@keynv/cli 0.1.0-rc.17 → 0.1.0-rc.19

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/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'module';
3
3
  import { createHash, randomBytes } from 'crypto';
4
- import { readFileSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, mkdirSync, rmSync, lstatSync } from 'fs';
4
+ import { readFileSync, existsSync, writeFileSync, statSync, realpathSync, readdirSync, renameSync, mkdirSync, rmSync, lstatSync } from 'fs';
5
5
  import { hostname, platform, homedir } from 'os';
6
6
  import * as nodePath from 'path';
7
- import { relative, join, isAbsolute, resolve, dirname, basename } from 'path';
7
+ import { relative, join, isAbsolute, resolve, sep, dirname, basename } from 'path';
8
8
  import { Entry } from '@napi-rs/keyring';
9
9
  import { styleText, stripVTControlCharacters } from 'util';
10
10
  import j2, { stdin, stdout } from 'process';
@@ -1057,7 +1057,7 @@ var require_lib = __commonJS({
1057
1057
  var VERSION, AGENT;
1058
1058
  var init_version = __esm({
1059
1059
  "src/version.ts"() {
1060
- VERSION = "0.1.0-rc.17" ;
1060
+ VERSION = "0.1.0-rc.19" ;
1061
1061
  AGENT = `keynv-cli/${VERSION}`;
1062
1062
  }
1063
1063
  });
@@ -5778,6 +5778,13 @@ var init_http = __esm({
5778
5778
  Check the server is running: curl ${serverUrl}/v1/health`
5779
5779
  );
5780
5780
  }
5781
+ } else {
5782
+ this.creds = null;
5783
+ throw clientError(
5784
+ 401,
5785
+ "auth.session_expired",
5786
+ "Session expired. Run `keynv` to reconnect."
5787
+ );
5781
5788
  }
5782
5789
  }
5783
5790
  if (res.status === 204) return void 0;
@@ -5978,11 +5985,13 @@ var init_envFile = __esm({
5978
5985
  function aiContextBody() {
5979
5986
  return `## keynv (secrets)
5980
5987
 
5981
- This project uses [keynv](https://keynv.dev) for secrets. There is a \`.keynv.env\` file at the project root containing **alias references** like \`OPENAI_API_KEY=@projectname.dev.openai-key\` \u2014 these are NOT real values, they are references to secrets stored in the keynv vault.
5988
+ This project uses [keynv](https://keynv.dev) for secrets. \`.keynv.env\` files contain **alias references** like \`OPENAI_API_KEY=@projectname.dev.openai-key\` \u2014 these are NOT real values, they are references to secrets stored in the keynv vault.
5989
+
5990
+ In a monorepo, expect **one \`.keynv.env\` per app directory** (e.g. \`apps/api/.keynv.env\`, \`apps/web/.keynv.env\`) next to the original \`.env\` file that was migrated. \`keynv exec\` walks upward from the current directory and loads the nearest one, so running it from inside an app's folder picks up that app's aliases.
5982
5991
 
5983
5992
  ### Mental model
5984
5993
 
5985
- - **\`.keynv.env\`** \u2014 checked into git, contains aliases. Safe to read, edit, commit.
5994
+ - **\`.keynv.env\`** \u2014 checked into git, contains aliases. Safe to read, edit, commit. May appear in subdirectories of a monorepo.
5986
5995
  - **Vault** \u2014 holds the real values. The \`keynv\` CLI reads it on demand.
5987
5996
  - **\`keynv exec -- <command>\`** \u2014 forks the command in a subprocess where the real values are injected into env vars. The parent process (where you, the AI agent, run) NEVER sees the real values.
5988
5997
  - **Redactor** \u2014 pipes subprocess stdout/stderr through a masker before they reach this terminal. Even if a script accidentally echoes a secret, you see \`<REDACTED:...>\` here.
@@ -6050,9 +6059,9 @@ ${block}
6050
6059
  writeFileSync(path, next);
6051
6060
  return "updated";
6052
6061
  }
6053
- const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
6062
+ const sep2 = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
6054
6063
  const trailingNewline = existing.endsWith("\n") ? "" : "\n";
6055
- writeFileSync(path, `${existing}${trailingNewline}${sep}${block}
6064
+ writeFileSync(path, `${existing}${trailingNewline}${sep2}${block}
6056
6065
  `);
6057
6066
  return "appended";
6058
6067
  }
@@ -6064,6 +6073,175 @@ var init_ai_context = __esm({
6064
6073
  KEYNV_BLOCK_END = "<!-- keynv:end -->";
6065
6074
  }
6066
6075
  });
6076
+ function backupEnvFile(absolutePath, now = /* @__PURE__ */ new Date()) {
6077
+ const plain = `${absolutePath}.backup`;
6078
+ if (!existsSync(plain)) {
6079
+ renameSync(absolutePath, plain);
6080
+ return { renamedTo: plain, usedTimestamp: false };
6081
+ }
6082
+ const stamped = `${absolutePath}.backup-${timestampSlug(now)}`;
6083
+ renameSync(absolutePath, stamped);
6084
+ return { renamedTo: stamped, usedTimestamp: true };
6085
+ }
6086
+ function timestampSlug(d2 = /* @__PURE__ */ new Date()) {
6087
+ const y = d2.getFullYear();
6088
+ const mo = pad2(d2.getMonth() + 1);
6089
+ const da = pad2(d2.getDate());
6090
+ const h2 = pad2(d2.getHours());
6091
+ const mi = pad2(d2.getMinutes());
6092
+ return `${y}${mo}${da}-${h2}${mi}`;
6093
+ }
6094
+ function pad2(n) {
6095
+ return n < 10 ? `0${n}` : String(n);
6096
+ }
6097
+ var init_backup = __esm({
6098
+ "src/init/backup.ts"() {
6099
+ }
6100
+ });
6101
+
6102
+ // src/init/collision.ts
6103
+ function planVaultKeys(sources) {
6104
+ const byDir = /* @__PURE__ */ new Map();
6105
+ const shadowedAccum = /* @__PURE__ */ new Map();
6106
+ for (const s of sources) {
6107
+ const dirKey = `${s.envName}|${s.file.containingDir}`;
6108
+ let perKey = byDir.get(dirKey);
6109
+ if (!perKey) {
6110
+ perKey = /* @__PURE__ */ new Map();
6111
+ byDir.set(dirKey, perKey);
6112
+ }
6113
+ const prior = perKey.get(s.name);
6114
+ if (prior) {
6115
+ const shadowKey = `${s.envName}|${s.file.containingDir}|${s.name}`;
6116
+ let note = shadowedAccum.get(shadowKey);
6117
+ if (!note) {
6118
+ note = {
6119
+ envName: s.envName,
6120
+ localKey: s.name,
6121
+ containingDir: s.file.containingDir,
6122
+ laterFile: s.file.name,
6123
+ earlierFiles: [prior.source.name]
6124
+ };
6125
+ shadowedAccum.set(shadowKey, note);
6126
+ } else {
6127
+ note.earlierFiles.push(note.laterFile);
6128
+ note.laterFile = s.file.name;
6129
+ }
6130
+ }
6131
+ perKey.set(s.name, {
6132
+ envName: s.envName,
6133
+ containingDir: s.file.containingDir,
6134
+ localKey: s.name,
6135
+ value: s.value,
6136
+ isAlias: s.isAlias,
6137
+ source: s.file,
6138
+ line: s.line
6139
+ });
6140
+ }
6141
+ const intraResolved = [];
6142
+ for (const perKey of byDir.values()) intraResolved.push(...perKey.values());
6143
+ const groups = /* @__PURE__ */ new Map();
6144
+ for (const e2 of intraResolved) {
6145
+ const k2 = `${e2.envName}|${e2.localKey}`;
6146
+ let g = groups.get(k2);
6147
+ if (!g) {
6148
+ g = [];
6149
+ groups.set(k2, g);
6150
+ }
6151
+ g.push(e2);
6152
+ }
6153
+ const resolved = [];
6154
+ const renamed = [];
6155
+ const merged = [];
6156
+ for (const group of groups.values()) {
6157
+ if (group.length === 1) {
6158
+ const e2 = group[0];
6159
+ resolved.push({
6160
+ envName: e2.envName,
6161
+ localKey: e2.localKey,
6162
+ value: e2.value,
6163
+ isAlias: e2.isAlias,
6164
+ vaultKey: toAliasKey(e2.localKey),
6165
+ source: e2.source,
6166
+ line: e2.line
6167
+ });
6168
+ continue;
6169
+ }
6170
+ const allSameValue = group.every((e2) => e2.value === group[0]?.value);
6171
+ if (allSameValue) {
6172
+ const vaultKey = toAliasKey(group[0].localKey);
6173
+ merged.push({
6174
+ envName: group[0].envName,
6175
+ key: group[0].localKey,
6176
+ sources: group.map((e2) => e2.source)
6177
+ });
6178
+ for (const e2 of group) {
6179
+ resolved.push({
6180
+ envName: e2.envName,
6181
+ localKey: e2.localKey,
6182
+ value: e2.value,
6183
+ isAlias: e2.isAlias,
6184
+ vaultKey,
6185
+ source: e2.source,
6186
+ line: e2.line
6187
+ });
6188
+ }
6189
+ continue;
6190
+ }
6191
+ const initialSlugs = group.map((e2) => initialSlug(e2.source));
6192
+ const initialUnique = new Set(initialSlugs).size === initialSlugs.length;
6193
+ const slugs = initialUnique ? initialSlugs : group.map((e2) => fullSlug(e2.source));
6194
+ for (let i = 0; i < group.length; i++) {
6195
+ const e2 = group[i];
6196
+ const slug = slugs[i];
6197
+ const vaultKey = toAliasKey(`${slug}-${e2.localKey}`);
6198
+ renamed.push({
6199
+ envName: e2.envName,
6200
+ localKey: e2.localKey,
6201
+ vaultKey,
6202
+ source: e2.source,
6203
+ otherSources: group.filter((_, j3) => j3 !== i).map((g) => g.source)
6204
+ });
6205
+ resolved.push({
6206
+ envName: e2.envName,
6207
+ localKey: e2.localKey,
6208
+ value: e2.value,
6209
+ isAlias: e2.isAlias,
6210
+ vaultKey,
6211
+ source: e2.source,
6212
+ line: e2.line
6213
+ });
6214
+ }
6215
+ }
6216
+ return {
6217
+ resolved,
6218
+ renamed,
6219
+ merged,
6220
+ shadowed: [...shadowedAccum.values()]
6221
+ };
6222
+ }
6223
+ function initialSlug(file) {
6224
+ if (file.relativeDir === "") return "root";
6225
+ const parts = file.relativeDir.split("/");
6226
+ return (parts[parts.length - 1] ?? "root").toLowerCase();
6227
+ }
6228
+ function fullSlug(file) {
6229
+ if (file.relativeDir === "") return "root";
6230
+ return file.relativeDir.toLowerCase().replace(/\//g, "-");
6231
+ }
6232
+ function toAliasKey(name) {
6233
+ if (!name) return name;
6234
+ if (KEY_RE3.test(name)) return name;
6235
+ const normalised = name.toLowerCase().replace(/_/g, "-");
6236
+ if (KEY_RE3.test(normalised)) return normalised;
6237
+ return normalised.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "key";
6238
+ }
6239
+ var KEY_RE3;
6240
+ var init_collision = __esm({
6241
+ "src/init/collision.ts"() {
6242
+ KEY_RE3 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
6243
+ }
6244
+ });
6067
6245
  function findProjectRoot(startDir) {
6068
6246
  const result = walkUp(startDir, (dir) => {
6069
6247
  for (const marker of PROJECT_MARKERS) {
@@ -6106,31 +6284,86 @@ function buildRoot(dir, marker) {
6106
6284
  packageJsonInvalid: invalid
6107
6285
  };
6108
6286
  }
6109
- function findEnvFiles(rootDir) {
6110
- let entries;
6287
+ function findEnvFilesRecursive(rootDir, opts = {}) {
6288
+ const maxDepth = Math.max(1, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
6289
+ const ignore = opts.ignore ?? IGNORE_DIRS;
6290
+ let rootReal;
6111
6291
  try {
6112
- entries = readdirSync(rootDir);
6292
+ rootReal = realpathSync(rootDir);
6113
6293
  } catch {
6114
6294
  return [];
6115
6295
  }
6116
6296
  const hits = [];
6117
- for (const name of entries) {
6118
- if (!ENV_GLOB.test(name)) continue;
6119
- if (ENV_EXAMPLE.test(name)) continue;
6120
- if (name === KEYNV_ENV_BASENAME) continue;
6121
- const full = join(rootDir, name);
6297
+ const seen = /* @__PURE__ */ new Set([rootReal]);
6298
+ const queue = [{ dir: rootDir, depth: 0 }];
6299
+ while (queue.length > 0) {
6300
+ const current = queue.shift();
6301
+ if (!current) break;
6302
+ const { dir, depth } = current;
6303
+ let entries;
6122
6304
  try {
6123
- if (!statSync(full).isFile()) continue;
6305
+ const raw = readdirSync(dir, { withFileTypes: true });
6306
+ entries = raw.map((d2) => ({
6307
+ name: d2.name,
6308
+ isFile: d2.isFile(),
6309
+ isDir: d2.isDirectory(),
6310
+ isSymlink: d2.isSymbolicLink()
6311
+ }));
6124
6312
  } catch {
6125
6313
  continue;
6126
6314
  }
6127
- const suffixMatch = name.match(/^\.env\.(.+)$/);
6128
- const suffix = suffixMatch ? suffixMatch[1] : null;
6129
- hits.push({ path: full, name, suffix, suggestedEnv: suggestedEnvForSuffix(suffix) });
6315
+ for (const entry2 of entries) {
6316
+ const full = join(dir, entry2.name);
6317
+ let isFile = entry2.isFile;
6318
+ let isDir = entry2.isDir;
6319
+ if (entry2.isSymlink) {
6320
+ try {
6321
+ const st3 = statSync(full);
6322
+ isFile = st3.isFile();
6323
+ if (st3.isDirectory()) isDir = false;
6324
+ } catch {
6325
+ continue;
6326
+ }
6327
+ }
6328
+ if (isFile) {
6329
+ if (!ENV_GLOB.test(entry2.name)) continue;
6330
+ if (ENV_EXAMPLE.test(entry2.name)) continue;
6331
+ if (KEYNV_ENV_EXCLUDE.test(entry2.name)) continue;
6332
+ const suffixMatch = entry2.name.match(/^\.env\.(.+)$/);
6333
+ const suffix = suffixMatch ? suffixMatch[1] : null;
6334
+ const rel = relative(rootDir, dir);
6335
+ const relativeDir = rel === "" ? "" : rel.split(sep).join("/");
6336
+ hits.push({
6337
+ path: full,
6338
+ name: entry2.name,
6339
+ suffix,
6340
+ suggestedEnv: suggestedEnvForSuffix(suffix),
6341
+ relativeDir,
6342
+ containingDir: dir
6343
+ });
6344
+ continue;
6345
+ }
6346
+ if (isDir && depth + 1 < maxDepth) {
6347
+ if (ignore.has(entry2.name)) continue;
6348
+ let realDir;
6349
+ try {
6350
+ realDir = realpathSync(full);
6351
+ } catch {
6352
+ continue;
6353
+ }
6354
+ if (seen.has(realDir)) continue;
6355
+ seen.add(realDir);
6356
+ queue.push({ dir: full, depth: depth + 1 });
6357
+ }
6358
+ }
6130
6359
  }
6131
6360
  hits.sort((a, b2) => {
6132
- if (a.suffix === null) return -1;
6133
- if (b2.suffix === null) return 1;
6361
+ const aRoot = a.relativeDir === "";
6362
+ const bRoot = b2.relativeDir === "";
6363
+ if (aRoot !== bRoot) return aRoot ? -1 : 1;
6364
+ if (a.relativeDir !== b2.relativeDir) return a.relativeDir.localeCompare(b2.relativeDir);
6365
+ if (a.suffix === null && b2.suffix !== null) return -1;
6366
+ if (a.suffix !== null && b2.suffix === null) return 1;
6134
6367
  return a.name.localeCompare(b2.name);
6135
6368
  });
6136
6369
  return hits;
@@ -6159,7 +6392,7 @@ function suggestedEnvForSuffix(suffix) {
6159
6392
  function hasExistingKeynvEnv(rootDir) {
6160
6393
  return existsSync(join(rootDir, KEYNV_ENV_BASENAME));
6161
6394
  }
6162
- var PROJECT_MARKERS, GIT_MARKER, ENV_GLOB, ENV_EXAMPLE, KEYNV_ENV_BASENAME;
6395
+ var PROJECT_MARKERS, GIT_MARKER, ENV_GLOB, ENV_EXAMPLE, KEYNV_ENV_BASENAME, KEYNV_ENV_EXCLUDE, IGNORE_DIRS, DEFAULT_MAX_DEPTH;
6163
6396
  var init_detect = __esm({
6164
6397
  "src/init/detect.ts"() {
6165
6398
  init_fs();
@@ -6177,6 +6410,74 @@ var init_detect = __esm({
6177
6410
  ENV_GLOB = /^\.env(\.[A-Za-z0-9_-]+)?$/;
6178
6411
  ENV_EXAMPLE = /^\.env\.(example|sample|template|dist|defaults)$/;
6179
6412
  KEYNV_ENV_BASENAME = ".keynv.env";
6413
+ KEYNV_ENV_EXCLUDE = /^\.keynv\.(.+\.)?env$/;
6414
+ IGNORE_DIRS = /* @__PURE__ */ new Set([
6415
+ // VCS + git hooks
6416
+ ".git",
6417
+ ".husky",
6418
+ // Package managers / dep caches
6419
+ "node_modules",
6420
+ ".yarn",
6421
+ // Yarn Berry / PnP cache
6422
+ "bower_components",
6423
+ // Generic build / output / coverage dirs
6424
+ "dist",
6425
+ "build",
6426
+ "out",
6427
+ "coverage",
6428
+ ".cache",
6429
+ ".nyc_output",
6430
+ // Rust / Go / PHP
6431
+ "target",
6432
+ "vendor",
6433
+ // JS / TS frameworks
6434
+ ".next",
6435
+ // Next.js
6436
+ ".turbo",
6437
+ // Turborepo
6438
+ ".nuxt",
6439
+ // Nuxt 2/3
6440
+ ".output",
6441
+ // Nitro / Nuxt 3 build output
6442
+ ".svelte-kit",
6443
+ // SvelteKit
6444
+ ".astro",
6445
+ // Astro
6446
+ ".angular",
6447
+ // Angular CLI cache
6448
+ ".parcel-cache",
6449
+ // Parcel bundler
6450
+ ".docusaurus",
6451
+ // Docusaurus
6452
+ ".expo",
6453
+ // Expo / React Native
6454
+ // Deployment / serverless platforms
6455
+ ".vercel",
6456
+ ".netlify",
6457
+ ".wrangler",
6458
+ // Cloudflare Workers
6459
+ ".serverless",
6460
+ // Serverless framework
6461
+ ".sst",
6462
+ // SST
6463
+ ".amplify",
6464
+ // AWS Amplify
6465
+ ".firebase",
6466
+ // Firebase
6467
+ // Python tooling (mixed-language monorepos)
6468
+ ".venv",
6469
+ "venv",
6470
+ "__pycache__",
6471
+ ".tox",
6472
+ ".mypy_cache",
6473
+ ".pytest_cache",
6474
+ ".ruff_cache",
6475
+ // IDE / editor state
6476
+ ".idea",
6477
+ ".vscode",
6478
+ ".fleet"
6479
+ ]);
6480
+ DEFAULT_MAX_DEPTH = 5;
6180
6481
  }
6181
6482
  });
6182
6483
 
@@ -8594,15 +8895,16 @@ var init_pickProject = __esm({
8594
8895
  var init_exports = {};
8595
8896
  __export(init_exports, {
8596
8897
  UserCancelled: () => UserCancelled,
8898
+ composeKeynvEnv: () => composeKeynvEnv,
8597
8899
  runInitFlow: () => runInitFlow
8598
8900
  });
8599
- function toAliasKey(name) {
8600
- if (!name) return name;
8601
- const KEY_RE3 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8602
- if (KEY_RE3.test(name)) return name;
8603
- const normalised = name.toLowerCase().replace(/_/g, "-");
8604
- if (KEY_RE3.test(normalised)) return normalised;
8605
- return normalised.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "key";
8901
+ function displayName(file) {
8902
+ return file.relativeDir === "" ? file.name : `${file.relativeDir}/${file.name}`;
8903
+ }
8904
+ function relFromRoot(rootPath, absPath) {
8905
+ const r = relative(rootPath, absPath);
8906
+ if (r === "") return ".";
8907
+ return r.split(/[\\/]/).filter(Boolean).join("/");
8606
8908
  }
8607
8909
  async function runInitFlow(client, opts) {
8608
8910
  ge("Set up this project");
@@ -8616,11 +8918,11 @@ async function runInitFlow(client, opts) {
8616
8918
  if (root.packageJsonInvalid) {
8617
8919
  R2.warn(`package.json at ${root.path} is not valid JSON \u2014 script wrapping will be skipped.`);
8618
8920
  }
8619
- const envFiles = findEnvFiles(root.path);
8921
+ const envFiles = findEnvFilesRecursive(root.path);
8620
8922
  const intoExisting = hasExistingKeynvEnv(root.path);
8621
8923
  if (envFiles.length === 0 && !intoExisting) {
8622
8924
  R2.info(
8623
- `No .env files found in ${root.path}. There's nothing to migrate yet \u2014 create a .keynv.env by hand or run \`keynv exec\` once you have one.`
8925
+ `No .env files found in ${root.path} (scanned root and subdirectories). There's nothing to migrate yet \u2014 create a .keynv.env by hand or run \`keynv exec\` once you have one.`
8624
8926
  );
8625
8927
  ye("Nothing to do.");
8626
8928
  return { exitCode: 0 };
@@ -8629,8 +8931,9 @@ async function runInitFlow(client, opts) {
8629
8931
  [
8630
8932
  `Project root: ${root.path}`,
8631
8933
  `Marker: ${root.marker}`,
8632
- envFiles.length > 0 ? `Found env files: ${envFiles.map((f) => f.name).join(", ")}` : "Found env files: (none)",
8633
- intoExisting ? "Existing .keynv.env detected \u2014 will merge new entries in." : ""
8934
+ envFiles.length > 0 ? `Found env files:
8935
+ ${envFiles.map((f) => ` ${displayName(f)}`).join("\n")}` : "Found env files: (none)",
8936
+ intoExisting ? "Existing root .keynv.env detected \u2014 will merge new entries in." : ""
8634
8937
  ].filter(Boolean).join("\n"),
8635
8938
  "Detected"
8636
8939
  );
@@ -8645,62 +8948,118 @@ async function runInitFlow(client, opts) {
8645
8948
  return { exitCode: 130 };
8646
8949
  }
8647
8950
  const distinctEnvs = [...new Set(fileMapping.map((m) => m.envName))];
8648
- const perEnv = parseAndMergePerEnv(fileMapping);
8951
+ const allSources = [];
8649
8952
  const skipped = [];
8650
- for (const env2 of distinctEnvs) {
8651
- const before = perEnv.get(env2) ?? [];
8652
- const kept = [];
8653
- for (const e2 of before) {
8953
+ for (const { file, envName } of fileMapping) {
8954
+ let parsed;
8955
+ try {
8956
+ parsed = parseEnvFile(readFileSync(file.path, "utf8"), file.path);
8957
+ } catch (err) {
8958
+ R2.warn(
8959
+ `${displayName(file)}: ${err instanceof Error ? err.message : String(err)} \u2014 skipping file`
8960
+ );
8961
+ continue;
8962
+ }
8963
+ for (const e2 of parsed) {
8654
8964
  if (classifyEntry(e2.name, e2.value).verdict === "skip") {
8655
- skipped.push({ env: env2, entry: e2 });
8656
- } else {
8657
- kept.push(e2);
8965
+ skipped.push({ env: envName, name: e2.name });
8966
+ continue;
8658
8967
  }
8968
+ allSources.push({
8969
+ file,
8970
+ envName,
8971
+ name: e2.name,
8972
+ value: e2.value,
8973
+ isAlias: e2.isAlias,
8974
+ line: e2.line
8975
+ });
8659
8976
  }
8660
- perEnv.set(env2, kept);
8661
8977
  }
8662
8978
  if (skipped.length > 0) {
8663
- const names = [...new Set(skipped.map((s) => s.entry.name))];
8979
+ const names = [...new Set(skipped.map((s) => s.name))];
8664
8980
  R2.info(
8665
8981
  `Skipped ${skipped.length} framework/shell-managed entr${skipped.length === 1 ? "y" : "ies"}: ${names.join(", ")}`
8666
8982
  );
8667
8983
  }
8668
- for (const env2 of distinctEnvs) {
8669
- const shadowed = (perEnv.get(env2) ?? []).filter((e2) => e2.shadowedBy.length > 0);
8670
- if (shadowed.length === 0) continue;
8671
- const detail = shadowed.map((e2) => ` [${env2}] ${e2.name}: ${e2.source} \u2192 ${e2.shadowedBy[e2.shadowedBy.length - 1]}`).join("\n");
8984
+ const plan = planVaultKeys(allSources);
8985
+ if (plan.shadowed.length > 0) {
8986
+ const detail = plan.shadowed.map(
8987
+ (s) => ` [${s.envName}] ${relFromRoot(root.path, s.containingDir)}/${s.localKey}: ${s.earlierFiles.join(", ")} -> ${s.laterFile}`
8988
+ ).join("\n");
8989
+ R2.warn(
8990
+ `Some keys appear in multiple env files in the same directory mapped to the same env; using the last value (dotenv convention):
8991
+ ${detail}`
8992
+ );
8993
+ }
8994
+ if (plan.merged.length > 0) {
8995
+ const detail = plan.merged.map(
8996
+ (m) => ` [${m.envName}] ${m.key}: same value across ${m.sources.map((s) => displayName(s)).join(", ")} -> one vault entry shared`
8997
+ ).join("\n");
8998
+ R2.info(`Merged cross-app duplicates into a single vault entry:
8999
+ ${detail}`);
9000
+ }
9001
+ if (plan.renamed.length > 0) {
9002
+ const byLocal = /* @__PURE__ */ new Map();
9003
+ for (const r of plan.renamed) {
9004
+ const k2 = `${r.envName}|${r.localKey}`;
9005
+ const arr = byLocal.get(k2) ?? [];
9006
+ arr.push(r);
9007
+ byLocal.set(k2, arr);
9008
+ }
9009
+ const detail = [...byLocal.values()].map((group) => {
9010
+ const head = group[0];
9011
+ if (!head) return "";
9012
+ const sources = group.map((r) => `${displayName(r.source)} -> @vault:${r.vaultKey}`).join("\n ");
9013
+ return ` [${head.envName}] ${head.localKey} (values differ across apps):
9014
+ ${sources}`;
9015
+ }).filter(Boolean).join("\n");
8672
9016
  R2.warn(
8673
- `Some keys appear in multiple env files mapped to the same env; using the last value (dotenv convention):
9017
+ `Vault keys were renamed to avoid cross-app collisions (your code keeps using the original names locally):
8674
9018
  ${detail}`
8675
9019
  );
8676
9020
  }
8677
- const totalEntries = [...perEnv.values()].reduce((n, arr) => n + arr.length, 0);
8678
- if (totalEntries === 0) {
9021
+ if (plan.resolved.length === 0) {
8679
9022
  R2.info(
8680
9023
  "All env files were empty or only contained framework-managed vars. Nothing to upload."
8681
9024
  );
8682
9025
  ye("Done.");
8683
9026
  return { exitCode: 0 };
8684
9027
  }
8685
- const choices = [];
8686
- for (const env2 of distinctEnvs) {
8687
- for (const e2 of perEnv.get(env2) ?? []) {
8688
- const c2 = classifyEntry(e2.name, e2.value);
8689
- const hint = c2.hint || (e2.isAlias ? "looks like an alias literal" : "no signal");
8690
- const preview2 = e2.isAlias ? e2.value : previewValue(e2.value, 28);
8691
- const envTag = distinctEnvs.length > 1 ? `[${env2}] ` : "";
8692
- choices.push({
8693
- composite: `${env2}|${e2.name}`,
8694
- env: env2,
8695
- name: e2.name,
8696
- value: e2.value,
8697
- isAlias: e2.isAlias,
9028
+ const groups = /* @__PURE__ */ new Map();
9029
+ for (const r of plan.resolved) {
9030
+ const composite = `${r.envName}|${r.vaultKey}`;
9031
+ let g = groups.get(composite);
9032
+ if (!g) {
9033
+ const c2 = classifyEntry(r.localKey, r.value);
9034
+ const hint = c2.hint || (r.isAlias ? "looks like an alias literal" : "no signal");
9035
+ const preview2 = r.isAlias ? r.value : previewValue(r.value, 28);
9036
+ const envTag = distinctEnvs.length > 1 ? `[${r.envName}] ` : "";
9037
+ const renamedTag = r.localKey !== r.vaultKey ? ` -> vault:${r.vaultKey}` : "";
9038
+ g = {
9039
+ composite,
9040
+ envName: r.envName,
9041
+ vaultKey: r.vaultKey,
9042
+ localKey: r.localKey,
9043
+ value: r.value,
9044
+ isAlias: r.isAlias,
8698
9045
  verdict: c2.verdict,
8699
- label: `${envTag}${e2.name} ${preview2}`,
8700
- hint
8701
- });
9046
+ label: `${envTag}${r.localKey}${renamedTag} ${preview2}`,
9047
+ hint,
9048
+ sources: []
9049
+ };
9050
+ groups.set(composite, g);
8702
9051
  }
9052
+ g.sources.push(r);
8703
9053
  }
9054
+ for (const g of groups.values()) {
9055
+ const dirs = [...new Set(g.sources.map((s) => s.source.relativeDir || "<root>"))];
9056
+ if (dirs.length > 1) g.label = `${g.label} (shared by ${dirs.join(", ")})`;
9057
+ else if (envFiles.some((f) => f.relativeDir !== "")) {
9058
+ const only = dirs[0];
9059
+ if (only && only !== "<root>") g.label = `${g.label} [${only}]`;
9060
+ }
9061
+ }
9062
+ const choices = [...groups.values()];
8704
9063
  const initialSecretSelection = choices.filter((c2) => c2.verdict === "secret" && !c2.isAlias).map((c2) => c2.composite);
8705
9064
  const selectedComposites = unwrap(
8706
9065
  await ve({
@@ -8722,18 +9081,24 @@ ${detail}`
8722
9081
  scriptWrapSelection = scriptPlan.recommended.map((a) => a.name);
8723
9082
  }
8724
9083
  const perEnvCounts = distinctEnvs.map((env2) => {
8725
- const entries = perEnv.get(env2) ?? [];
8726
- const sec = entries.filter((e2) => selected.has(`${env2}|${e2.name}`)).length;
8727
- const lit = entries.length - sec;
8728
- return ` ${env2}: ${sec} secrets, ${lit} literals${env2 === defaultEnv ? " (default \u2014 written to .keynv.env)" : ""}`;
9084
+ const envGroups = choices.filter((g) => g.envName === env2);
9085
+ const sec = envGroups.filter((g) => selected.has(g.composite)).length;
9086
+ const lit = envGroups.length - sec;
9087
+ return ` ${env2}: ${sec} secrets, ${lit} literals${env2 === defaultEnv ? " (default \u2014 written to each app's .keynv.env)" : ""}`;
8729
9088
  }).join("\n");
9089
+ const writeDirs = [...new Set(plan.resolved.map((r) => r.source.containingDir))];
9090
+ const writeDirsLines = writeDirs.map((d2) => ` ${relFromRoot(root.path, d2)}`).join("\n");
9091
+ const renameLine = plan.renamed.length > 0 ? `Renamed vault keys: ${plan.renamed.length} (to avoid cross-app collisions)` : "";
8730
9092
  const planSummary = [
8731
9093
  `Project: ${projectChoice.name}${projectChoice.created ? " (will be created)" : ""}`,
8732
9094
  `Environments: ${distinctEnvs.join(", ")}`,
8733
9095
  "Per-env breakdown:",
8734
9096
  perEnvCounts,
8735
9097
  `Script wraps: ${scriptWrapSelection.length}`,
8736
- "Original .env: delete after upload",
9098
+ `.keynv.env files: will be written under`,
9099
+ writeDirsLines,
9100
+ "Original .env files: rename to .env.backup after upload",
9101
+ renameLine,
8737
9102
  opts.dryRun ? "Dry-run: no changes will be made." : ""
8738
9103
  ].filter(Boolean).join("\n");
8739
9104
  Se(planSummary, "About to apply");
@@ -8748,100 +9113,98 @@ ${detail}`
8748
9113
  }
8749
9114
  const projectId2 = await ensureProjectAndEnvs(client, projectChoice, distinctEnvs);
8750
9115
  if (projectId2 === null) return { exitCode: 1 };
8751
- const uploadedByEnv = /* @__PURE__ */ new Map();
9116
+ const aliasByGroup = /* @__PURE__ */ new Map();
8752
9117
  const failed = [];
8753
- const totalToUpload = selected.size;
8754
- if (totalToUpload > 0) {
9118
+ const groupsToUpload = choices.filter((g) => selected.has(g.composite));
9119
+ if (groupsToUpload.length > 0) {
8755
9120
  const s = ft();
8756
- s.start(`Uploading ${totalToUpload} secret${totalToUpload === 1 ? "" : "s"}`);
9121
+ s.start(
9122
+ `Uploading ${groupsToUpload.length} secret${groupsToUpload.length === 1 ? "" : "s"}`
9123
+ );
8757
9124
  let i = 0;
8758
- for (const env2 of distinctEnvs) {
8759
- const envUploaded = /* @__PURE__ */ new Map();
8760
- uploadedByEnv.set(env2, envUploaded);
8761
- for (const e2 of perEnv.get(env2) ?? []) {
8762
- if (!selected.has(`${env2}|${e2.name}`)) continue;
8763
- i++;
8764
- s.message(`Uploading (${i}/${totalToUpload}) [${env2}] ${e2.name}`);
8765
- const aliasKey = toAliasKey(e2.name);
8766
- try {
8767
- await client.request(`/v1/projects/${projectId2}/secrets`, {
8768
- method: "POST",
8769
- body: { env: env2, key: aliasKey, value: e2.value }
8770
- });
8771
- const alias2 = reference_exports.buildAlias({
8772
- project: projectChoice.name,
8773
- environment: env2,
8774
- key: aliasKey
8775
- });
8776
- if (alias2 === null) {
8777
- failed.push({
8778
- env: env2,
8779
- name: e2.name,
8780
- reason: `produced an invalid alias for project=${projectChoice.name} env=${env2} key=${aliasKey}`
8781
- });
8782
- } else {
8783
- envUploaded.set(e2.name, alias2.literal);
8784
- }
8785
- } catch (err) {
9125
+ for (const g of groupsToUpload) {
9126
+ i++;
9127
+ s.message(`Uploading (${i}/${groupsToUpload.length}) [${g.envName}] ${g.vaultKey}`);
9128
+ try {
9129
+ await client.request(`/v1/projects/${projectId2}/secrets`, {
9130
+ method: "POST",
9131
+ body: { env: g.envName, key: g.vaultKey, value: g.value }
9132
+ });
9133
+ const alias2 = reference_exports.buildAlias({
9134
+ project: projectChoice.name,
9135
+ environment: g.envName,
9136
+ key: g.vaultKey
9137
+ });
9138
+ if (alias2 === null) {
8786
9139
  failed.push({
8787
- env: env2,
8788
- name: e2.name,
8789
- reason: err instanceof Error ? err.message : String(err)
9140
+ env: g.envName,
9141
+ name: g.localKey,
9142
+ reason: `produced an invalid alias for project=${projectChoice.name} env=${g.envName} key=${g.vaultKey}`
8790
9143
  });
9144
+ } else {
9145
+ aliasByGroup.set(g.composite, alias2.literal);
8791
9146
  }
9147
+ } catch (err) {
9148
+ failed.push({
9149
+ env: g.envName,
9150
+ name: g.localKey,
9151
+ reason: err instanceof Error ? err.message : String(err)
9152
+ });
8792
9153
  }
8793
9154
  }
8794
9155
  if (failed.length === 0) {
8795
- s.stop(`Uploaded ${totalToUpload} secret${totalToUpload === 1 ? "" : "s"}`);
9156
+ s.stop(`Uploaded ${groupsToUpload.length} secret${groupsToUpload.length === 1 ? "" : "s"}`);
8796
9157
  } else {
8797
9158
  s.error(
8798
- `${totalToUpload - failed.length}/${totalToUpload} uploaded; ${failed.length} failed`
9159
+ `${groupsToUpload.length - failed.length}/${groupsToUpload.length} uploaded; ${failed.length} failed`
8799
9160
  );
8800
9161
  for (const f of failed) R2.warn(` [${f.env}] ${f.name}: ${f.reason}`);
8801
9162
  }
8802
9163
  }
8803
- const defaultUploaded = uploadedByEnv.get(defaultEnv) ?? /* @__PURE__ */ new Map();
8804
- const defaultLiterals = (perEnv.get(defaultEnv) ?? []).filter(
8805
- (e2) => !selected.has(`${defaultEnv}|${e2.name}`)
8806
- );
8807
- const keynvEnvPath = join(root.path, ".keynv.env");
8808
- try {
8809
- const lines = composeKeynvEnv({
8810
- uploadedAliases: defaultUploaded,
8811
- literals: defaultLiterals,
8812
- mergeWithExisting: intoExisting ? readFileSync(keynvEnvPath, "utf8") : null
8813
- });
8814
- writeFileSync(keynvEnvPath, `${lines.join("\n")}
8815
- `);
8816
- R2.success(
8817
- `${intoExisting ? "Updated" : "Wrote"} ${keynvEnvPath} (${defaultUploaded.size + defaultLiterals.length} entries from "${defaultEnv}")`
8818
- );
8819
- } catch (err) {
8820
- R2.error(`Failed to write .keynv.env: ${err instanceof Error ? err.message : String(err)}`);
8821
- return { exitCode: 1 };
9164
+ const bucketKey = (dir, env2) => `${dir}|${env2}`;
9165
+ const buckets = /* @__PURE__ */ new Map();
9166
+ for (const r of plan.resolved) {
9167
+ const k2 = bucketKey(r.source.containingDir, r.envName);
9168
+ let b2 = buckets.get(k2);
9169
+ if (!b2) {
9170
+ b2 = {
9171
+ containingDir: r.source.containingDir,
9172
+ envName: r.envName,
9173
+ aliasLines: [],
9174
+ literalEntries: []
9175
+ };
9176
+ buckets.set(k2, b2);
9177
+ }
9178
+ const groupKey = `${r.envName}|${r.vaultKey}`;
9179
+ const alias2 = aliasByGroup.get(groupKey);
9180
+ if (selected.has(groupKey) && alias2 !== void 0) {
9181
+ b2.aliasLines.push([r.localKey, alias2]);
9182
+ } else {
9183
+ b2.literalEntries.push({ name: r.localKey, value: r.value });
9184
+ }
8822
9185
  }
8823
- const otherEnvsWithAliases = distinctEnvs.filter(
8824
- (env2) => env2 !== defaultEnv && (uploadedByEnv.get(env2)?.size ?? 0) > 0
8825
- );
8826
- for (const env2 of otherEnvsWithAliases) {
8827
- const envUploaded = uploadedByEnv.get(env2) ?? /* @__PURE__ */ new Map();
8828
- const envLiterals = (perEnv.get(env2) ?? []).filter((e2) => !selected.has(`${env2}|${e2.name}`));
8829
- const envFilePath = join(root.path, `.keynv.${env2}.env`);
9186
+ for (const b2 of buckets.values()) {
9187
+ const targetName = b2.envName === defaultEnv ? ".keynv.env" : `.keynv.${b2.envName}.env`;
9188
+ const targetPath = join(b2.containingDir, targetName);
9189
+ const dirIntoExisting = existsSync(targetPath);
8830
9190
  try {
8831
9191
  const lines = composeKeynvEnv({
8832
- uploadedAliases: envUploaded,
8833
- literals: envLiterals,
8834
- mergeWithExisting: null
9192
+ uploadedAliases: new Map(b2.aliasLines),
9193
+ literals: b2.literalEntries.map((e2) => ({ name: e2.name, value: e2.value })),
9194
+ mergeWithExisting: dirIntoExisting ? readFileSync(targetPath, "utf8") : null
8835
9195
  });
8836
- writeFileSync(envFilePath, `${lines.join("\n")}
9196
+ writeFileSync(targetPath, `${lines.join("\n")}
8837
9197
  `);
9198
+ const relTarget = relFromRoot(root.path, targetPath);
9199
+ const total = b2.aliasLines.length + b2.literalEntries.length;
8838
9200
  R2.success(
8839
- `Wrote .keynv.${env2}.env (${envUploaded.size} secret alias${envUploaded.size === 1 ? "" : "es"} from "${env2}")`
9201
+ `${dirIntoExisting ? "Updated" : "Wrote"} ${relTarget} (${total} entr${total === 1 ? "y" : "ies"} from "${b2.envName}")`
8840
9202
  );
8841
9203
  } catch (err) {
8842
- R2.warn(
8843
- `Could not write .keynv.${env2}.env: ${err instanceof Error ? err.message : String(err)}`
9204
+ R2.error(
9205
+ `Failed to write ${relFromRoot(root.path, targetPath)}: ${err instanceof Error ? err.message : String(err)}`
8844
9206
  );
9207
+ return { exitCode: 1 };
8845
9208
  }
8846
9209
  }
8847
9210
  try {
@@ -8869,19 +9232,28 @@ ${detail}`
8869
9232
  }
8870
9233
  }
8871
9234
  for (const f of envFiles) {
9235
+ const relSrc = relFromRoot(root.path, f.path);
8872
9236
  try {
8873
- unlinkSync(f.path);
8874
- R2.success(`Removed ${f.name}`);
9237
+ const { renamedTo } = backupEnvFile(f.path);
9238
+ const relTarget = relFromRoot(root.path, renamedTo);
9239
+ R2.success(`Renamed ${relSrc} -> ${relTarget}`);
8875
9240
  } catch (err) {
8876
- R2.warn(`Could not remove ${f.name}: ${err instanceof Error ? err.message : String(err)}`);
9241
+ R2.warn(`Could not rename ${relSrc}: ${err instanceof Error ? err.message : String(err)}`);
8877
9242
  }
8878
9243
  }
8879
9244
  const otherEnvs = distinctEnvs.filter((e2) => e2 !== defaultEnv);
8880
9245
  if (otherEnvs.length > 0) {
8881
9246
  const lines = otherEnvs.map((env2) => {
8882
- const count = uploadedByEnv.get(env2)?.size ?? 0;
8883
- const hasFile = count > 0;
8884
- return hasFile ? ` ${env2}: ${count} secret${count === 1 ? "" : "s"} \u2192 .keynv.${env2}.env (use \`keynv exec --from .keynv.${env2}.env -- <cmd>\`)` : ` ${env2}: 0 secrets in vault (no alias file written)`;
9247
+ const dirsForEnv = [
9248
+ ...new Set(
9249
+ [...buckets.values()].filter((b2) => b2.envName === env2).map((b2) => b2.containingDir)
9250
+ )
9251
+ ];
9252
+ if (dirsForEnv.length === 0) {
9253
+ return ` ${env2}: 0 secrets in vault (no alias file written)`;
9254
+ }
9255
+ const fileList = dirsForEnv.map((d2) => `${relFromRoot(root.path, d2)}/.keynv.${env2}.env`).join(", ");
9256
+ return ` ${env2}: ${fileList} (use \`keynv exec --from <file> -- <cmd>\`)`;
8885
9257
  });
8886
9258
  Se(lines.join("\n"), "Secrets in other envs");
8887
9259
  }
@@ -8946,7 +9318,7 @@ async function pickFileEnvMapping(files, project) {
8946
9318
  opts.push({ value: "__custom", label: "+ Custom env name\u2026" });
8947
9319
  let envName = unwrap(
8948
9320
  await Ee({
8949
- message: `Map ${f.name} to which keynv env?`,
9321
+ message: `Map ${displayName(f)} to which keynv env?`,
8950
9322
  options: opts,
8951
9323
  initialValue: suggested
8952
9324
  })
@@ -8963,43 +9335,6 @@ async function pickFileEnvMapping(files, project) {
8963
9335
  }
8964
9336
  return assignments;
8965
9337
  }
8966
- function parseAndMergePerEnv(mapping) {
8967
- const acc = /* @__PURE__ */ new Map();
8968
- for (const { file, envName } of mapping) {
8969
- let entries;
8970
- try {
8971
- entries = parseEnvFile(readFileSync(file.path, "utf8"), file.path);
8972
- } catch (err) {
8973
- R2.warn(`${file.name}: ${err instanceof Error ? err.message : String(err)} \u2014 skipping file`);
8974
- continue;
8975
- }
8976
- let envMap = acc.get(envName);
8977
- if (!envMap) {
8978
- envMap = /* @__PURE__ */ new Map();
8979
- acc.set(envName, envMap);
8980
- }
8981
- for (const e2 of entries) {
8982
- const existing = envMap.get(e2.name);
8983
- if (existing) {
8984
- existing.shadowedBy.push(file.name);
8985
- existing.value = e2.value;
8986
- existing.isAlias = e2.isAlias;
8987
- } else {
8988
- envMap.set(e2.name, {
8989
- name: e2.name,
8990
- value: e2.value,
8991
- isAlias: e2.isAlias,
8992
- source: file.name,
8993
- sourceLine: e2.line,
8994
- shadowedBy: []
8995
- });
8996
- }
8997
- }
8998
- }
8999
- const out = /* @__PURE__ */ new Map();
9000
- for (const [env2, m] of acc) out.set(env2, [...m.values()]);
9001
- return out;
9002
- }
9003
9338
  function pickDefaultEnv(envs) {
9004
9339
  if (envs.length === 0) return "dev";
9005
9340
  if (envs.includes("dev")) return "dev";
@@ -9110,6 +9445,8 @@ var init_init = __esm({
9110
9445
  init_dist();
9111
9446
  init_envFile();
9112
9447
  init_ai_context();
9448
+ init_backup();
9449
+ init_collision();
9113
9450
  init_detect();
9114
9451
  init_heuristics();
9115
9452
  init_script_wrap();
@@ -9598,7 +9935,7 @@ var require_lib2 = __commonJS({
9598
9935
  return `'${escapedValue}${value.slice(chunkIndex)}'`;
9599
9936
  return `'${escapedValue}'`;
9600
9937
  };
9601
- var pad2 = (value) => value < 10 ? "0" + value : "" + value;
9938
+ var pad22 = (value) => value < 10 ? "0" + value : "" + value;
9602
9939
  var pad3 = (value) => value < 10 ? "00" + value : value < 100 ? "0" + value : "" + value;
9603
9940
  var pad4 = (value) => value < 10 ? "000" + value : value < 100 ? "00" + value : value < 1e3 ? "0" + value : "" + value;
9604
9941
  var convertTimezone = (tz) => {
@@ -9641,7 +9978,7 @@ var require_lib2 = __commonJS({
9641
9978
  second = adjustedDate.getUTCSeconds();
9642
9979
  millisecond = adjustedDate.getUTCMilliseconds();
9643
9980
  }
9644
- return escapeString(pad4(year) + "-" + pad2(month) + "-" + pad2(day) + " " + pad2(hour) + ":" + pad2(minute) + ":" + pad2(second) + "." + pad3(millisecond));
9981
+ return escapeString(pad4(year) + "-" + pad22(month) + "-" + pad22(day) + " " + pad22(hour) + ":" + pad22(minute) + ":" + pad22(second) + "." + pad3(millisecond));
9645
9982
  };
9646
9983
  exports.dateToString = dateToString;
9647
9984
  var escapeId = (value, forbidQualified) => {
@@ -46559,6 +46896,8 @@ init_dist();
46559
46896
  init_http();
46560
46897
  init_envFile();
46561
46898
  init_ai_context();
46899
+ init_backup();
46900
+ init_collision();
46562
46901
  init_detect();
46563
46902
  init_heuristics();
46564
46903
  init_init();
@@ -46712,12 +47051,50 @@ var ProjectDeleteCommand = class extends Command {
46712
47051
  // src/commands/init.ts
46713
47052
  function toAliasKey2(name) {
46714
47053
  if (!name) return name;
46715
- const KEY_RE3 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
46716
- if (KEY_RE3.test(name)) return name;
47054
+ const KEY_RE4 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
47055
+ if (KEY_RE4.test(name)) return name;
46717
47056
  const normalised = name.toLowerCase().replace(/_/g, "-");
46718
- if (KEY_RE3.test(normalised)) return normalised;
47057
+ if (KEY_RE4.test(normalised)) return normalised;
46719
47058
  return normalised.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "key";
46720
47059
  }
47060
+ function escapeRegExp2(value) {
47061
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47062
+ }
47063
+ function displayHit(rootPath, file) {
47064
+ return relFromRootPath(rootPath, file.path);
47065
+ }
47066
+ function relFromRootPath(rootPath, absPath) {
47067
+ const r = relative(rootPath, absPath);
47068
+ if (r === "") return ".";
47069
+ return r.split(/[\\/]/).filter(Boolean).join("/");
47070
+ }
47071
+ function envBodyFor2(name) {
47072
+ const isProd = name === "prod" || name === "production";
47073
+ return {
47074
+ name,
47075
+ tier: isProd ? "production" : "non-production",
47076
+ require_approval: isProd
47077
+ };
47078
+ }
47079
+ function writeKeynvEnvMappings(path, projectName, envName, secrets) {
47080
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
47081
+ const lines = [];
47082
+ for (const s of secrets) {
47083
+ const hasEntry = new RegExp(`^${escapeRegExp2(s.name)}=`, "m").test(existing);
47084
+ if (!hasEntry) {
47085
+ lines.push(`# ${s.name}`, `${s.name}=@${projectName}.${envName}.${s.aliasKey}`);
47086
+ }
47087
+ }
47088
+ if (lines.length === 0) return 0;
47089
+ const prefix = existing.trimEnd();
47090
+ const block = `# Generated by keynv setup
47091
+ ${lines.join("\n")}
47092
+ `;
47093
+ writeFileSync(path, prefix ? `${prefix}
47094
+
47095
+ ${block}` : block);
47096
+ return lines.length / 2;
47097
+ }
46721
47098
  var InitCommand = class extends Command {
46722
47099
  static paths = [["init"]];
46723
47100
  static usage = Command.Usage({
@@ -46874,12 +47251,33 @@ any prompts.
46874
47251
  return 0;
46875
47252
  }
46876
47253
  const secretsWithKeys = secrets.map((s) => ({ ...s, aliasKey: toAliasKey2(s.name) }));
46877
- return this.uploadSecrets(client, projectId2, projectName, envName, secretsWithKeys);
47254
+ const result = await this.uploadSecrets(
47255
+ client,
47256
+ projectId2,
47257
+ projectName,
47258
+ envName,
47259
+ secretsWithKeys
47260
+ );
47261
+ if (result !== 0) return result;
47262
+ const keynvEnvPath = join(process.cwd(), ".keynv.env");
47263
+ const written = writeKeynvEnvMappings(keynvEnvPath, projectName, envName, secretsWithKeys);
47264
+ this.context.stdout.write(
47265
+ written > 0 ? `keynv: wrote ${keynvEnvPath}
47266
+ ` : `keynv: ${keynvEnvPath} already up to date
47267
+ `
47268
+ );
47269
+ return 0;
46878
47270
  }
46879
47271
  /**
46880
- * Auto-scan mode (--yes without explicit --env-file). Finds .env files
46881
- * in the project root, classifies entries, creates a project, uploads
46882
- * secrets, and writes .keynv.env all without prompts.
47272
+ * Auto-scan mode (--yes without explicit --env-file). Recursively
47273
+ * finds .env files across the project (root, apps/*, packages/*, …),
47274
+ * classifies entries, creates the project + needed envs, uploads
47275
+ * secrets, writes a `.keynv.env` next to each source `.env`, and
47276
+ * renames the originals to `.env.backup` — all without prompts.
47277
+ *
47278
+ * Multi-source key collisions are resolved by auto-prefixing the
47279
+ * vault key with the source directory slug; the user's code keeps
47280
+ * using the original env-var name locally.
46883
47281
  */
46884
47282
  async runAutoScan(client) {
46885
47283
  const root = findProjectRoot(process.cwd());
@@ -46887,75 +47285,207 @@ any prompts.
46887
47285
  this.context.stderr.write("keynv: no project root found (no package.json, .git, etc.).\n");
46888
47286
  return 1;
46889
47287
  }
46890
- const envFiles = findEnvFiles(root.path);
47288
+ const envFiles = findEnvFilesRecursive(root.path);
46891
47289
  if (envFiles.length === 0) {
46892
- this.context.stdout.write("keynv: no .env files found. Nothing to migrate.\n");
47290
+ this.context.stdout.write(
47291
+ "keynv: no .env files found (scanned root and subdirectories). Nothing to migrate.\n"
47292
+ );
46893
47293
  return 0;
46894
47294
  }
46895
47295
  const projectName = this.project ?? root.suggestedName;
46896
- const envName = this.env ?? suggestedEnvForSuffix(envFiles[0]?.suffix ?? null);
46897
- const secrets = [];
47296
+ const cliEnv = this.env;
47297
+ const allSources = [];
47298
+ const isSecret = /* @__PURE__ */ new Set();
47299
+ let skippedCount = 0;
46898
47300
  for (const f of envFiles) {
46899
47301
  try {
46900
47302
  const entries = parseEnvFile(readFileSync(f.path, "utf8"), f.path);
47303
+ const envName = cliEnv ?? suggestedEnvForSuffix(f.suffix);
46901
47304
  for (const e2 of entries) {
46902
- if (classifyEntry(e2.name, e2.value).verdict === "secret") {
46903
- secrets.push({ name: e2.name, value: e2.value });
47305
+ const verdict = classifyEntry(e2.name, e2.value).verdict;
47306
+ if (verdict === "skip") {
47307
+ skippedCount++;
47308
+ continue;
47309
+ }
47310
+ if (verdict === "secret" && !e2.isAlias) {
47311
+ isSecret.add(`${f.path}|${e2.name}`);
46904
47312
  }
47313
+ allSources.push({
47314
+ file: f,
47315
+ envName,
47316
+ name: e2.name,
47317
+ value: e2.value,
47318
+ isAlias: e2.isAlias,
47319
+ line: e2.line
47320
+ });
46905
47321
  }
46906
47322
  } catch {
46907
- this.context.stderr.write(`keynv: warning \u2014 could not parse ${f.name}, skipping.
46908
- `);
47323
+ this.context.stderr.write(
47324
+ `keynv: warning \u2014 could not parse ${displayHit(root.path, f)}, skipping.
47325
+ `
47326
+ );
46909
47327
  }
46910
47328
  }
46911
- if (secrets.length === 0) {
46912
- this.context.stdout.write("keynv: no secrets detected in .env files. Nothing to upload.\n");
47329
+ if (allSources.length === 0) {
47330
+ this.context.stdout.write("keynv: no migrable entries in .env files. Nothing to do.\n");
46913
47331
  return 0;
46914
47332
  }
47333
+ const plan = planVaultKeys(allSources);
47334
+ for (const r of plan.renamed) {
47335
+ this.context.stderr.write(
47336
+ `keynv: renamed ${r.localKey} -> ${r.vaultKey} in env ${r.envName} (collision with ${r.otherSources.map((s) => displayHit(root.path, s)).join(", ")})
47337
+ `
47338
+ );
47339
+ }
47340
+ for (const m of plan.merged) {
47341
+ this.context.stderr.write(
47342
+ `keynv: merged ${m.key} in env ${m.envName} (same value in ${m.sources.map((s) => displayHit(root.path, s)).join(", ")})
47343
+ `
47344
+ );
47345
+ }
47346
+ for (const s of plan.shadowed) {
47347
+ this.context.stderr.write(
47348
+ `keynv: dotenv last-wins: ${s.localKey} in ${relFromRootPath(root.path, s.containingDir)} (${s.earlierFiles.join(", ")} -> ${s.laterFile})
47349
+ `
47350
+ );
47351
+ }
47352
+ const distinctEnvs = [...new Set(plan.resolved.map((r) => r.envName))];
47353
+ const secretCount = plan.resolved.filter(
47354
+ (r) => isSecret.has(`${r.source.path}|${r.localKey}`)
47355
+ ).length;
47356
+ const literalCount = plan.resolved.length - secretCount;
46915
47357
  this.context.stdout.write(
46916
- `keynv: auto-scan found ${secrets.length} secret(s) across ${envFiles.length} file(s).
47358
+ `keynv: auto-scan found ${secretCount} secret(s) and ${literalCount} literal(s) across ${envFiles.length} file(s)${skippedCount > 0 ? `; skipped ${skippedCount} framework entr${skippedCount === 1 ? "y" : "ies"}` : ""}.
46917
47359
  `
46918
47360
  );
46919
47361
  if (this.dryRun) {
46920
- for (const { name } of secrets) {
46921
- const aliasKey = toAliasKey2(name);
46922
- this.context.stdout.write(` ${name}=@${projectName}.${envName}.${aliasKey}
46923
- `);
47362
+ for (const r of plan.resolved) {
47363
+ const tag = isSecret.has(`${r.source.path}|${r.localKey}`) ? "secret" : "literal";
47364
+ const target = isSecret.has(`${r.source.path}|${r.localKey}`) ? `@${projectName}.${r.envName}.${r.vaultKey}` : r.value;
47365
+ this.context.stdout.write(
47366
+ ` ${displayHit(root.path, r.source)}: ${r.localKey}=${target} [${tag}]
47367
+ `
47368
+ );
46924
47369
  }
46925
47370
  return 0;
46926
47371
  }
46927
47372
  let projectId2;
46928
47373
  try {
46929
47374
  projectId2 = await resolveProjectId(client, projectName);
47375
+ try {
47376
+ const detail = await client.request(`/v1/projects/${projectId2}`);
47377
+ const existing = new Set(detail.environments.map((e2) => e2.name));
47378
+ const missing = distinctEnvs.filter((e2) => !existing.has(e2));
47379
+ for (const env2 of missing) {
47380
+ await client.request(`/v1/projects/${projectId2}/environments`, {
47381
+ method: "POST",
47382
+ body: envBodyFor2(env2)
47383
+ });
47384
+ this.context.stdout.write(`keynv: added env "${env2}" to project "${projectName}".
47385
+ `);
47386
+ }
47387
+ } catch (err) {
47388
+ this.context.stderr.write(
47389
+ `keynv: warning \u2014 could not reconcile envs for ${projectName}: ${err instanceof Error ? err.message : String(err)}
47390
+ `
47391
+ );
47392
+ }
46930
47393
  } catch {
46931
47394
  const created = await client.request("/v1/projects", {
46932
47395
  method: "POST",
46933
47396
  body: {
46934
47397
  name: projectName,
46935
- environments: [{ name: envName, tier: "non-production", require_approval: false }]
47398
+ environments: distinctEnvs.map((name) => envBodyFor2(name))
46936
47399
  }
46937
47400
  });
46938
47401
  projectId2 = created.id;
46939
- this.context.stdout.write(`keynv: created project "${projectName}" (${projectId2}).
46940
- `);
47402
+ this.context.stdout.write(
47403
+ `keynv: created project "${projectName}" (${projectId2}) with env(s): ${distinctEnvs.join(", ")}
47404
+ `
47405
+ );
46941
47406
  }
46942
- const secretsWithKeys = secrets.map((s) => ({ ...s, aliasKey: toAliasKey2(s.name) }));
46943
- const result = await this.uploadSecrets(
46944
- client,
46945
- projectId2,
46946
- projectName,
46947
- envName,
46948
- secretsWithKeys
47407
+ const aliasByGroup = /* @__PURE__ */ new Map();
47408
+ const secretGroupMap = /* @__PURE__ */ new Map();
47409
+ for (const r of plan.resolved) {
47410
+ if (!isSecret.has(`${r.source.path}|${r.localKey}`)) continue;
47411
+ const k2 = `${r.envName}|${r.vaultKey}`;
47412
+ if (!secretGroupMap.has(k2)) {
47413
+ secretGroupMap.set(k2, { envName: r.envName, vaultKey: r.vaultKey, value: r.value });
47414
+ }
47415
+ }
47416
+ let uploaded = 0;
47417
+ const failed = [];
47418
+ for (const [composite, g] of secretGroupMap) {
47419
+ const alias2 = reference_exports.buildAlias({
47420
+ project: projectName,
47421
+ environment: g.envName,
47422
+ key: g.vaultKey
47423
+ });
47424
+ if (!alias2) {
47425
+ failed.push({ name: g.vaultKey, reason: `invalid alias key: ${g.vaultKey}` });
47426
+ continue;
47427
+ }
47428
+ try {
47429
+ await client.request(`/v1/projects/${projectId2}/secrets`, {
47430
+ method: "POST",
47431
+ body: { env: g.envName, key: g.vaultKey, value: g.value }
47432
+ });
47433
+ aliasByGroup.set(composite, alias2.literal);
47434
+ uploaded++;
47435
+ } catch (err) {
47436
+ failed.push({
47437
+ name: g.vaultKey,
47438
+ reason: err instanceof Error ? err.message : String(err)
47439
+ });
47440
+ }
47441
+ }
47442
+ this.context.stdout.write(
47443
+ `keynv: uploaded ${uploaded}/${secretGroupMap.size} secret(s) to ${projectName}.
47444
+ `
46949
47445
  );
46950
- if (result !== 0) return result;
46951
- const aliasLines = secretsWithKeys.map((s) => `# ${s.name}
46952
- ${s.name}=@${projectName}.${envName}.${s.aliasKey}`).join("\n");
46953
- const keynvEnvPath = join(root.path, ".keynv.env");
46954
- writeFileSync(keynvEnvPath, `# Auto-generated by keynv init --yes
46955
- ${aliasLines}
47446
+ if (failed.length > 0) {
47447
+ for (const f of failed) this.context.stderr.write(` failed: ${f.name} \u2014 ${f.reason}
46956
47448
  `);
46957
- this.context.stdout.write(`keynv: wrote ${keynvEnvPath}
47449
+ return 1;
47450
+ }
47451
+ const defaultEnv = distinctEnvs.includes("dev") ? "dev" : distinctEnvs[0];
47452
+ const buckets = /* @__PURE__ */ new Map();
47453
+ for (const r of plan.resolved) {
47454
+ const k2 = `${r.source.containingDir}|${r.envName}`;
47455
+ let b2 = buckets.get(k2);
47456
+ if (!b2) {
47457
+ b2 = {
47458
+ containingDir: r.source.containingDir,
47459
+ envName: r.envName,
47460
+ aliasLines: [],
47461
+ literalEntries: []
47462
+ };
47463
+ buckets.set(k2, b2);
47464
+ }
47465
+ const isSec = isSecret.has(`${r.source.path}|${r.localKey}`);
47466
+ const alias2 = aliasByGroup.get(`${r.envName}|${r.vaultKey}`);
47467
+ if (isSec && alias2 !== void 0) {
47468
+ b2.aliasLines.push([r.localKey, alias2]);
47469
+ } else {
47470
+ b2.literalEntries.push({ name: r.localKey, value: r.value });
47471
+ }
47472
+ }
47473
+ for (const b2 of buckets.values()) {
47474
+ const targetName = b2.envName === defaultEnv ? ".keynv.env" : `.keynv.${b2.envName}.env`;
47475
+ const targetPath = join(b2.containingDir, targetName);
47476
+ const dirIntoExisting = existsSync(targetPath);
47477
+ const lines = composeKeynvEnv({
47478
+ uploadedAliases: new Map(b2.aliasLines),
47479
+ literals: b2.literalEntries,
47480
+ mergeWithExisting: dirIntoExisting ? readFileSync(targetPath, "utf8") : null
47481
+ });
47482
+ writeFileSync(targetPath, `${lines.join("\n")}
46958
47483
  `);
47484
+ this.context.stdout.write(
47485
+ `keynv: ${dirIntoExisting ? "updated" : "wrote"} ${relFromRootPath(root.path, targetPath)}
47486
+ `
47487
+ );
47488
+ }
46959
47489
  try {
46960
47490
  const outcome = writeAiContext(root.path);
46961
47491
  if (outcome === "created") this.context.stdout.write("keynv: wrote AGENTS.md\n");
@@ -46963,9 +47493,19 @@ ${aliasLines}
46963
47493
  this.context.stdout.write("keynv: warning \u2014 could not write AGENTS.md.\n");
46964
47494
  }
46965
47495
  for (const f of envFiles) {
46966
- unlinkSync(f.path);
46967
- this.context.stdout.write(`keynv: removed ${f.name} (secrets migrated to vault).
46968
- `);
47496
+ const relSrc = displayHit(root.path, f);
47497
+ try {
47498
+ const { renamedTo } = backupEnvFile(f.path);
47499
+ this.context.stdout.write(
47500
+ `keynv: renamed ${relSrc} -> ${relFromRootPath(root.path, renamedTo)}
47501
+ `
47502
+ );
47503
+ } catch (err) {
47504
+ this.context.stderr.write(
47505
+ `keynv: warning \u2014 could not rename ${relSrc}: ${err instanceof Error ? err.message : String(err)}
47506
+ `
47507
+ );
47508
+ }
46969
47509
  }
46970
47510
  this.context.stdout.write(
46971
47511
  "keynv: done. Use `keynv exec` to run commands with resolved secrets.\n"
@@ -48537,28 +49077,27 @@ cli.register(RedactStreamCommand);
48537
49077
  cli.register(TestCommand);
48538
49078
  cli.register(ServerInitCommand);
48539
49079
  var argv = process.argv.slice(2);
48540
- if (argv.length === 0) {
48541
- const { runMenu: runMenu2 } = await Promise.resolve().then(() => (init_menu(), menu_exports));
48542
- const { isInteractive: isInteractive2 } = await Promise.resolve().then(() => (init_tty(), tty_exports));
48543
- if (isInteractive2()) {
48544
- runMenu2().then((code) => process.exit(code)).catch((err) => {
48545
- process.stderr.write(`${fmtError(err)}
48546
- `);
48547
- process.exit(1);
48548
- });
48549
- } else {
49080
+ async function main() {
49081
+ if (argv.length === 0) {
49082
+ const { runMenu: runMenu2 } = await Promise.resolve().then(() => (init_menu(), menu_exports));
49083
+ const { isInteractive: isInteractive2 } = await Promise.resolve().then(() => (init_tty(), tty_exports));
49084
+ if (isInteractive2()) {
49085
+ return runMenu2();
49086
+ }
48550
49087
  process.stdout.write(
48551
49088
  "keynv \u2014 AI-safe secrets management.\nRun `keynv --help` for the full command list, or run `keynv` in an interactive\nterminal to open the menu.\n"
48552
49089
  );
48553
- process.exit(0);
49090
+ return 0;
48554
49091
  }
48555
- } else {
48556
- cli.run(argv).then((code) => process.exit(code ?? 0)).catch((err) => {
48557
- process.stderr.write(`${fmtError(err)}
48558
- `);
48559
- process.exit(1);
48560
- });
49092
+ return cli.run(argv);
48561
49093
  }
49094
+ main().then((code) => {
49095
+ process.exitCode = code ?? 0;
49096
+ }).catch((err) => {
49097
+ process.stderr.write(`${fmtError(err)}
49098
+ `);
49099
+ process.exitCode = 1;
49100
+ });
48562
49101
  /*! Bundled license information:
48563
49102
 
48564
49103
  long/umd/index.js: