@keynv/cli 0.1.0-rc.18 → 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, unlinkSync, existsSync, statSync, writeFileSync, 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.18" ;
1060
+ VERSION = "0.1.0-rc.19" ;
1061
1061
  AGENT = `keynv-cli/${VERSION}`;
1062
1062
  }
1063
1063
  });
@@ -5985,11 +5985,13 @@ var init_envFile = __esm({
5985
5985
  function aiContextBody() {
5986
5986
  return `## keynv (secrets)
5987
5987
 
5988
- 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.
5989
5991
 
5990
5992
  ### Mental model
5991
5993
 
5992
- - **\`.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.
5993
5995
  - **Vault** \u2014 holds the real values. The \`keynv\` CLI reads it on demand.
5994
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.
5995
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.
@@ -6057,9 +6059,9 @@ ${block}
6057
6059
  writeFileSync(path, next);
6058
6060
  return "updated";
6059
6061
  }
6060
- 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";
6061
6063
  const trailingNewline = existing.endsWith("\n") ? "" : "\n";
6062
- writeFileSync(path, `${existing}${trailingNewline}${sep}${block}
6064
+ writeFileSync(path, `${existing}${trailingNewline}${sep2}${block}
6063
6065
  `);
6064
6066
  return "appended";
6065
6067
  }
@@ -6071,6 +6073,175 @@ var init_ai_context = __esm({
6071
6073
  KEYNV_BLOCK_END = "<!-- keynv:end -->";
6072
6074
  }
6073
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
+ });
6074
6245
  function findProjectRoot(startDir) {
6075
6246
  const result = walkUp(startDir, (dir) => {
6076
6247
  for (const marker of PROJECT_MARKERS) {
@@ -6113,31 +6284,86 @@ function buildRoot(dir, marker) {
6113
6284
  packageJsonInvalid: invalid
6114
6285
  };
6115
6286
  }
6116
- function findEnvFiles(rootDir) {
6117
- 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;
6118
6291
  try {
6119
- entries = readdirSync(rootDir);
6292
+ rootReal = realpathSync(rootDir);
6120
6293
  } catch {
6121
6294
  return [];
6122
6295
  }
6123
6296
  const hits = [];
6124
- for (const name of entries) {
6125
- if (!ENV_GLOB.test(name)) continue;
6126
- if (ENV_EXAMPLE.test(name)) continue;
6127
- if (name === KEYNV_ENV_BASENAME) continue;
6128
- 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;
6129
6304
  try {
6130
- 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
+ }));
6131
6312
  } catch {
6132
6313
  continue;
6133
6314
  }
6134
- const suffixMatch = name.match(/^\.env\.(.+)$/);
6135
- const suffix = suffixMatch ? suffixMatch[1] : null;
6136
- 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
+ }
6137
6359
  }
6138
6360
  hits.sort((a, b2) => {
6139
- if (a.suffix === null) return -1;
6140
- 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;
6141
6367
  return a.name.localeCompare(b2.name);
6142
6368
  });
6143
6369
  return hits;
@@ -6166,7 +6392,7 @@ function suggestedEnvForSuffix(suffix) {
6166
6392
  function hasExistingKeynvEnv(rootDir) {
6167
6393
  return existsSync(join(rootDir, KEYNV_ENV_BASENAME));
6168
6394
  }
6169
- 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;
6170
6396
  var init_detect = __esm({
6171
6397
  "src/init/detect.ts"() {
6172
6398
  init_fs();
@@ -6184,6 +6410,74 @@ var init_detect = __esm({
6184
6410
  ENV_GLOB = /^\.env(\.[A-Za-z0-9_-]+)?$/;
6185
6411
  ENV_EXAMPLE = /^\.env\.(example|sample|template|dist|defaults)$/;
6186
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;
6187
6481
  }
6188
6482
  });
6189
6483
 
@@ -8601,15 +8895,16 @@ var init_pickProject = __esm({
8601
8895
  var init_exports = {};
8602
8896
  __export(init_exports, {
8603
8897
  UserCancelled: () => UserCancelled,
8898
+ composeKeynvEnv: () => composeKeynvEnv,
8604
8899
  runInitFlow: () => runInitFlow
8605
8900
  });
8606
- function toAliasKey(name) {
8607
- if (!name) return name;
8608
- const KEY_RE3 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8609
- if (KEY_RE3.test(name)) return name;
8610
- const normalised = name.toLowerCase().replace(/_/g, "-");
8611
- if (KEY_RE3.test(normalised)) return normalised;
8612
- 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("/");
8613
8908
  }
8614
8909
  async function runInitFlow(client, opts) {
8615
8910
  ge("Set up this project");
@@ -8623,11 +8918,11 @@ async function runInitFlow(client, opts) {
8623
8918
  if (root.packageJsonInvalid) {
8624
8919
  R2.warn(`package.json at ${root.path} is not valid JSON \u2014 script wrapping will be skipped.`);
8625
8920
  }
8626
- const envFiles = findEnvFiles(root.path);
8921
+ const envFiles = findEnvFilesRecursive(root.path);
8627
8922
  const intoExisting = hasExistingKeynvEnv(root.path);
8628
8923
  if (envFiles.length === 0 && !intoExisting) {
8629
8924
  R2.info(
8630
- `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.`
8631
8926
  );
8632
8927
  ye("Nothing to do.");
8633
8928
  return { exitCode: 0 };
@@ -8636,8 +8931,9 @@ async function runInitFlow(client, opts) {
8636
8931
  [
8637
8932
  `Project root: ${root.path}`,
8638
8933
  `Marker: ${root.marker}`,
8639
- envFiles.length > 0 ? `Found env files: ${envFiles.map((f) => f.name).join(", ")}` : "Found env files: (none)",
8640
- 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." : ""
8641
8937
  ].filter(Boolean).join("\n"),
8642
8938
  "Detected"
8643
8939
  );
@@ -8652,62 +8948,118 @@ async function runInitFlow(client, opts) {
8652
8948
  return { exitCode: 130 };
8653
8949
  }
8654
8950
  const distinctEnvs = [...new Set(fileMapping.map((m) => m.envName))];
8655
- const perEnv = parseAndMergePerEnv(fileMapping);
8951
+ const allSources = [];
8656
8952
  const skipped = [];
8657
- for (const env2 of distinctEnvs) {
8658
- const before = perEnv.get(env2) ?? [];
8659
- const kept = [];
8660
- 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) {
8661
8964
  if (classifyEntry(e2.name, e2.value).verdict === "skip") {
8662
- skipped.push({ env: env2, entry: e2 });
8663
- } else {
8664
- kept.push(e2);
8965
+ skipped.push({ env: envName, name: e2.name });
8966
+ continue;
8665
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
+ });
8666
8976
  }
8667
- perEnv.set(env2, kept);
8668
8977
  }
8669
8978
  if (skipped.length > 0) {
8670
- const names = [...new Set(skipped.map((s) => s.entry.name))];
8979
+ const names = [...new Set(skipped.map((s) => s.name))];
8671
8980
  R2.info(
8672
8981
  `Skipped ${skipped.length} framework/shell-managed entr${skipped.length === 1 ? "y" : "ies"}: ${names.join(", ")}`
8673
8982
  );
8674
8983
  }
8675
- for (const env2 of distinctEnvs) {
8676
- const shadowed = (perEnv.get(env2) ?? []).filter((e2) => e2.shadowedBy.length > 0);
8677
- if (shadowed.length === 0) continue;
8678
- 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");
8679
9016
  R2.warn(
8680
- `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):
8681
9018
  ${detail}`
8682
9019
  );
8683
9020
  }
8684
- const totalEntries = [...perEnv.values()].reduce((n, arr) => n + arr.length, 0);
8685
- if (totalEntries === 0) {
9021
+ if (plan.resolved.length === 0) {
8686
9022
  R2.info(
8687
9023
  "All env files were empty or only contained framework-managed vars. Nothing to upload."
8688
9024
  );
8689
9025
  ye("Done.");
8690
9026
  return { exitCode: 0 };
8691
9027
  }
8692
- const choices = [];
8693
- for (const env2 of distinctEnvs) {
8694
- for (const e2 of perEnv.get(env2) ?? []) {
8695
- const c2 = classifyEntry(e2.name, e2.value);
8696
- const hint = c2.hint || (e2.isAlias ? "looks like an alias literal" : "no signal");
8697
- const preview2 = e2.isAlias ? e2.value : previewValue(e2.value, 28);
8698
- const envTag = distinctEnvs.length > 1 ? `[${env2}] ` : "";
8699
- choices.push({
8700
- composite: `${env2}|${e2.name}`,
8701
- env: env2,
8702
- name: e2.name,
8703
- value: e2.value,
8704
- 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,
8705
9045
  verdict: c2.verdict,
8706
- label: `${envTag}${e2.name} ${preview2}`,
8707
- hint
8708
- });
9046
+ label: `${envTag}${r.localKey}${renamedTag} ${preview2}`,
9047
+ hint,
9048
+ sources: []
9049
+ };
9050
+ groups.set(composite, g);
9051
+ }
9052
+ g.sources.push(r);
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}]`;
8709
9060
  }
8710
9061
  }
9062
+ const choices = [...groups.values()];
8711
9063
  const initialSecretSelection = choices.filter((c2) => c2.verdict === "secret" && !c2.isAlias).map((c2) => c2.composite);
8712
9064
  const selectedComposites = unwrap(
8713
9065
  await ve({
@@ -8729,18 +9081,24 @@ ${detail}`
8729
9081
  scriptWrapSelection = scriptPlan.recommended.map((a) => a.name);
8730
9082
  }
8731
9083
  const perEnvCounts = distinctEnvs.map((env2) => {
8732
- const entries = perEnv.get(env2) ?? [];
8733
- const sec = entries.filter((e2) => selected.has(`${env2}|${e2.name}`)).length;
8734
- const lit = entries.length - sec;
8735
- 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)" : ""}`;
8736
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)` : "";
8737
9092
  const planSummary = [
8738
9093
  `Project: ${projectChoice.name}${projectChoice.created ? " (will be created)" : ""}`,
8739
9094
  `Environments: ${distinctEnvs.join(", ")}`,
8740
9095
  "Per-env breakdown:",
8741
9096
  perEnvCounts,
8742
9097
  `Script wraps: ${scriptWrapSelection.length}`,
8743
- "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,
8744
9102
  opts.dryRun ? "Dry-run: no changes will be made." : ""
8745
9103
  ].filter(Boolean).join("\n");
8746
9104
  Se(planSummary, "About to apply");
@@ -8755,100 +9113,98 @@ ${detail}`
8755
9113
  }
8756
9114
  const projectId2 = await ensureProjectAndEnvs(client, projectChoice, distinctEnvs);
8757
9115
  if (projectId2 === null) return { exitCode: 1 };
8758
- const uploadedByEnv = /* @__PURE__ */ new Map();
9116
+ const aliasByGroup = /* @__PURE__ */ new Map();
8759
9117
  const failed = [];
8760
- const totalToUpload = selected.size;
8761
- if (totalToUpload > 0) {
9118
+ const groupsToUpload = choices.filter((g) => selected.has(g.composite));
9119
+ if (groupsToUpload.length > 0) {
8762
9120
  const s = ft();
8763
- s.start(`Uploading ${totalToUpload} secret${totalToUpload === 1 ? "" : "s"}`);
9121
+ s.start(
9122
+ `Uploading ${groupsToUpload.length} secret${groupsToUpload.length === 1 ? "" : "s"}`
9123
+ );
8764
9124
  let i = 0;
8765
- for (const env2 of distinctEnvs) {
8766
- const envUploaded = /* @__PURE__ */ new Map();
8767
- uploadedByEnv.set(env2, envUploaded);
8768
- for (const e2 of perEnv.get(env2) ?? []) {
8769
- if (!selected.has(`${env2}|${e2.name}`)) continue;
8770
- i++;
8771
- s.message(`Uploading (${i}/${totalToUpload}) [${env2}] ${e2.name}`);
8772
- const aliasKey = toAliasKey(e2.name);
8773
- try {
8774
- await client.request(`/v1/projects/${projectId2}/secrets`, {
8775
- method: "POST",
8776
- body: { env: env2, key: aliasKey, value: e2.value }
8777
- });
8778
- const alias2 = reference_exports.buildAlias({
8779
- project: projectChoice.name,
8780
- environment: env2,
8781
- key: aliasKey
8782
- });
8783
- if (alias2 === null) {
8784
- failed.push({
8785
- env: env2,
8786
- name: e2.name,
8787
- reason: `produced an invalid alias for project=${projectChoice.name} env=${env2} key=${aliasKey}`
8788
- });
8789
- } else {
8790
- envUploaded.set(e2.name, alias2.literal);
8791
- }
8792
- } 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) {
8793
9139
  failed.push({
8794
- env: env2,
8795
- name: e2.name,
8796
- 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}`
8797
9143
  });
9144
+ } else {
9145
+ aliasByGroup.set(g.composite, alias2.literal);
8798
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
+ });
8799
9153
  }
8800
9154
  }
8801
9155
  if (failed.length === 0) {
8802
- s.stop(`Uploaded ${totalToUpload} secret${totalToUpload === 1 ? "" : "s"}`);
9156
+ s.stop(`Uploaded ${groupsToUpload.length} secret${groupsToUpload.length === 1 ? "" : "s"}`);
8803
9157
  } else {
8804
9158
  s.error(
8805
- `${totalToUpload - failed.length}/${totalToUpload} uploaded; ${failed.length} failed`
9159
+ `${groupsToUpload.length - failed.length}/${groupsToUpload.length} uploaded; ${failed.length} failed`
8806
9160
  );
8807
9161
  for (const f of failed) R2.warn(` [${f.env}] ${f.name}: ${f.reason}`);
8808
9162
  }
8809
9163
  }
8810
- const defaultUploaded = uploadedByEnv.get(defaultEnv) ?? /* @__PURE__ */ new Map();
8811
- const defaultLiterals = (perEnv.get(defaultEnv) ?? []).filter(
8812
- (e2) => !selected.has(`${defaultEnv}|${e2.name}`)
8813
- );
8814
- const keynvEnvPath = join(root.path, ".keynv.env");
8815
- try {
8816
- const lines = composeKeynvEnv({
8817
- uploadedAliases: defaultUploaded,
8818
- literals: defaultLiterals,
8819
- mergeWithExisting: intoExisting ? readFileSync(keynvEnvPath, "utf8") : null
8820
- });
8821
- writeFileSync(keynvEnvPath, `${lines.join("\n")}
8822
- `);
8823
- R2.success(
8824
- `${intoExisting ? "Updated" : "Wrote"} ${keynvEnvPath} (${defaultUploaded.size + defaultLiterals.length} entries from "${defaultEnv}")`
8825
- );
8826
- } catch (err) {
8827
- R2.error(`Failed to write .keynv.env: ${err instanceof Error ? err.message : String(err)}`);
8828
- 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
+ }
8829
9185
  }
8830
- const otherEnvsWithAliases = distinctEnvs.filter(
8831
- (env2) => env2 !== defaultEnv && (uploadedByEnv.get(env2)?.size ?? 0) > 0
8832
- );
8833
- for (const env2 of otherEnvsWithAliases) {
8834
- const envUploaded = uploadedByEnv.get(env2) ?? /* @__PURE__ */ new Map();
8835
- const envLiterals = (perEnv.get(env2) ?? []).filter((e2) => !selected.has(`${env2}|${e2.name}`));
8836
- 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);
8837
9190
  try {
8838
9191
  const lines = composeKeynvEnv({
8839
- uploadedAliases: envUploaded,
8840
- literals: envLiterals,
8841
- 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
8842
9195
  });
8843
- writeFileSync(envFilePath, `${lines.join("\n")}
9196
+ writeFileSync(targetPath, `${lines.join("\n")}
8844
9197
  `);
9198
+ const relTarget = relFromRoot(root.path, targetPath);
9199
+ const total = b2.aliasLines.length + b2.literalEntries.length;
8845
9200
  R2.success(
8846
- `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}")`
8847
9202
  );
8848
9203
  } catch (err) {
8849
- R2.warn(
8850
- `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)}`
8851
9206
  );
9207
+ return { exitCode: 1 };
8852
9208
  }
8853
9209
  }
8854
9210
  try {
@@ -8876,19 +9232,28 @@ ${detail}`
8876
9232
  }
8877
9233
  }
8878
9234
  for (const f of envFiles) {
9235
+ const relSrc = relFromRoot(root.path, f.path);
8879
9236
  try {
8880
- unlinkSync(f.path);
8881
- R2.success(`Removed ${f.name}`);
9237
+ const { renamedTo } = backupEnvFile(f.path);
9238
+ const relTarget = relFromRoot(root.path, renamedTo);
9239
+ R2.success(`Renamed ${relSrc} -> ${relTarget}`);
8882
9240
  } catch (err) {
8883
- 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)}`);
8884
9242
  }
8885
9243
  }
8886
9244
  const otherEnvs = distinctEnvs.filter((e2) => e2 !== defaultEnv);
8887
9245
  if (otherEnvs.length > 0) {
8888
9246
  const lines = otherEnvs.map((env2) => {
8889
- const count = uploadedByEnv.get(env2)?.size ?? 0;
8890
- const hasFile = count > 0;
8891
- 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>\`)`;
8892
9257
  });
8893
9258
  Se(lines.join("\n"), "Secrets in other envs");
8894
9259
  }
@@ -8953,7 +9318,7 @@ async function pickFileEnvMapping(files, project) {
8953
9318
  opts.push({ value: "__custom", label: "+ Custom env name\u2026" });
8954
9319
  let envName = unwrap(
8955
9320
  await Ee({
8956
- message: `Map ${f.name} to which keynv env?`,
9321
+ message: `Map ${displayName(f)} to which keynv env?`,
8957
9322
  options: opts,
8958
9323
  initialValue: suggested
8959
9324
  })
@@ -8970,43 +9335,6 @@ async function pickFileEnvMapping(files, project) {
8970
9335
  }
8971
9336
  return assignments;
8972
9337
  }
8973
- function parseAndMergePerEnv(mapping) {
8974
- const acc = /* @__PURE__ */ new Map();
8975
- for (const { file, envName } of mapping) {
8976
- let entries;
8977
- try {
8978
- entries = parseEnvFile(readFileSync(file.path, "utf8"), file.path);
8979
- } catch (err) {
8980
- R2.warn(`${file.name}: ${err instanceof Error ? err.message : String(err)} \u2014 skipping file`);
8981
- continue;
8982
- }
8983
- let envMap = acc.get(envName);
8984
- if (!envMap) {
8985
- envMap = /* @__PURE__ */ new Map();
8986
- acc.set(envName, envMap);
8987
- }
8988
- for (const e2 of entries) {
8989
- const existing = envMap.get(e2.name);
8990
- if (existing) {
8991
- existing.shadowedBy.push(file.name);
8992
- existing.value = e2.value;
8993
- existing.isAlias = e2.isAlias;
8994
- } else {
8995
- envMap.set(e2.name, {
8996
- name: e2.name,
8997
- value: e2.value,
8998
- isAlias: e2.isAlias,
8999
- source: file.name,
9000
- sourceLine: e2.line,
9001
- shadowedBy: []
9002
- });
9003
- }
9004
- }
9005
- }
9006
- const out = /* @__PURE__ */ new Map();
9007
- for (const [env2, m] of acc) out.set(env2, [...m.values()]);
9008
- return out;
9009
- }
9010
9338
  function pickDefaultEnv(envs) {
9011
9339
  if (envs.length === 0) return "dev";
9012
9340
  if (envs.includes("dev")) return "dev";
@@ -9117,6 +9445,8 @@ var init_init = __esm({
9117
9445
  init_dist();
9118
9446
  init_envFile();
9119
9447
  init_ai_context();
9448
+ init_backup();
9449
+ init_collision();
9120
9450
  init_detect();
9121
9451
  init_heuristics();
9122
9452
  init_script_wrap();
@@ -9605,7 +9935,7 @@ var require_lib2 = __commonJS({
9605
9935
  return `'${escapedValue}${value.slice(chunkIndex)}'`;
9606
9936
  return `'${escapedValue}'`;
9607
9937
  };
9608
- var pad2 = (value) => value < 10 ? "0" + value : "" + value;
9938
+ var pad22 = (value) => value < 10 ? "0" + value : "" + value;
9609
9939
  var pad3 = (value) => value < 10 ? "00" + value : value < 100 ? "0" + value : "" + value;
9610
9940
  var pad4 = (value) => value < 10 ? "000" + value : value < 100 ? "00" + value : value < 1e3 ? "0" + value : "" + value;
9611
9941
  var convertTimezone = (tz) => {
@@ -9648,7 +9978,7 @@ var require_lib2 = __commonJS({
9648
9978
  second = adjustedDate.getUTCSeconds();
9649
9979
  millisecond = adjustedDate.getUTCMilliseconds();
9650
9980
  }
9651
- 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));
9652
9982
  };
9653
9983
  exports.dateToString = dateToString;
9654
9984
  var escapeId = (value, forbidQualified) => {
@@ -46566,6 +46896,8 @@ init_dist();
46566
46896
  init_http();
46567
46897
  init_envFile();
46568
46898
  init_ai_context();
46899
+ init_backup();
46900
+ init_collision();
46569
46901
  init_detect();
46570
46902
  init_heuristics();
46571
46903
  init_init();
@@ -46719,15 +47051,31 @@ var ProjectDeleteCommand = class extends Command {
46719
47051
  // src/commands/init.ts
46720
47052
  function toAliasKey2(name) {
46721
47053
  if (!name) return name;
46722
- const KEY_RE3 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
46723
- 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;
46724
47056
  const normalised = name.toLowerCase().replace(/_/g, "-");
46725
- if (KEY_RE3.test(normalised)) return normalised;
47057
+ if (KEY_RE4.test(normalised)) return normalised;
46726
47058
  return normalised.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "key";
46727
47059
  }
46728
47060
  function escapeRegExp2(value) {
46729
47061
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46730
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
+ }
46731
47079
  function writeKeynvEnvMappings(path, projectName, envName, secrets) {
46732
47080
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
46733
47081
  const lines = [];
@@ -46921,9 +47269,15 @@ any prompts.
46921
47269
  return 0;
46922
47270
  }
46923
47271
  /**
46924
- * Auto-scan mode (--yes without explicit --env-file). Finds .env files
46925
- * in the project root, classifies entries, creates a project, uploads
46926
- * 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.
46927
47281
  */
46928
47282
  async runAutoScan(client) {
46929
47283
  const root = findProjectRoot(process.cwd());
@@ -46931,74 +47285,207 @@ any prompts.
46931
47285
  this.context.stderr.write("keynv: no project root found (no package.json, .git, etc.).\n");
46932
47286
  return 1;
46933
47287
  }
46934
- const envFiles = findEnvFiles(root.path);
47288
+ const envFiles = findEnvFilesRecursive(root.path);
46935
47289
  if (envFiles.length === 0) {
46936
- 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
+ );
46937
47293
  return 0;
46938
47294
  }
46939
47295
  const projectName = this.project ?? root.suggestedName;
46940
- const envName = this.env ?? suggestedEnvForSuffix(envFiles[0]?.suffix ?? null);
46941
- const secrets = [];
47296
+ const cliEnv = this.env;
47297
+ const allSources = [];
47298
+ const isSecret = /* @__PURE__ */ new Set();
47299
+ let skippedCount = 0;
46942
47300
  for (const f of envFiles) {
46943
47301
  try {
46944
47302
  const entries = parseEnvFile(readFileSync(f.path, "utf8"), f.path);
47303
+ const envName = cliEnv ?? suggestedEnvForSuffix(f.suffix);
46945
47304
  for (const e2 of entries) {
46946
- if (classifyEntry(e2.name, e2.value).verdict === "secret") {
46947
- 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;
46948
47309
  }
47310
+ if (verdict === "secret" && !e2.isAlias) {
47311
+ isSecret.add(`${f.path}|${e2.name}`);
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
+ });
46949
47321
  }
46950
47322
  } catch {
46951
- this.context.stderr.write(`keynv: warning \u2014 could not parse ${f.name}, skipping.
46952
- `);
47323
+ this.context.stderr.write(
47324
+ `keynv: warning \u2014 could not parse ${displayHit(root.path, f)}, skipping.
47325
+ `
47326
+ );
46953
47327
  }
46954
47328
  }
46955
- if (secrets.length === 0) {
46956
- 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");
46957
47331
  return 0;
46958
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;
46959
47357
  this.context.stdout.write(
46960
- `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"}` : ""}.
46961
47359
  `
46962
47360
  );
46963
47361
  if (this.dryRun) {
46964
- for (const { name } of secrets) {
46965
- const aliasKey = toAliasKey2(name);
46966
- this.context.stdout.write(` ${name}=@${projectName}.${envName}.${aliasKey}
46967
- `);
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
+ );
46968
47369
  }
46969
47370
  return 0;
46970
47371
  }
46971
47372
  let projectId2;
46972
47373
  try {
46973
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
+ }
46974
47393
  } catch {
46975
47394
  const created = await client.request("/v1/projects", {
46976
47395
  method: "POST",
46977
47396
  body: {
46978
47397
  name: projectName,
46979
- environments: [{ name: envName, tier: "non-production", require_approval: false }]
47398
+ environments: distinctEnvs.map((name) => envBodyFor2(name))
46980
47399
  }
46981
47400
  });
46982
47401
  projectId2 = created.id;
46983
- this.context.stdout.write(`keynv: created project "${projectName}" (${projectId2}).
46984
- `);
47402
+ this.context.stdout.write(
47403
+ `keynv: created project "${projectName}" (${projectId2}) with env(s): ${distinctEnvs.join(", ")}
47404
+ `
47405
+ );
47406
+ }
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
+ }
46985
47441
  }
46986
- const secretsWithKeys = secrets.map((s) => ({ ...s, aliasKey: toAliasKey2(s.name) }));
46987
- const result = await this.uploadSecrets(
46988
- client,
46989
- projectId2,
46990
- projectName,
46991
- envName,
46992
- secretsWithKeys
46993
- );
46994
- if (result !== 0) return result;
46995
- const keynvEnvPath = join(root.path, ".keynv.env");
46996
- const written = writeKeynvEnvMappings(keynvEnvPath, projectName, envName, secretsWithKeys);
46997
47442
  this.context.stdout.write(
46998
- written > 0 ? `keynv: wrote ${keynvEnvPath}
46999
- ` : `keynv: ${keynvEnvPath} already up to date
47443
+ `keynv: uploaded ${uploaded}/${secretGroupMap.size} secret(s) to ${projectName}.
47000
47444
  `
47001
47445
  );
47446
+ if (failed.length > 0) {
47447
+ for (const f of failed) this.context.stderr.write(` failed: ${f.name} \u2014 ${f.reason}
47448
+ `);
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")}
47483
+ `);
47484
+ this.context.stdout.write(
47485
+ `keynv: ${dirIntoExisting ? "updated" : "wrote"} ${relFromRootPath(root.path, targetPath)}
47486
+ `
47487
+ );
47488
+ }
47002
47489
  try {
47003
47490
  const outcome = writeAiContext(root.path);
47004
47491
  if (outcome === "created") this.context.stdout.write("keynv: wrote AGENTS.md\n");
@@ -47006,9 +47493,19 @@ any prompts.
47006
47493
  this.context.stdout.write("keynv: warning \u2014 could not write AGENTS.md.\n");
47007
47494
  }
47008
47495
  for (const f of envFiles) {
47009
- unlinkSync(f.path);
47010
- this.context.stdout.write(`keynv: removed ${f.name} (secrets migrated to vault).
47011
- `);
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
+ }
47012
47509
  }
47013
47510
  this.context.stdout.write(
47014
47511
  "keynv: done. Use `keynv exec` to run commands with resolved secrets.\n"