@bremmdev/arca-env 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -372,8 +372,12 @@ var ArcaError = class extends Error {
372
372
  //#region src/config.ts
373
373
  const CONFIG_FILE = "arca.json";
374
374
  const SCHEMA_REF = "./node_modules/@bremmdev/arca-env/schema.json";
375
- /** Profiles aren't implemented yet. Everything uses this single set internally. */
375
+ /** The profile a project has when arca.json declares none. */
376
376
  const DEFAULT_PROFILE = "default";
377
+ /** Lowercase because vault files are named after profiles, on case-insensitive file systems. */
378
+ const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/;
379
+ /** `policy.vault` sits next to the profile vaults. */
380
+ const RESERVED_PROFILES = /* @__PURE__ */ new Set(["policy"]);
377
381
  const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
378
382
  function isSensitive(decl) {
379
383
  return decl?.sensitive !== false;
@@ -431,7 +435,7 @@ function parseProject(text, file) {
431
435
  if (!isObject(value)) fail(`vars.${key} must be an object (use {} for no options)`);
432
436
  vars[key] = parseDecl(key, value, fail);
433
437
  }
434
- for (const key of ["profiles", "defaultProfile"]) if (key in o) warnings.push(`"${key}" in ${CONFIG_FILE} is ignored: profiles aren't available in this version yet.`);
438
+ const { profiles, defaultProfile } = parseProfiles(o, fail, warnings);
435
439
  const known = /* @__PURE__ */ new Set([
436
440
  "$schema",
437
441
  "id",
@@ -451,7 +455,9 @@ function parseProject(text, file) {
451
455
  name,
452
456
  envFiles,
453
457
  leakGuard: { ignore },
454
- vars
458
+ vars,
459
+ profiles,
460
+ defaultProfile
455
461
  },
456
462
  warnings
457
463
  };
@@ -486,6 +492,85 @@ function parseDecl(key, v, fail) {
486
492
  }
487
493
  return d;
488
494
  }
495
+ function normalizeProfile(name) {
496
+ return name.trim().toLowerCase();
497
+ }
498
+ /** Why a profile name can't be used, or null when it can. */
499
+ function invalidProfileName(name) {
500
+ if (!PROFILE_NAME.test(name)) return `"${name}" is not a valid profile name (letters, digits, - and _, up to 40 characters)`;
501
+ if (RESERVED_PROFILES.has(name)) return `"${name}" is reserved, pick another profile name`;
502
+ return null;
503
+ }
504
+ function parseProfiles(o, fail, warnings) {
505
+ let defaultProfile;
506
+ if (o.defaultProfile !== void 0) {
507
+ if (typeof o.defaultProfile !== "string") fail("\"defaultProfile\" must be a profile name");
508
+ defaultProfile = normalizeProfile(o.defaultProfile);
509
+ const bad = invalidProfileName(defaultProfile);
510
+ if (bad) fail(`defaultProfile: ${bad}`);
511
+ }
512
+ const raw = o.profiles ?? {};
513
+ if (!isObject(raw)) fail("\"profiles\" must be an object like { \"ontwikkel\": {}, \"acceptatie\": { \"extends\": \"ontwikkel\" } }");
514
+ const profiles = {};
515
+ for (const [rawName, value] of Object.entries(raw)) {
516
+ const name = normalizeProfile(rawName);
517
+ const bad = invalidProfileName(name);
518
+ if (bad) fail(`profiles: ${bad}`);
519
+ if (name in profiles) fail(`profiles: "${rawName}" and another profile differ only in case. Profile names are case-insensitive.`);
520
+ if (!isObject(value)) fail(`profiles.${rawName} must be an object (use {} for no options)`);
521
+ const v = value;
522
+ const decl = { envFiles: [] };
523
+ if (v.extends !== void 0) {
524
+ if (typeof v.extends !== "string") fail(`profiles.${rawName}.extends must be a profile name`);
525
+ decl.extends = normalizeProfile(v.extends);
526
+ }
527
+ if (v.envFiles !== void 0) {
528
+ if (!Array.isArray(v.envFiles) || v.envFiles.some((f) => typeof f !== "string")) fail(`profiles.${rawName}.envFiles must be an array of file names`);
529
+ decl.envFiles = v.envFiles;
530
+ }
531
+ for (const key of Object.keys(v)) if (key !== "extends" && key !== "envFiles") warnings.push(`Unknown field "${key}" in profiles.${rawName} is ignored.`);
532
+ profiles[name] = decl;
533
+ }
534
+ const declared = Object.keys(profiles);
535
+ if (declared.length === 0) {
536
+ const name = defaultProfile ?? "default";
537
+ return {
538
+ profiles: { [name]: { envFiles: [] } },
539
+ defaultProfile: name
540
+ };
541
+ }
542
+ if (defaultProfile === void 0) defaultProfile = "default" in profiles ? DEFAULT_PROFILE : declared[0];
543
+ else if (!(defaultProfile in profiles)) fail(`"defaultProfile" is "${defaultProfile}", which isn't in "profiles" (${declared.join(", ")})`);
544
+ for (const [name, decl] of Object.entries(profiles)) {
545
+ if (decl.extends === void 0) continue;
546
+ if (!(decl.extends in profiles)) fail(`profiles.${name} extends "${decl.extends}", which isn't in "profiles" (${declared.join(", ")})`);
547
+ const seen = [name];
548
+ for (let p = decl.extends; p !== void 0; p = profiles[p].extends) {
549
+ if (seen.includes(p)) fail(`profiles: "extends" loops: ${[...seen, p].join(" → ")}`);
550
+ seen.push(p);
551
+ }
552
+ }
553
+ return {
554
+ profiles,
555
+ defaultProfile
556
+ };
557
+ }
558
+ /** The profile followed by the profiles it extends, nearest first. */
559
+ function profileChain(config, profile) {
560
+ const chain = [];
561
+ for (let p = profile; p !== void 0 && !chain.includes(p); p = config.profiles[p]?.extends) chain.push(p);
562
+ return chain;
563
+ }
564
+ /** Config files arca loads for a profile: top-level envFiles, then the chain's, the profile's own last (later files win). */
565
+ function profileEnvFiles(config, profile) {
566
+ const files = [...config.envFiles];
567
+ for (const p of profileChain(config, profile).reverse()) for (const f of config.profiles[p]?.envFiles ?? []) if (!files.includes(f)) files.push(f);
568
+ return files;
569
+ }
570
+ /** Profiles that extend `profile` directly. */
571
+ function profileChildren(config, profile) {
572
+ return Object.entries(config.profiles).filter(([, d]) => d.extends === profile).map(([n]) => n);
573
+ }
489
574
  function isObject(v) {
490
575
  return typeof v === "object" && v !== null && !Array.isArray(v);
491
576
  }
@@ -508,6 +593,24 @@ function declareVars(file, decls) {
508
593
  raw.vars = vars;
509
594
  });
510
595
  }
596
+ /**
597
+ * Adds a profile to arca.json. A file without "profiles" gets its implicit default profile
598
+ * written out too, so the values stored for it keep working.
599
+ */
600
+ function addProfileToConfig(project, name, decl) {
601
+ updateConfigFile(project.file, (raw) => {
602
+ const profiles = isObject(raw.profiles) && Object.keys(raw.profiles).length > 0 ? raw.profiles : { [project.config.defaultProfile]: {} };
603
+ profiles[name] = decl.extends ? { extends: decl.extends } : {};
604
+ raw.profiles = profiles;
605
+ });
606
+ }
607
+ function removeProfileFromConfig(file, name) {
608
+ updateConfigFile(file, (raw) => {
609
+ if (!isObject(raw.profiles)) return;
610
+ const profiles = raw.profiles;
611
+ for (const key of Object.keys(profiles)) if (normalizeProfile(key) === name) delete profiles[key];
612
+ });
613
+ }
511
614
  function newConfig(name) {
512
615
  return {
513
616
  $schema: SCHEMA_REF,
@@ -831,6 +934,9 @@ Secrets are stored outside the repo with arca and injected when commands run.
831
934
  which wrap commands in \`arca run\`. Ad-hoc \`arca run -- <cmd>\` is refused for agents.
832
935
  Output is always masked: secrets appear as \`[arca:NAME]\`.
833
936
  - \`npx arca list\` shows which variables exist. \`npx arca check\` verifies required ones are set.
937
+ - A project can have several profiles (sets of values). A run that doesn't use the default
938
+ profile starts with an \`arca ▸ profile …\` line. Don't switch profiles with \`npx arca use\`
939
+ unless the user asks: it also changes what the user's own terminals use.
834
940
  - Do not try to read secret values (\`npx arca reveal\`, printing \`process.env\`, reading the
835
941
  arca store).
836
942
  - Only the user can set or change values. If a value is missing or wrong, ask the user
@@ -887,6 +993,42 @@ Type this code to continue: ${code}\n`);
887
993
  if (answer === null || answer.trim().toUpperCase() !== code) throw new ArcaError("arca: the code did not match. Nothing was changed.");
888
994
  }
889
995
  //#endregion
996
+ //#region src/util.ts
997
+ function levenshtein(a, b) {
998
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
999
+ for (let i = 1; i <= a.length; i++) {
1000
+ let prev = dp[0];
1001
+ dp[0] = i;
1002
+ for (let j = 1; j <= b.length; j++) {
1003
+ const tmp = dp[j];
1004
+ dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
1005
+ prev = tmp;
1006
+ }
1007
+ }
1008
+ return dp[b.length];
1009
+ }
1010
+ /** The closest candidate within a small edit distance, for "did you mean …?". */
1011
+ function suggest(input, candidates) {
1012
+ let best = null;
1013
+ let bestDist = Infinity;
1014
+ for (const c of candidates) {
1015
+ if (c === input) continue;
1016
+ const d = levenshtein(input.toUpperCase(), c.toUpperCase());
1017
+ if (d < bestDist) {
1018
+ best = c;
1019
+ bestDist = d;
1020
+ }
1021
+ }
1022
+ const limit = Math.max(1, Math.min(3, Math.floor(input.length / 4)));
1023
+ return bestDist <= limit ? best : null;
1024
+ }
1025
+ function plural(n, one, many = `${one}s`) {
1026
+ return `${n} ${n === 1 ? one : many}`;
1027
+ }
1028
+ function shortDate(iso) {
1029
+ return iso ? iso.slice(0, 10) : "";
1030
+ }
1031
+ //#endregion
890
1032
  //#region src/runner.ts
891
1033
  async function runCommand(s, opts) {
892
1034
  const { io, ctx } = s;
@@ -904,27 +1046,30 @@ async function runCommand(s, opts) {
904
1046
  const decision = decideRun(ctx, verifyScript(io, command), (await s.policy()).agentRun, display);
905
1047
  if (!decision.allow) throw new ArcaError(decision.message);
906
1048
  }
907
- const envFileValues = loadEnvFiles(project.root, project.config.envFiles);
1049
+ const envFileValues = loadEnvFiles(project.root, s.envFiles());
1050
+ const profileEnv = { ARCA_PROFILE: s.profile };
908
1051
  if (!s.hasVault()) {
909
1052
  const env = {
910
1053
  ...envFileValues,
911
- ...io.env
1054
+ ...io.env,
1055
+ ...profileEnv
912
1056
  };
913
1057
  const missing = missingRequired(s, project, env);
914
1058
  if (ctx.ci || opts.passthrough) {
915
- if (missing.length) throw new ArcaError(missingMessage(project, missing, true));
1059
+ if (missing.length) throw new ArcaError(missingMessage(s, missing, true));
1060
+ banner(s, null, false);
916
1061
  return spawnChild(s, command, env, null);
917
1062
  }
918
- if (missing.filter((n) => isSensitive(project.config.vars[n])).length) throw new ArcaError(missingMessage(project, missing, false));
919
- s.note(s.err("dim", `arca: no secrets stored for ${project.config.name} on this machine yet. Running without them.`));
1063
+ if (missing.filter((n) => isSensitive(project.config.vars[n])).length) throw new ArcaError(missingMessage(s, missing, false));
1064
+ s.note(s.err("dim", `arca: no secrets stored for ${s.forWhom()} on this machine yet. Running without them.`));
920
1065
  return spawnChild(s, command, env, null);
921
1066
  }
922
- const vault = await s.readVault();
1067
+ const values = await s.resolve();
923
1068
  const only = opts.only ? new Set(opts.only) : null;
924
1069
  const vaultValues = {};
925
- for (const [name, v] of Object.entries(vault.vars)) if (!only || only.has(name)) vaultValues[name] = v.value;
1070
+ for (const [name, v] of values) if (!only || only.has(name)) vaultValues[name] = v.value;
926
1071
  if (only) {
927
- const unknown = [...only].filter((n) => !(n in vault.vars));
1072
+ const unknown = [...only].filter((n) => !values.has(n));
928
1073
  if (unknown.length) s.note(s.err("yellow", `arca: --only names not stored in arca: ${unknown.join(", ")}`));
929
1074
  }
930
1075
  const overridden = Object.keys(vaultValues).filter((k) => io.env[k] !== void 0 && io.env[k] !== vaultValues[k]);
@@ -943,19 +1088,34 @@ async function runCommand(s, opts) {
943
1088
  s.note(s.err("dim", `arca: ${names} from arca ${verb} inherited from your shell (use --no-override to keep the shell's).`));
944
1089
  }
945
1090
  const missing = missingRequired(s, project, env);
946
- if (missing.length) throw new ArcaError(missingMessage(project, missing, false));
947
- const secrets = s.secrets(vault);
1091
+ if (missing.length) throw new ArcaError(missingMessage(s, missing, false));
1092
+ const secrets = await s.allSecrets();
1093
+ const injected = s.secretsOf(values).filter((x) => x.name in vaultValues);
948
1094
  if (!s.quiet) {
949
1095
  const findings = scanEnvFiles(project, s.envFilesForGuard(), secrets);
950
1096
  if (findings.length) s.note(s.err("yellow", summarizeFindings(findings)));
951
1097
  }
1098
+ banner(s, injected.length, true);
1099
+ Object.assign(env, profileEnv);
952
1100
  env.ARCA_ACTIVE = project.config.id;
953
1101
  env.ARCA_PROJECT = project.config.name;
954
1102
  delete env.ARCA_PASSPHRASE;
955
- addConsoleGuard(env, secrets.map((x) => x.name));
1103
+ addConsoleGuard(env, injected.map((x) => x.name));
956
1104
  return spawnChild(s, command, env, secrets.flatMap(needlesFor));
957
1105
  }
958
1106
  /**
1107
+ * One line naming the profile whenever it isn't the default, the same for every profile.
1108
+ * Information, not a warning: agents read it too, so they know which set is active.
1109
+ */
1110
+ function banner(s, secrets, masked) {
1111
+ if (s.quiet || s.profile === s.defaultProfile) return;
1112
+ const via = s.profileSource === "flag" ? "--profile" : s.profileSource === "env" ? "ARCA_PROFILE" : "arca use";
1113
+ const parts = [`profile ${s.profile} (via ${via})`];
1114
+ if (secrets !== null) parts.push(plural(secrets, "secret"));
1115
+ if (masked) parts.push("output masked");
1116
+ s.note(s.err("cyan", `arca ▸ ${parts.join(" · ")}`));
1117
+ }
1118
+ /**
959
1119
  * Preloads the console guard into every Node process the command starts, so values are
960
1120
  * masked before in-process console wrappers (e.g. dev tools that forward server logs to the
961
1121
  * browser) can see them. Only names are passed; the guard reads values from its own env.
@@ -978,15 +1138,15 @@ function missingRequired(s, project, env) {
978
1138
  let fileKeys = null;
979
1139
  return required.filter((name) => {
980
1140
  if (env[name] !== void 0 && env[name] !== "") return false;
981
- fileKeys ??= new Set(s.envFilesForGuard().filter((f) => !isTemplateFile(f.name)).flatMap((f) => f.entries.filter((e) => e.value !== "").map((e) => e.key)));
1141
+ fileKeys ??= new Set(s.envFilesFor().filter((f) => !isTemplateFile(f.name)).flatMap((f) => f.entries.filter((e) => e.value !== "").map((e) => e.key)));
982
1142
  return !fileKeys.has(name);
983
1143
  });
984
1144
  }
985
- function missingMessage(project, missing, ci) {
1145
+ function missingMessage(s, missing, ci) {
986
1146
  const n = missing.length;
987
- const head = `arca: ${n} required variable${n === 1 ? " is" : "s are"} not set for ${project.config.name}:\n ${missing.join(", ")}`;
1147
+ const head = `arca: ${n} required variable${n === 1 ? " is" : "s are"} not set for ${s.forWhom()}:\n ${missing.join(", ")}`;
988
1148
  if (ci) return `${head}\nAdd ${n === 1 ? "it" : "them"} to your CI or hosting platform's environment variables.`;
989
- return `${head}\nRun \`npx arca setup\` to set them. Takes about a minute. (AI agents: ask the user to run it.)`;
1149
+ return `${head}\nRun \`npx arca setup${s.profileFlag()}\` to set them. Takes about a minute. (AI agents: ask the user to run it.)`;
990
1150
  }
991
1151
  /** Spawns the command. With needles, stdout and stderr are piped through the masker. */
992
1152
  function spawnChild(s, command, env, needles) {
@@ -1160,6 +1320,38 @@ var Store = class {
1160
1320
  }
1161
1321
  return false;
1162
1322
  }
1323
+ /** Profiles with a vault file for this project. */
1324
+ vaultProfiles(id) {
1325
+ const dir = this.projectDir(id);
1326
+ if (!existsSync(dir)) return [];
1327
+ return readdirSync(dir).filter((f) => f.endsWith(".vault") && f !== "policy.vault").map((f) => f.slice(0, -6));
1328
+ }
1329
+ removeVault(id, profile) {
1330
+ this.withLock(id, () => {
1331
+ const file = this.vaultFile(id, profile);
1332
+ rmSync(file, { force: true });
1333
+ rmSync(`${file}.bak`, { force: true });
1334
+ });
1335
+ }
1336
+ stateFile(id) {
1337
+ return join(this.projectDir(id), "state.json");
1338
+ }
1339
+ readState(id) {
1340
+ const file = this.stateFile(id);
1341
+ if (!existsSync(file)) return {};
1342
+ try {
1343
+ const data = JSON.parse(readFileSync(file, "utf8"));
1344
+ return typeof data.activeProfile === "string" ? data : {};
1345
+ } catch {
1346
+ return {};
1347
+ }
1348
+ }
1349
+ writeState(id, state) {
1350
+ this.withLock(id, () => {
1351
+ if (state.activeProfile === void 0) rmSync(this.stateFile(id), { force: true });
1352
+ else atomicWrite(this.stateFile(id), JSON.stringify(state, null, 2) + "\n");
1353
+ });
1354
+ }
1163
1355
  projectIds() {
1164
1356
  const dir = join(this.root, "projects");
1165
1357
  return existsSync(dir) ? readdirSync(dir).filter((d) => d.startsWith("arc_")) : [];
@@ -1487,10 +1679,14 @@ var Session = class {
1487
1679
  project;
1488
1680
  out;
1489
1681
  err;
1490
- profile = DEFAULT_PROFILE;
1682
+ profile;
1683
+ profileSource;
1684
+ /** The `arca use` profile, when arca.json no longer declares it. */
1685
+ staleActiveProfile = null;
1491
1686
  quiet = false;
1492
1687
  json = false;
1493
1688
  master = null;
1689
+ vaults = /* @__PURE__ */ new Map();
1494
1690
  constructor(io, opts = {}) {
1495
1691
  this.io = io;
1496
1692
  this.store = new Store(storeRoot(io.env, io.platform));
@@ -1499,6 +1695,50 @@ var Session = class {
1499
1695
  this.project = loadProject(opts.projectDir ? resolve(io.cwd, opts.projectDir) : io.cwd);
1500
1696
  this.out = painter(io.stdout, io.env);
1501
1697
  this.err = painter(io.stderr, io.env);
1698
+ const picked = this.project ? this.pickProfile(this.project, opts.profile) : {
1699
+ profile: DEFAULT_PROFILE,
1700
+ source: "default"
1701
+ };
1702
+ this.profile = picked.profile;
1703
+ this.profileSource = picked.source;
1704
+ this.staleActiveProfile = picked.stale ?? null;
1705
+ }
1706
+ /** Precedence: --profile > ARCA_PROFILE > `arca use` > defaultProfile. */
1707
+ pickProfile(project, flag) {
1708
+ const { profiles, defaultProfile, id } = project.config;
1709
+ if (flag !== void 0) return {
1710
+ profile: this.checkProfile(project, flag, "--profile"),
1711
+ source: "flag"
1712
+ };
1713
+ const fromEnv = this.io.env.ARCA_PROFILE;
1714
+ const outer = this.ctx.activeProject;
1715
+ if (fromEnv && (!outer || outer === id)) return {
1716
+ profile: this.checkProfile(project, fromEnv, "ARCA_PROFILE"),
1717
+ source: "env"
1718
+ };
1719
+ const active = this.store.readState(id).activeProfile;
1720
+ if (active !== void 0) {
1721
+ if (active in profiles) return {
1722
+ profile: active,
1723
+ source: active === defaultProfile ? "default" : "use"
1724
+ };
1725
+ return {
1726
+ profile: defaultProfile,
1727
+ source: "default",
1728
+ stale: active
1729
+ };
1730
+ }
1731
+ return {
1732
+ profile: defaultProfile,
1733
+ source: "default"
1734
+ };
1735
+ }
1736
+ checkProfile(project, raw, via) {
1737
+ const name = normalizeProfile(raw);
1738
+ const names = Object.keys(project.config.profiles);
1739
+ if (name in project.config.profiles) return name;
1740
+ const guess = suggest(name, names);
1741
+ throw new ArcaError(`arca: profile "${raw}" (from ${via}) isn't declared in arca.json. ${names.length === 1 ? `${project.config.name} has one profile (${names[0]}). Add one with \`npx arca profiles add ${name}\`.` : `Profiles: ${names.join(", ")}.${guess ? ` Did you mean ${guess}?` : ""}`}`, 2);
1502
1742
  }
1503
1743
  print(text = "") {
1504
1744
  this.io.stdout.write(text + "\n");
@@ -1510,6 +1750,7 @@ var Session = class {
1510
1750
  warnConfig() {
1511
1751
  if (!this.project || this.quiet) return;
1512
1752
  for (const w of this.project.warnings) this.note(this.err("yellow", `arca: ${w}`));
1753
+ if (this.staleActiveProfile) this.note(this.err("yellow", `arca: the active profile "${this.staleActiveProfile}" (set with \`arca use\`) isn't in arca.json any more. Using ${this.profile}. \`npx arca use --reset\` clears it.`));
1513
1754
  }
1514
1755
  requireProject() {
1515
1756
  if (!this.project) throw new ArcaError("arca: no arca.json found in this folder or any parent. Run `npx arca init` to set up arca for this project.");
@@ -1518,8 +1759,60 @@ var Session = class {
1518
1759
  get projectId() {
1519
1760
  return this.requireProject().config.id;
1520
1761
  }
1521
- hasVault() {
1522
- return this.project ? this.store.hasVault(this.project.config.id, this.profile) : false;
1762
+ /** Every declared profile, default first. */
1763
+ get profiles() {
1764
+ if (!this.project) return [DEFAULT_PROFILE];
1765
+ const { profiles, defaultProfile } = this.project.config;
1766
+ return [defaultProfile, ...Object.keys(profiles).filter((p) => p !== defaultProfile)];
1767
+ }
1768
+ get defaultProfile() {
1769
+ return this.project?.config.defaultProfile ?? "default";
1770
+ }
1771
+ get multiProfile() {
1772
+ return this.profiles.length > 1;
1773
+ }
1774
+ /** "acme-web", or "acme-web (profile acceptatie)" once a project has several profiles. */
1775
+ forWhom(profile = this.profile) {
1776
+ const name = this.requireProject().config.name;
1777
+ return this.multiProfile ? `${name} (profile ${profile})` : name;
1778
+ }
1779
+ /** ` --profile X` for commands we tell people to run, when X isn't the default. */
1780
+ profileFlag(profile = this.profile) {
1781
+ return profile === this.defaultProfile ? "" : ` --profile ${profile}`;
1782
+ }
1783
+ chain(profile = this.profile) {
1784
+ return this.project ? profileChain(this.project.config, profile) : [profile];
1785
+ }
1786
+ envFiles(profile = this.profile) {
1787
+ return this.project ? profileEnvFiles(this.project.config, profile) : [];
1788
+ }
1789
+ /**
1790
+ * A vault written before the project declared profiles is `default.vault`. When arca.json
1791
+ * now names another default profile, those values belong to it.
1792
+ */
1793
+ legacyVaultPending() {
1794
+ const p = this.project;
1795
+ if (!p || p.config.defaultProfile === "default" || "default" in p.config.profiles) return false;
1796
+ return this.store.hasVault(p.config.id, "default") && !this.store.hasVault(p.config.id, p.config.defaultProfile);
1797
+ }
1798
+ async migrateLegacyVault() {
1799
+ if (!this.legacyVaultPending()) return;
1800
+ const { id, defaultProfile } = this.requireProject().config;
1801
+ const master = await this.masterKey();
1802
+ const data = this.store.readVault(master, id, DEFAULT_PROFILE);
1803
+ this.store.writeVault(master, id, defaultProfile, data);
1804
+ this.store.removeVault(id, DEFAULT_PROFILE);
1805
+ this.note(this.err("dim", `arca: moved the values stored before this project had profiles into profile ${defaultProfile}.`));
1806
+ }
1807
+ /** Whether `profile` itself has stored values on this machine. */
1808
+ ownsVault(profile) {
1809
+ if (!this.project) return false;
1810
+ if (this.store.hasVault(this.project.config.id, profile)) return true;
1811
+ return profile === this.project.config.defaultProfile && this.legacyVaultPending();
1812
+ }
1813
+ /** Whether the profile or any profile it extends has stored values. */
1814
+ hasVault(profile = this.profile) {
1815
+ return this.chain(profile).some((p) => this.ownsVault(p));
1523
1816
  }
1524
1817
  async masterKey(create = false) {
1525
1818
  if (this.master) return this.master;
@@ -1527,15 +1820,22 @@ var Session = class {
1527
1820
  if (!this.master) throw new ArcaError("arca: no master key.");
1528
1821
  return this.master;
1529
1822
  }
1530
- /** The project's vault. Doesn't touch the keychain when no vault exists yet. */
1531
- async readVault() {
1532
- if (!this.hasVault()) return emptyVault();
1533
- return this.store.readVault(await this.masterKey(), this.projectId, this.profile);
1534
- }
1535
- async writeVault(data) {
1823
+ /** A profile's own values. Doesn't touch the keychain when it has no vault. */
1824
+ async readVault(profile = this.profile) {
1825
+ const cached = this.vaults.get(profile);
1826
+ if (cached) return cached;
1827
+ if (!this.ownsVault(profile)) return emptyVault();
1828
+ if (profile === this.defaultProfile) await this.migrateLegacyVault();
1829
+ const data = this.store.readVault(await this.masterKey(), this.projectId, profile);
1830
+ this.vaults.set(profile, data);
1831
+ return data;
1832
+ }
1833
+ async writeVault(data, profile = this.profile) {
1536
1834
  const project = this.requireProject();
1537
1835
  const master = await this.masterKey(true);
1538
- this.store.writeVault(master, project.config.id, this.profile, data);
1836
+ if (profile === this.defaultProfile) await this.migrateLegacyVault();
1837
+ this.store.writeVault(master, project.config.id, profile, data);
1838
+ this.vaults.set(profile, data);
1539
1839
  const prev = this.store.touchRegistry(project.config.id, {
1540
1840
  name: project.config.name,
1541
1841
  root: project.root,
@@ -1543,6 +1843,19 @@ var Session = class {
1543
1843
  });
1544
1844
  this.warnCollision(prev);
1545
1845
  }
1846
+ /** The values a profile sees: its own first, then each profile up its `extends` chain. */
1847
+ async resolve(profile = this.profile) {
1848
+ const out = /* @__PURE__ */ new Map();
1849
+ for (const p of this.chain(profile)) {
1850
+ const vault = await this.readVault(p);
1851
+ for (const [name, v] of Object.entries(vault.vars)) if (!out.has(name)) out.set(name, {
1852
+ value: v.value,
1853
+ profile: p,
1854
+ updatedAt: v.updatedAt
1855
+ });
1856
+ }
1857
+ return out;
1858
+ }
1546
1859
  warnCollision(prev) {
1547
1860
  const project = this.project;
1548
1861
  if (!project || !prev?.gitRemote) return;
@@ -1553,14 +1866,33 @@ var Session = class {
1553
1866
  if (!this.project || !this.store.hasPolicy(this.project.config.id)) return { agentRun: "scripts" };
1554
1867
  return this.store.readPolicy(await this.masterKey(), this.project.config.id);
1555
1868
  }
1556
- /** Stored values that count as secrets (masking needles, leak-guard exact matches). */
1557
- secrets(vault) {
1869
+ /** Stored values of `profile` (with its chain) that count as secrets. */
1870
+ secretsOf(values) {
1558
1871
  const decls = this.project?.config.vars ?? {};
1559
- return Object.entries(vault.vars).filter(([name]) => isSensitive(decls[name])).map(([name, v]) => ({
1872
+ return [...values].filter(([name]) => isSensitive(decls[name])).map(([name, v]) => ({
1560
1873
  name,
1561
1874
  value: v.value
1562
1875
  }));
1563
1876
  }
1877
+ /**
1878
+ * Masking needles and leak-guard matches: the active profile's secrets first, then the
1879
+ * values of every other profile, named `NAME@profile` so output says where they belong.
1880
+ */
1881
+ async allSecrets(profile = this.profile) {
1882
+ if (!this.project) return [];
1883
+ await this.migrateLegacyVault();
1884
+ const out = this.secretsOf(await this.resolve(profile));
1885
+ const decls = this.project.config.vars;
1886
+ const others = /* @__PURE__ */ new Set([...this.profiles, ...this.store.vaultProfiles(this.project.config.id)]);
1887
+ for (const p of others) {
1888
+ if (p === profile || !this.ownsVault(p)) continue;
1889
+ for (const [name, v] of Object.entries((await this.readVault(p)).vars)) if (isSensitive(decls[name])) out.push({
1890
+ name: `${name}@${p}`,
1891
+ value: v.value
1892
+ });
1893
+ }
1894
+ return out;
1895
+ }
1564
1896
  /** Nearest directory with a package.json, from cwd up to the project root. */
1565
1897
  packageRoot() {
1566
1898
  let dir = resolve(this.io.cwd);
@@ -1571,14 +1903,15 @@ var Session = class {
1571
1903
  dir = parent;
1572
1904
  }
1573
1905
  }
1574
- /** Every .env* file the leak guard reads: project root, nearest package root, and envFiles. */
1906
+ /** Every .env* file the leak guard reads: project root, nearest package root, and every profile's envFiles. */
1575
1907
  envFilesForGuard() {
1576
1908
  const project = this.requireProject();
1577
1909
  const dirs = [project.root];
1578
1910
  const pkg = this.packageRoot();
1579
1911
  if (pkg && pkg !== project.root) dirs.push(pkg);
1580
1912
  const files = findEnvFiles(dirs);
1581
- for (const f of project.config.envFiles) {
1913
+ const listed = /* @__PURE__ */ new Set([...project.config.envFiles, ...Object.values(project.config.profiles).flatMap((p) => p.envFiles)]);
1914
+ for (const f of listed) {
1582
1915
  const path = resolve(project.root, f);
1583
1916
  if (files.some((x) => x.path === path)) continue;
1584
1917
  const file = readEnvFile(path, f);
@@ -1586,6 +1919,13 @@ var Session = class {
1586
1919
  }
1587
1920
  return files;
1588
1921
  }
1922
+ /** The .env* files that can provide values for `profile`: all of them, minus other profiles' own envFiles. */
1923
+ envFilesFor(profile = this.profile) {
1924
+ const project = this.requireProject();
1925
+ const mine = new Set(this.envFiles(profile).map((f) => resolve(project.root, f)));
1926
+ const others = new Set(Object.values(project.config.profiles).flatMap((p) => p.envFiles).map((f) => resolve(project.root, f)).filter((f) => !mine.has(f)));
1927
+ return this.envFilesForGuard().filter((f) => !others.has(f.path));
1928
+ }
1589
1929
  };
1590
1930
  function safeMarkers(store) {
1591
1931
  try {
@@ -1615,42 +1955,6 @@ function gitRemote(root) {
1615
1955
  }
1616
1956
  }
1617
1957
  //#endregion
1618
- //#region src/util.ts
1619
- function levenshtein(a, b) {
1620
- const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
1621
- for (let i = 1; i <= a.length; i++) {
1622
- let prev = dp[0];
1623
- dp[0] = i;
1624
- for (let j = 1; j <= b.length; j++) {
1625
- const tmp = dp[j];
1626
- dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
1627
- prev = tmp;
1628
- }
1629
- }
1630
- return dp[b.length];
1631
- }
1632
- /** The closest candidate within a small edit distance, for "did you mean …?". */
1633
- function suggest(input, candidates) {
1634
- let best = null;
1635
- let bestDist = Infinity;
1636
- for (const c of candidates) {
1637
- if (c === input) continue;
1638
- const d = levenshtein(input.toUpperCase(), c.toUpperCase());
1639
- if (d < bestDist) {
1640
- best = c;
1641
- bestDist = d;
1642
- }
1643
- }
1644
- const limit = Math.max(1, Math.min(3, Math.floor(input.length / 4)));
1645
- return bestDist <= limit ? best : null;
1646
- }
1647
- function plural(n, one, many = `${one}s`) {
1648
- return `${n} ${n === 1 ? one : many}`;
1649
- }
1650
- function shortDate(iso) {
1651
- return iso ? iso.slice(0, 10) : "";
1652
- }
1653
- //#endregion
1654
1958
  //#region src/commands/values.ts
1655
1959
  const now = () => (/* @__PURE__ */ new Date()).toISOString();
1656
1960
  function storeValue(vault, name, value, source) {
@@ -1677,7 +1981,7 @@ async function setCommand(s, opts) {
1677
1981
  const fromStdin = opts.value === void 0 && opts.generate === void 0 && !s.io.stdinIsTTY;
1678
1982
  await humanGate(s.io, s.ctx, {
1679
1983
  kind: "write",
1680
- command: `npx arca set ${name}`,
1984
+ command: `npx arca set ${name}${s.profileFlag()}`,
1681
1985
  allowPipedStdin: fromStdin
1682
1986
  });
1683
1987
  const declared = project.config.vars;
@@ -1715,26 +2019,37 @@ async function setCommand(s, opts) {
1715
2019
  if (isSensitive(decl) && value.length < 6) s.note(s.err("yellow", `⚠ ${name} is shorter than 6 characters, so it can't be masked in output.`));
1716
2020
  if (decl && !isSensitive(decl)) s.note(s.err("dim", `arca: ${name} is declared as config (sensitive: false). It's stored in arca anyway and arca's value wins over .env.`));
1717
2021
  const what = opts.generate !== void 0 ? "Generated" : existed ? "Updated" : "Set";
1718
- s.note(`${s.err("green", "✔")} ${what} ${name} for ${project.config.name}${existed ? " (previous value kept, `npx arca restore " + name + "` undoes this)" : ""}`);
2022
+ const undo = existed ? ` (previous value kept, \`npx arca restore ${name}${s.profileFlag()}\` undoes this)` : "";
2023
+ s.note(`${s.err("green", "✔")} ${what} ${name} for ${s.forWhom()}${undo}`);
1719
2024
  return 0;
1720
2025
  }
2026
+ /** Error for a name the profile only inherits, so it can't be changed or removed there. */
2027
+ function inheritedError(s, name, from, verb) {
2028
+ return new ArcaError(`arca: ${name} isn't stored in profile ${s.profile} itself, it's inherited from ${from}.\n To ${verb} it for every profile that extends ${from}: npx arca ${verb} ${name}${s.profileFlag(from)}`);
2029
+ }
1721
2030
  async function unsetCommand(s, rawName) {
1722
- const project = s.requireProject();
2031
+ s.requireProject();
1723
2032
  const name = checkName(rawName, "npx arca unset NAME");
1724
2033
  await humanGate(s.io, s.ctx, {
1725
2034
  kind: "write",
1726
- command: `npx arca unset ${name}`
2035
+ command: `npx arca unset ${name}${s.profileFlag()}`
1727
2036
  });
1728
2037
  const vault = await s.readVault();
1729
2038
  const v = vault.vars[name];
1730
- if (!v) throw new ArcaError(`arca: ${name} is not stored in arca for ${project.config.name}.`);
2039
+ if (!v) {
2040
+ const inherited = (await s.resolve()).get(name);
2041
+ if (inherited) throw inheritedError(s, name, inherited.profile, "unset");
2042
+ throw new ArcaError(`arca: ${name} is not stored in arca for ${s.forWhom()}.`);
2043
+ }
1731
2044
  vault.removed[name] = {
1732
2045
  value: v.value,
1733
2046
  updatedAt: v.updatedAt
1734
2047
  };
1735
2048
  delete vault.vars[name];
1736
2049
  await s.writeVault(vault);
1737
- s.note(`${s.err("green", "✔")} Removed ${name}. \`npx arca restore ${name}\` brings it back.`);
2050
+ const fallback = (await s.resolve()).get(name);
2051
+ s.note(`${s.err("green", "✔")} Removed ${name}${s.multiProfile ? ` from profile ${s.profile}` : ""}. \`npx arca restore ${name}${s.profileFlag()}\` brings it back.`);
2052
+ if (fallback) s.note(s.err("dim", ` ${s.profile} now inherits ${name} from ${fallback.profile}.`));
1738
2053
  return 0;
1739
2054
  }
1740
2055
  async function restoreCommand(s, rawName) {
@@ -1742,7 +2057,7 @@ async function restoreCommand(s, rawName) {
1742
2057
  const name = checkName(rawName, "npx arca restore NAME");
1743
2058
  await humanGate(s.io, s.ctx, {
1744
2059
  kind: "write",
1745
- command: `npx arca restore ${name}`
2060
+ command: `npx arca restore ${name}${s.profileFlag()}`
1746
2061
  });
1747
2062
  const vault = await s.readVault();
1748
2063
  const current = vault.vars[name];
@@ -1756,23 +2071,27 @@ async function restoreCommand(s, rawName) {
1756
2071
  }
1757
2072
  };
1758
2073
  else if (!current && vault.removed[name]) storeValue(vault, name, vault.removed[name].value, "restore");
1759
- else throw new ArcaError(`arca: there's no previous value of ${name} to restore.`);
2074
+ else {
2075
+ const inherited = (await s.resolve()).get(name);
2076
+ if (!current && inherited) throw inheritedError(s, name, inherited.profile, "restore");
2077
+ throw new ArcaError(`arca: there's no previous value of ${name} to restore${s.multiProfile ? ` in profile ${s.profile}` : ""}.`);
2078
+ }
1760
2079
  await s.writeVault(vault);
1761
2080
  s.note(`${s.err("green", "✔")} Restored the previous value of ${name}. Run it again to undo.`);
1762
2081
  return 0;
1763
2082
  }
1764
2083
  async function revealCommand(s, rawName) {
1765
- const project = s.requireProject();
2084
+ s.requireProject();
1766
2085
  const name = checkName(rawName, "npx arca reveal NAME");
1767
2086
  await humanGate(s.io, s.ctx, {
1768
2087
  kind: "reveal",
1769
- command: `npx arca reveal ${name}`
2088
+ command: `npx arca reveal ${name}${s.profileFlag()}`
1770
2089
  });
1771
- const vault = await s.readVault();
1772
- const v = vault.vars[name];
2090
+ const values = await s.resolve();
2091
+ const v = values.get(name);
1773
2092
  if (!v) {
1774
- const guess = suggest(name, Object.keys(vault.vars));
1775
- throw new ArcaError(`arca: ${name} is not stored in arca for ${project.config.name}.${guess ? ` Did you mean ${guess}?` : ""}`);
2093
+ const guess = suggest(name, values.keys());
2094
+ throw new ArcaError(`arca: ${name} is not stored in arca for ${s.forWhom()}.${guess ? ` Did you mean ${guess}?` : ""}`);
1776
2095
  }
1777
2096
  s.print(v.value);
1778
2097
  return 0;
@@ -1787,10 +2106,9 @@ async function exportCommand(s, opts) {
1787
2106
  ].includes(format)) throw new ArcaError("arca: --format must be dotenv, json or shell.", 2);
1788
2107
  await humanGate(s.io, s.ctx, {
1789
2108
  kind: "reveal",
1790
- command: "npx arca export"
2109
+ command: `npx arca export${s.profileFlag()}`
1791
2110
  });
1792
- const vault = await s.readVault();
1793
- const entries = Object.entries(vault.vars).map(([k, v]) => [k, v.value]);
2111
+ const entries = [...await s.resolve()].map(([k, v]) => [k, v.value]);
1794
2112
  let text;
1795
2113
  if (format === "json") text = JSON.stringify(Object.fromEntries(entries), null, 2);
1796
2114
  else if (format === "shell") text = entries.map(([k, v]) => `export ${k}='${v.replace(/'/g, `'\\''`)}'`).join("\n");
@@ -1806,15 +2124,16 @@ async function setupCommand(s, opts) {
1806
2124
  const project = s.requireProject();
1807
2125
  await humanGate(s.io, s.ctx, {
1808
2126
  kind: "write",
1809
- command: "npx arca setup"
2127
+ command: `npx arca setup${s.profileFlag()}`
1810
2128
  });
1811
2129
  const vault = await s.readVault();
1812
- const todo = Object.entries(project.config.vars).filter(([name, d]) => isSensitive(d) && !(name in vault.vars) && (opts.all || d.required));
2130
+ const inherited = await s.resolve();
2131
+ const todo = Object.entries(project.config.vars).filter(([name, d]) => isSensitive(d) && !inherited.has(name) && (opts.all || d.required));
1813
2132
  if (todo.length === 0) {
1814
- s.note(`${s.err("green", "✔")} All ${opts.all ? "declared" : "required"} secrets are set for ${project.config.name}.`);
2133
+ s.note(`${s.err("green", "✔")} All ${opts.all ? "declared" : "required"} secrets are set for ${s.forWhom()}.`);
1815
2134
  return 0;
1816
2135
  }
1817
- s.note(`arca: ${plural(todo.length, "variable")} to set for ${project.config.name}. Leave a prompt empty to skip it.\n`);
2136
+ s.note(`arca: ${plural(todo.length, "variable")} to set for ${s.forWhom()}. Leave a prompt empty to skip it.\n`);
1818
2137
  let set = 0;
1819
2138
  for (const [name, d] of todo) {
1820
2139
  s.note(`${s.err("cyan", "◆")} ${s.err("bold", name)}${d.description ? `: ${d.description}` : ""}`);
@@ -1841,7 +2160,7 @@ async function setupCommand(s, opts) {
1841
2160
  }
1842
2161
  const left = todo.length - set;
1843
2162
  if (left === 0) s.note(`\n${s.err("green", "✔")} All ${opts.all ? "declared" : "required"} variables set. Run \`npm run dev\`.`);
1844
- else s.note(`\narca: set ${set}, ${left} still missing. Run \`npx arca setup\` again later.`);
2163
+ else s.note(`\narca: set ${set}, ${left} still missing. Run \`npx arca setup${s.profileFlag()}\` again later.`);
1845
2164
  return left === 0 ? 0 : 1;
1846
2165
  }
1847
2166
  /** Decides which entries of which files are secrets. Shared with `arca init`. */
@@ -1897,13 +2216,14 @@ async function importCommand(s, opts) {
1897
2216
  const project = s.requireProject();
1898
2217
  await humanGate(s.io, s.ctx, {
1899
2218
  kind: "write",
1900
- command: "npx arca import" + (opts.delete ? " --delete" : "")
2219
+ command: `npx arca import${s.profileFlag()}${opts.delete ? " --delete" : ""}`
1901
2220
  });
1902
2221
  const files = opts.files.length ? opts.files : defaultImportFiles(s);
1903
2222
  if (files.length === 0) throw new ArcaError(`arca: no .env files found in ${project.root}. Name the file: npx arca import path/to/.env`);
1904
2223
  const plan = planImport(s, files, opts);
1905
2224
  if (plan.sources.length === 0) throw new ArcaError(`arca: none of these files exist: ${files.join(", ")}`);
1906
2225
  const vault = await s.readVault();
2226
+ const resolved = await s.resolve();
1907
2227
  const conflicts = [...plan.secrets].filter(([k, v]) => vault.vars[k] && vault.vars[k].value !== v.value).map(([k]) => k);
1908
2228
  let overwrite = Boolean(opts.yes);
1909
2229
  if (conflicts.length && !opts.yes) {
@@ -1912,19 +2232,29 @@ async function importCommand(s, opts) {
1912
2232
  overwrite = answer;
1913
2233
  }
1914
2234
  const imported = [];
2235
+ const inheritedSame = [];
2236
+ let changed = false;
1915
2237
  for (const [key, v] of plan.secrets) {
1916
2238
  if (vault.vars[key]?.value === v.value) {
1917
2239
  imported.push(key);
1918
2240
  continue;
1919
2241
  }
2242
+ const parent = resolved.get(key);
2243
+ if (!vault.vars[key] && parent && parent.profile !== s.profile && parent.value === v.value) {
2244
+ imported.push(key);
2245
+ inheritedSame.push(key);
2246
+ continue;
2247
+ }
1920
2248
  if (vault.vars[key] && !overwrite) continue;
1921
2249
  storeValue(vault, key, v.value, `import:${v.file}`);
1922
2250
  imported.push(key);
2251
+ changed = true;
1923
2252
  }
1924
- if (imported.length) await s.writeVault(vault);
2253
+ if (changed) await s.writeVault(vault);
1925
2254
  const undeclared = imported.filter((k) => !(k in project.config.vars));
1926
2255
  if (undeclared.length) declareVars(project.file, Object.fromEntries(undeclared.map((k) => [k, {}])));
1927
- s.note(`${s.err("green", "✔")} Imported ${plural(imported.length, "secret")} into arca${imported.length ? `: ${imported.join(", ")}` : ""}`);
2256
+ s.note(`${s.err("green", "✔")} Imported ${plural(imported.length, "secret")} into arca${s.multiProfile ? ` (profile ${s.profile})` : ""}${imported.length ? `: ${imported.join(", ")}` : ""}`);
2257
+ if (inheritedSame.length) s.note(s.err("dim", ` same value as the profile it extends, so still inherited: ${inheritedSame.join(", ")}`));
1928
2258
  if (undeclared.length) s.note(`${s.err("green", "✔")} Declared ${undeclared.join(", ")} in arca.json`);
1929
2259
  const config = plan.skipped.filter((x) => x.why !== "empty");
1930
2260
  if (config.length) s.note(s.err("dim", ` stays in .env: ${config.map((x) => x.key).join(", ")}${opts.includePublic ? "" : " (use --include-public or --only to import these too)"}`));
@@ -2077,30 +2407,46 @@ async function initCommand(s, opts) {
2077
2407
  for (const k of stays) decls[k] = { sensitive: false };
2078
2408
  declareVars(configFile, decls);
2079
2409
  }
2080
- if (otherFiles.length) step(`Left alone: ${otherFiles.join(", ")} (separate sets of values arrive with profiles in a later version)`);
2410
+ const plans = await planProfiles(s, configFile, otherFiles, merged, secretKeys, ask);
2411
+ const modeFiles = otherFiles.filter((n) => MODE_FILE.test(n));
2412
+ const leftAlone = otherFiles.filter((n) => !modeFiles.includes(n) && !plans.some((p) => p.file.name === n));
2413
+ if (modeFiles.length) step(`Left alone: ${modeFiles.join(", ")} (framework mode files, your framework loads them itself)`);
2414
+ if (leftAlone.length) step(`Left alone: ${leftAlone.join(", ")}`);
2081
2415
  ok(`${created ? "Created" : "Updated"} arca.json${created ? ` (id ${parseProject(readFileSync(configFile, "utf8"), configFile).config.id})` : ""}`);
2082
2416
  const session = new Session(io);
2083
2417
  let imported = 0;
2084
- if (secretKeys.length && opts.import !== false) try {
2418
+ const profileImports = plans.filter((p) => p.store.size > 0);
2419
+ if ((secretKeys.length || profileImports.length) && opts.import !== false) try {
2085
2420
  await humanGate(io, session.ctx, {
2086
2421
  kind: "write",
2087
2422
  command: "npx arca import"
2088
2423
  });
2089
2424
  const hadKey = session.store.readGlobalConfig().keychain !== void 0;
2090
- const vault = await session.readVault();
2091
- for (const k of secretKeys) {
2092
- const v = merged.get(k);
2093
- if (v === "") continue;
2094
- storeValue(vault, k, v, `import:${[...devFiles].reverse().find((f) => f.entries.some((e) => e.key === k))?.name ?? ".env"}`);
2095
- imported++;
2096
- }
2097
- await session.writeVault(vault);
2425
+ if (secretKeys.length) {
2426
+ const vault = await session.readVault();
2427
+ for (const k of secretKeys) {
2428
+ const v = merged.get(k);
2429
+ if (v === "") continue;
2430
+ storeValue(vault, k, v, `import:${[...devFiles].reverse().find((f) => f.entries.some((e) => e.key === k))?.name ?? ".env"}`);
2431
+ imported++;
2432
+ }
2433
+ await session.writeVault(vault);
2434
+ }
2435
+ for (const plan of profileImports) {
2436
+ const ps = new Session(io, { profile: plan.name });
2437
+ const vault = await ps.readVault();
2438
+ for (const [k, v] of plan.store) storeValue(vault, k, v, `import:${plan.file.name}`);
2439
+ await ps.writeVault(vault);
2440
+ imported += plan.store.size;
2441
+ }
2098
2442
  if (!hadKey) ok(`Master key created in ${chooseBackend(session.store, io) === "os" ? keychainLabel(io.platform) : "a passphrase-protected file"}`);
2099
- ok(`Imported ${plural(imported, "secret")} into arca`);
2443
+ const per = profileImports.length ? ` (${[`${session.defaultProfile}: ${secretKeys.length}`, ...profileImports.map((p) => `${p.name}: ${p.store.size}`)].join(", ")})` : "";
2444
+ ok(`Imported ${plural(imported, "secret")} into arca${per}`);
2100
2445
  ok("Your .env files were not changed");
2101
2446
  } catch (e) {
2102
2447
  if (!(e instanceof ArcaError)) throw e;
2103
2448
  s.note(`${s.err("yellow", "✋")} Moving secret values into arca needs a human. Ask the user to run: npx arca import`);
2449
+ for (const p of profileImports) s.note(` and: npx arca import ${p.file.name} --profile ${p.name}`);
2104
2450
  s.note(" (the .env files stay untouched)");
2105
2451
  }
2106
2452
  if (pkg?.scripts) {
@@ -2172,11 +2518,75 @@ async function initCommand(s, opts) {
2172
2518
  };
2173
2519
  if (pkg && !deps["@bremmdev/arca-env"]) s.note(`\nInstall arca in this project: npm i -D @bremmdev/arca-env`);
2174
2520
  if (imported) {
2175
- s.note("\n" + s.err("yellow", `⚠ The ${plural(imported, "secret")} still have plaintext copies in ${devFiles.map((f) => f.name).join(", ")}.`));
2176
- s.note(" Remove just those entries (config lines stay): npx arca import --delete");
2521
+ const withSecrets = plans.filter((p) => p.secrets.size > 0);
2522
+ const files = [...secretKeys.length ? devFiles.map((f) => f.name) : [], ...withSecrets.map((p) => p.file.name)];
2523
+ s.note("\n" + s.err("yellow", `⚠ The ${plural(imported, "secret")} still have plaintext copies in ${files.join(", ")}.`));
2524
+ s.note(" Remove just those entries (config lines stay):");
2525
+ if (secretKeys.length) s.note(" npx arca import --delete");
2526
+ for (const p of withSecrets) s.note(` npx arca import ${p.file.name} --profile ${p.name} --delete`);
2177
2527
  } else if (!secretKeys.length) s.note("\nNext: npx arca set DATABASE_URL or npx arca setup");
2528
+ if (plans.length) s.note(`\nSwitch profiles with npx arca use ${plans[0].name} (and back with npx arca use --reset)`);
2178
2529
  return 0;
2179
2530
  }
2531
+ /** Files Next.js and Vite load by themselves, depending on the mode. Not profiles. */
2532
+ const MODE_FILE = /^\.env\.(development|production|test)(\.local)?$/;
2533
+ /**
2534
+ * `.env.acceptatie` → profile `acceptatie`. Secrets go into the profile's vault and config lines
2535
+ * stay in the file, which the profile lists in `envFiles`. When the file shares most secrets with
2536
+ * the default set (or leaves some out), the profile extends the default and stores only what differs.
2537
+ */
2538
+ async function planProfiles(s, configFile, candidates, defaults, defaultSecrets, ask) {
2539
+ const config = parseProject(readFileSync(configFile, "utf8"), configFile).config;
2540
+ const plans = [];
2541
+ for (const fileName of candidates) {
2542
+ const m = /^\.env\.([A-Za-z0-9_-]+)$/.exec(fileName);
2543
+ if (!m || MODE_FILE.test(fileName)) continue;
2544
+ const name = normalizeProfile(m[1]);
2545
+ if (invalidProfileName(name) || name in config.profiles) continue;
2546
+ const file = readEnvFile(join(s.io.cwd, fileName), fileName);
2547
+ if (!file || file.entries.length === 0) continue;
2548
+ const secrets = /* @__PURE__ */ new Map();
2549
+ const configKeys = [];
2550
+ for (const e of file.entries) {
2551
+ if (e.value === "") continue;
2552
+ const d = config.vars[e.key];
2553
+ if (d ? isSensitive(d) : defaultSecrets.includes(e.key) || Boolean(secretReason(e.key, e.value))) secrets.set(e.key, e.value);
2554
+ else configKeys.push(e.key);
2555
+ }
2556
+ const same = [...secrets].filter(([k, v]) => defaultSecrets.includes(k) && defaults.get(k) === v).length;
2557
+ const leavesOut = defaultSecrets.some((k) => !secrets.has(k));
2558
+ const extend = defaultSecrets.length > 0 && (leavesOut || same * 2 >= secrets.size);
2559
+ const store = new Map([...secrets].filter(([k, v]) => !extend || !defaultSecrets.includes(k) || defaults.get(k) !== v));
2560
+ if (!await ask(`Create profile ${name} from ${fileName} (${extend ? `extends ${config.defaultProfile}, ${plural(store.size, "secret")} of its own` : `${plural(secrets.size, "secret")}`}${configKeys.length ? `, ${plural(configKeys.length, "config line")} ${configKeys.length === 1 ? "stays" : "stay"} in the file` : ""})?`, true)) continue;
2561
+ plans.push({
2562
+ name,
2563
+ file,
2564
+ extends: extend ? config.defaultProfile : void 0,
2565
+ secrets,
2566
+ store,
2567
+ config: configKeys
2568
+ });
2569
+ }
2570
+ if (plans.length === 0) return plans;
2571
+ updateConfigFile(configFile, (raw) => {
2572
+ const profiles = raw.profiles && typeof raw.profiles === "object" && Object.keys(raw.profiles).length > 0 ? raw.profiles : { [config.defaultProfile]: {} };
2573
+ for (const p of plans) {
2574
+ const decl = {};
2575
+ if (p.extends) decl.extends = p.extends;
2576
+ if (p.config.length) decl.envFiles = [p.file.name];
2577
+ profiles[p.name] = decl;
2578
+ }
2579
+ raw.profiles = profiles;
2580
+ });
2581
+ const decls = {};
2582
+ for (const p of plans) {
2583
+ for (const k of p.secrets.keys()) decls[k] ??= {};
2584
+ for (const k of p.config) decls[k] ??= { sensitive: false };
2585
+ }
2586
+ declareVars(configFile, decls);
2587
+ for (const p of plans) s.note(`${s.err("green", "✔")} Profile ${p.name}${p.extends ? ` (extends ${p.extends})` : ""}: ${p.store.size ? `${p.store.size} → arca` : "no secrets of its own"}${p.config.length ? `, config stays in ${p.file.name}` : ""}`);
2588
+ return plans;
2589
+ }
2180
2590
  /** The arca section of an AGENTS.md: from its heading to the next heading or HTML comment block. */
2181
2591
  function findSection(text, marker) {
2182
2592
  const start = text.indexOf(marker);
@@ -2239,14 +2649,16 @@ function keychainLabel(platform) {
2239
2649
  }
2240
2650
  //#endregion
2241
2651
  //#region src/commands/info.ts
2242
- function rows(s, vault) {
2652
+ function rows(s, values, profile = s.profile) {
2243
2653
  const project = s.requireProject();
2244
2654
  const decls = project.config.vars;
2245
- const files = s.envFilesForGuard().filter((f) => !isTemplateFile(f.name));
2246
- const inFile = (name) => files.find((f) => f.entries.some((e) => e.key === name && e.value !== ""));
2247
- return [.../* @__PURE__ */ new Set([...Object.keys(decls), ...Object.keys(vault.vars)])].map((name) => {
2655
+ const files = s.envFilesFor(profile).filter((f) => !isTemplateFile(f.name));
2656
+ const loaded = s.envFiles(profile).map((f) => resolve(project.root, f));
2657
+ const ranked = [...files].sort((a, b) => loaded.indexOf(b.path) - loaded.indexOf(a.path));
2658
+ const inFile = (name) => ranked.find((f) => f.entries.some((e) => e.key === name && e.value !== ""));
2659
+ return [.../* @__PURE__ */ new Set([...Object.keys(decls), ...values.keys()])].map((name) => {
2248
2660
  const d = decls[name];
2249
- const stored = vault.vars[name];
2661
+ const stored = values.get(name);
2250
2662
  const sensitive = isSensitive(d);
2251
2663
  const file = inFile(name);
2252
2664
  const base = {
@@ -2259,6 +2671,8 @@ function rows(s, vault) {
2259
2671
  if (stored) return {
2260
2672
  ...base,
2261
2673
  status: "arca",
2674
+ profile: stored.profile,
2675
+ inherited: stored.profile !== profile,
2262
2676
  updatedAt: stored.updatedAt
2263
2677
  };
2264
2678
  if (file) return {
@@ -2274,7 +2688,7 @@ function rows(s, vault) {
2274
2688
  }
2275
2689
  function statusText(r, p) {
2276
2690
  switch (r.status) {
2277
- case "arca": return p("green", "✔ arca");
2691
+ case "arca": return r.inherited ? p("green", `✔ arca ↳ ${r.profile}`) : p("green", "✔ arca");
2278
2692
  case "env-file": return p("cyan", `✔ ${r.source}`);
2279
2693
  case "env-file-sensitive": return p("yellow", `⚠ ${r.source} (should be in arca)`);
2280
2694
  case "missing": return p("red", "✖ missing (required)");
@@ -2282,11 +2696,11 @@ function statusText(r, p) {
2282
2696
  }
2283
2697
  }
2284
2698
  async function listCommand(s) {
2285
- const project = s.requireProject();
2699
+ s.requireProject();
2286
2700
  s.warnConfig();
2287
- const list = rows(s, await s.readVault());
2701
+ const list = rows(s, await s.resolve());
2288
2702
  if (s.json) {
2289
- s.print(JSON.stringify(list.map(({ description, ...r }) => ({
2703
+ s.print(JSON.stringify(list.map(({ description, inherited, ...r }) => ({
2290
2704
  ...r,
2291
2705
  description: description ?? null
2292
2706
  })), null, 2));
@@ -2294,9 +2708,10 @@ async function listCommand(s) {
2294
2708
  }
2295
2709
  const p = s.out;
2296
2710
  if (list.length === 0) {
2297
- s.print(`No variables declared or stored for ${project.config.name} yet. Declare them in arca.json, then run \`npx arca setup\`.`);
2711
+ s.print(`No variables declared or stored for ${s.forWhom()} yet. Declare them in arca.json, then run \`npx arca setup${s.profileFlag()}\`.`);
2298
2712
  return 0;
2299
2713
  }
2714
+ if (s.multiProfile && s.profileSource !== "flag") return listMatrix(s);
2300
2715
  const w = Math.max(4, ...list.map((r) => r.name.length)) + 2;
2301
2716
  const sw = Math.max(...list.map((r) => statusText(r, (_, t) => t).length)) + 2;
2302
2717
  s.print(p("bold", `${pad("NAME", w)}${pad("STATUS", sw)}${pad("KIND", 9)}UPDATED`));
@@ -2307,37 +2722,108 @@ async function listCommand(s) {
2307
2722
  }
2308
2723
  return 0;
2309
2724
  }
2725
+ /**
2726
+ * The `arca list` matrix:
2727
+ * ontwikkel ● acceptatie test
2728
+ * DATABASE_URL ✔ set ✔ set ↳ ontwikkel
2729
+ */
2730
+ async function listMatrix(s) {
2731
+ const p = s.out;
2732
+ const profiles = s.profiles;
2733
+ const tables = /* @__PURE__ */ new Map();
2734
+ for (const profile of profiles) tables.set(profile, new Map(rows(s, await s.resolve(profile), profile).map((r) => [r.name, r])));
2735
+ const names = [...new Set([...tables.values()].flatMap((t) => [...t.keys()]))];
2736
+ const cell = (r) => {
2737
+ if (!r) return ["–", "dim"];
2738
+ if (r.status === "arca") return r.inherited ? [`↳ ${r.profile}`, "green"] : ["✔ set", "green"];
2739
+ if (r.status === "env-file") return [r.source, "cyan"];
2740
+ if (r.status === "env-file-sensitive") return [`⚠ ${r.source}`, "yellow"];
2741
+ if (r.status === "missing") return ["✖ missing", "red"];
2742
+ return ["–", "dim"];
2743
+ };
2744
+ const heads = profiles.map((x) => x === s.profile ? `${x} ●` : x);
2745
+ const w = Math.max(4, ...names.map((n) => n.length)) + 2;
2746
+ const widths = profiles.map((x, i) => Math.max(heads[i].length, ...names.map((n) => cell(tables.get(x).get(n))[0].length)) + 3);
2747
+ s.print(p("bold", (pad("", w) + heads.map((h, i) => pad(h, widths[i])).join("")).trimEnd()));
2748
+ for (const name of names) {
2749
+ const cells = profiles.map((x, i) => {
2750
+ const [text, color] = cell(tables.get(x).get(name));
2751
+ return pad(p(color, text), widths[i]);
2752
+ });
2753
+ const declared = [...tables.values()].some((t) => t.get(name)?.declared);
2754
+ s.print(`${pad(name, w)}${cells.join("")}${declared ? "" : p("dim", "(undeclared)")}`.trimEnd());
2755
+ }
2756
+ s.print(p("dim", `\n● active profile: ${s.profile}${activeWhy(s)}. Switch with \`npx arca use <profile>\`.`));
2757
+ return 0;
2758
+ }
2759
+ /** " (via arca use)", " (the default)", or nothing when the profile is literally called "default". */
2760
+ function activeWhy(s) {
2761
+ if (s.profileSource !== "default") return ` (via ${sourceLabel(s)})`;
2762
+ return s.profile === "default" ? "" : " (the default)";
2763
+ }
2764
+ function sourceLabel(s) {
2765
+ return s.profileSource === "flag" ? "--profile" : s.profileSource === "env" ? "ARCA_PROFILE" : s.profileSource === "use" ? "arca use" : "default";
2766
+ }
2767
+ async function checkProfile(s, profile) {
2768
+ const project = s.requireProject();
2769
+ const values = await s.resolve(profile);
2770
+ const files = s.envFilesFor(profile).filter((f) => !isTemplateFile(f.name));
2771
+ const envFileValues = loadEnvFiles(project.root, s.envFiles(profile));
2772
+ const present = (name) => values.has(name) || Boolean(s.io.env[name]) || Boolean(envFileValues[name]) || files.some((f) => f.entries.some((e) => e.key === name && e.value !== ""));
2773
+ const required = Object.entries(project.config.vars).filter(([, d]) => d.required);
2774
+ return {
2775
+ profile,
2776
+ required: required.length,
2777
+ missing: required.filter(([n]) => !present(n)).map(([n]) => n),
2778
+ misplaced: required.filter(([n, d]) => isSensitive(d) && !values.has(n) && !s.io.env[n] && present(n)).map(([n]) => n),
2779
+ undeclared: [...values.keys()].filter((n) => !(n in project.config.vars))
2780
+ };
2781
+ }
2310
2782
  async function checkCommand(s, opts) {
2311
2783
  const project = s.requireProject();
2312
2784
  s.warnConfig();
2313
- const vault = await s.readVault();
2314
- const files = s.envFilesForGuard().filter((f) => !isTemplateFile(f.name));
2315
- const envFileValues = loadEnvFiles(project.root, project.config.envFiles);
2316
- const present = (name) => name in vault.vars || Boolean(s.io.env[name]) || Boolean(envFileValues[name]) || files.some((f) => f.entries.some((e) => e.key === name && e.value !== ""));
2317
- const required = Object.entries(project.config.vars).filter(([, d]) => d.required);
2318
- const missing = required.filter(([n]) => !present(n)).map(([n]) => n);
2319
- const misplaced = required.filter(([n, d]) => isSensitive(d) && !(n in vault.vars) && !s.io.env[n] && present(n)).map(([n]) => n);
2320
- const findings = scanEnvFiles(project, s.envFilesForGuard(), s.secrets(vault));
2321
- const undeclared = Object.keys(vault.vars).filter((n) => !(n in project.config.vars));
2322
- if (s.json) s.print(JSON.stringify({
2323
- ok: missing.length === 0 && (!opts.strict || findings.length === 0),
2324
- missing,
2325
- inEnvFiles: misplaced,
2326
- undeclared,
2327
- leakGuard: findings
2328
- }, null, 2));
2329
- else {
2785
+ const results = [];
2786
+ for (const profile of opts.allProfiles ? s.profiles : [s.profile]) results.push(await checkProfile(s, profile));
2787
+ const findings = scanEnvFiles(project, s.envFilesForGuard(), await s.allSecrets());
2788
+ const missingAny = results.some((r) => r.missing.length > 0);
2789
+ if (s.json) {
2790
+ const ok = !missingAny && (!opts.strict || findings.length === 0);
2791
+ if (opts.allProfiles) {
2792
+ const profiles = Object.fromEntries(results.map(({ profile, required, ...r }) => [profile, {
2793
+ ok: r.missing.length === 0,
2794
+ ...r
2795
+ }]));
2796
+ s.print(JSON.stringify({
2797
+ ok,
2798
+ profiles,
2799
+ leakGuard: findings
2800
+ }, null, 2));
2801
+ } else {
2802
+ const r = results[0];
2803
+ s.print(JSON.stringify({
2804
+ ok,
2805
+ profile: r.profile,
2806
+ missing: r.missing,
2807
+ inEnvFiles: r.misplaced,
2808
+ undeclared: r.undeclared,
2809
+ leakGuard: findings
2810
+ }, null, 2));
2811
+ }
2812
+ } else {
2330
2813
  const p = s.out;
2331
- if (missing.length === 0) s.print(`${p("green", "✔")} All ${plural(required.length, "required variable")} set for ${project.config.name}`);
2332
- else {
2333
- s.print(`${p("red", "✖")} ${plural(missing.length, "required variable")} missing for ${project.config.name}: ${missing.join(", ")}`);
2334
- s.print(` Run \`npx arca setup\` to set them. (AI agents: ask the user to run it.)`);
2814
+ for (const r of results) {
2815
+ const who = s.forWhom(r.profile);
2816
+ if (r.missing.length === 0) s.print(`${p("green", "✔")} All ${plural(r.required, "required variable")} set for ${who}`);
2817
+ else {
2818
+ s.print(`${p("red", "✖")} ${plural(r.missing.length, "required variable")} missing for ${who}: ${r.missing.join(", ")}`);
2819
+ s.print(` Run \`npx arca setup${s.profileFlag(r.profile)}\` to set them. (AI agents: ask the user to run it.)`);
2820
+ }
2821
+ for (const n of r.misplaced) s.print(`${p("yellow", "⚠")} ${n} is a secret but comes from a .env file. Move it: npx arca import --only ${n} --delete${s.profileFlag(r.profile)}`);
2822
+ if (r.undeclared.length) s.print(p("dim", ` stored but not declared in arca.json: ${r.undeclared.join(", ")}`));
2335
2823
  }
2336
- for (const n of misplaced) s.print(`${p("yellow", "⚠")} ${n} is a secret but comes from a .env file. Move it: npx arca import --only ${n} --delete`);
2337
- if (undeclared.length) s.print(p("dim", ` stored but not declared in arca.json: ${undeclared.join(", ")}`));
2338
2824
  if (findings.length) s.print(`${p("yellow", "⚠")} Leak guard: ${plural(findings.length, "possible secret")} in .env files (run \`npx arca doctor\` for details)`);
2339
2825
  }
2340
- if (missing.length) return 1;
2826
+ if (missingAny) return 1;
2341
2827
  if (opts.strict && findings.length) return 1;
2342
2828
  return 0;
2343
2829
  }
@@ -2380,20 +2866,35 @@ async function doctorCommand(s) {
2380
2866
  info(`${project.config.name} (${project.config.id})`);
2381
2867
  info(`Config: ${project.file}`);
2382
2868
  for (const w of project.warnings) warn(w);
2383
- const vault = master && s.hasVault() ? s.store.readVault(master, project.config.id, s.profile) : null;
2384
- if (s.hasVault()) ok(`Vault: ${plural(Object.keys(vault?.vars ?? {}).length, "secret")} stored`);
2385
- else info("No secrets stored for this project on this machine yet");
2386
2869
  const reg = s.store.readRegistry()[project.config.id];
2387
2870
  const remote = gitRemote(project.root);
2388
2871
  if (reg?.gitRemote && remote && reg.gitRemote !== remote) {
2389
2872
  warn(`The ID is also used by ${reg.roots[0]} (git remote ${reg.gitRemote}). If these are different projects: npx arca init --new-id`);
2390
2873
  problems++;
2391
2874
  }
2875
+ s.print(p("bold", s.multiProfile ? "\nProfiles" : "\nVault"));
2876
+ if (s.multiProfile) info(`Active: ${s.profile} (${sourceLabel(s)})${s.staleActiveProfile ? `; \`arca use\` points at "${s.staleActiveProfile}", which no longer exists` : ""}`);
2392
2877
  const decls = project.config.vars;
2393
- const short = Object.entries(vault?.vars ?? {}).filter(([n, v]) => isSensitive(decls[n]) && v.value.length < 6);
2394
- for (const [n] of short) warn(`${n} is shorter than 6 characters, so it isn't masked in output`);
2395
- const missing = Object.entries(decls).filter(([n, d]) => d.required && isSensitive(d) && !vault?.vars[n]).map(([n]) => n);
2396
- if (missing.length) warn(`Required secrets not stored: ${missing.join(", ")}. Run \`npx arca setup\`.`);
2878
+ let active = null;
2879
+ for (const profile of s.profiles) {
2880
+ const label = s.multiProfile ? `${profile}: ` : "";
2881
+ if (!s.hasVault(profile)) {
2882
+ info(`${label}no secrets stored on this machine yet`);
2883
+ const missing = Object.entries(decls).filter(([, d]) => d.required && isSensitive(d)).map(([n]) => n);
2884
+ if (missing.length) warn(`${label}required secrets not stored: ${missing.join(", ")}. Run \`npx arca setup${s.profileFlag(profile)}\`.`);
2885
+ continue;
2886
+ }
2887
+ if (!master) continue;
2888
+ const values = await s.resolve(profile);
2889
+ if (profile === s.profile) active = values;
2890
+ const own = [...values.values()].filter((v) => v.profile === profile).length;
2891
+ const inherited = values.size - own;
2892
+ ok(`${label}${plural(own, "secret")} stored${inherited ? `, ${inherited} inherited` : ""}`);
2893
+ const short = [...values].filter(([n, v]) => v.profile === profile && isSensitive(decls[n]) && v.value.length < 6);
2894
+ for (const [n] of short) warn(`${label}${n} is shorter than 6 characters, so it isn't masked in output`);
2895
+ const missing = Object.entries(decls).filter(([n, d]) => d.required && isSensitive(d) && !values.has(n)).map(([n]) => n);
2896
+ if (missing.length) warn(`${label}required secrets not stored: ${missing.join(", ")}. Run \`npx arca setup${s.profileFlag(profile)}\`.`);
2897
+ }
2397
2898
  const pkgFile = join(s.packageRoot() ?? project.root, "package.json");
2398
2899
  if (existsSync(pkgFile)) {
2399
2900
  s.print(p("bold", "\nScripts"));
@@ -2404,11 +2905,12 @@ async function doctorCommand(s) {
2404
2905
  else warn("No scripts use `arca run` yet, so nothing gets secrets. Run `npx arca init` to wrap them.");
2405
2906
  if (unwrapped.length) info(`Not wrapped (no secrets): ${unwrapped.join(", ")}`);
2406
2907
  }
2407
- const modeFiles = project.config.envFiles.filter((f) => /^\.env\.(development|production|test)(\.local)?$/.test(f));
2908
+ const allEnvFiles = [...project.config.envFiles, ...Object.values(project.config.profiles).flatMap((x) => x.envFiles)];
2909
+ const modeFiles = [...new Set(allEnvFiles)].filter((f) => /^\.env\.(development|production|test)(\.local)?$/.test(f));
2408
2910
  for (const f of modeFiles) warn(`envFiles lists ${f}. arca injects it as process env, which beats the framework's own mode files (e.g. in \`next build\`). Prefer a name frameworks don't load.`);
2409
2911
  s.print(p("bold", "\nLeak guard (.env files)"));
2410
2912
  const files = s.envFilesForGuard();
2411
- const findings = scanEnvFiles(project, files, vault ? s.secrets(vault) : []);
2913
+ const findings = scanEnvFiles(project, files, master ? await s.allSecrets() : []);
2412
2914
  if (files.length === 0) info("No .env files");
2413
2915
  else if (findings.length === 0) ok(`${plural(files.length, "file")} checked, no secrets found: ${files.map((f) => f.name).join(", ")}`);
2414
2916
  for (const f of findings) {
@@ -2416,10 +2918,11 @@ async function doctorCommand(s) {
2416
2918
  s.print(` ${p("dim", fixFor(f))}`);
2417
2919
  problems++;
2418
2920
  }
2419
- const secretPrefixes = new Set(Object.keys(vault?.vars ?? {}).map((n) => n.split("_")[0]));
2921
+ const stored = active ?? /* @__PURE__ */ new Map();
2922
+ const secretPrefixes = new Set([...stored.keys()].map((n) => n.split("_")[0]));
2420
2923
  for (const file of files) for (const e of file.entries) {
2421
2924
  if (!/^https?:\/\//i.test(e.value) || decls[e.key]?.sensitive === true) continue;
2422
- if (secretPrefixes.has(e.key.split("_")[0]) && !(e.key in (vault?.vars ?? {}))) info(`${e.key} in ${file.name} is a URL next to the secret ${e.key.split("_")[0]}_… If it receives that credential, keep it in arca so only you can change it.`);
2925
+ if (secretPrefixes.has(e.key.split("_")[0]) && !stored.has(e.key)) info(`${e.key} in ${file.name} is a URL next to the secret ${e.key.split("_")[0]}_… If it receives that credential, keep it in arca so only you can change it.`);
2423
2926
  }
2424
2927
  if (master) {
2425
2928
  const policy = s.store.readPolicy(master, project.config.id);
@@ -2450,15 +2953,16 @@ async function statusCommand(s, version) {
2450
2953
  }
2451
2954
  s.warnConfig();
2452
2955
  const project = s.project;
2453
- const vault = await s.readVault();
2454
- const missing = rows(s, vault).filter((r) => r.status === "missing").map((r) => r.name);
2956
+ const values = await s.resolve();
2957
+ const missing = rows(s, values).filter((r) => r.status === "missing").map((r) => r.name);
2455
2958
  s.print(`\nProject ${project.config.name} (${project.config.id})`);
2456
- s.print(`Secrets ${plural(Object.keys(vault.vars).length, "stored")}${missing.length ? p("red", `, missing: ${missing.join(", ")}`) : ""}`);
2959
+ if (s.multiProfile) s.print(`Profile ${s.profile}${activeWhy(s)} ${p("dim", `(profiles: ${s.profiles.join(", ")})`)}`);
2960
+ s.print(`Secrets ${values.size} stored${missing.length ? p("red", `, missing: ${missing.join(", ")}`) : ""}`);
2457
2961
  const policy = await s.policy();
2458
2962
  s.print(`Agents ${policy.agentRun === "scripts" ? "package scripts only, output masked" : "denied"}`);
2459
2963
  s.print(`You ${s.ctx.agent.detected ? `AI agent (${s.ctx.agent.name} via ${s.ctx.agent.via})` : s.ctx.interactiveOutput ? "interactive terminal" : "non-interactive"}`);
2460
2964
  s.print(`Store ${s.store.root}`);
2461
- s.print(`\n${missing.length ? "Next: npx arca setup" : "Run your app as usual, e.g. npm run dev"} · npx arca help`);
2965
+ s.print(`\n${missing.length ? `Next: npx arca setup${s.profileFlag()}` : "Run your app as usual, e.g. npm run dev"} · npx arca help`);
2462
2966
  return 0;
2463
2967
  }
2464
2968
  async function policyCommand(s, args) {
@@ -2507,6 +3011,240 @@ async function projectsCommand(s, args) {
2507
3011
  return 0;
2508
3012
  }
2509
3013
  //#endregion
3014
+ //#region src/commands/profiles.ts
3015
+ async function useCommand(s, opts) {
3016
+ const project = s.requireProject();
3017
+ const { id, name, defaultProfile } = project.config;
3018
+ const p = s.err;
3019
+ if (opts.reset || opts.profile !== void 0 && normalizeProfile(opts.profile) === defaultProfile) {
3020
+ s.store.writeState(id, {});
3021
+ s.note(`${p("green", "✔")} ${name} is back on ${defaultProfile}`);
3022
+ envOverrideNote(s);
3023
+ return 0;
3024
+ }
3025
+ if (opts.profile === void 0) {
3026
+ if (s.json) {
3027
+ s.print(JSON.stringify({
3028
+ profile: s.profile,
3029
+ source: s.profileSource,
3030
+ defaultProfile,
3031
+ profiles: s.profiles
3032
+ }, null, 2));
3033
+ return 0;
3034
+ }
3035
+ s.warnConfig();
3036
+ s.print(`${s.profile}${s.out("dim", activeWhy(s))}`);
3037
+ if (!s.multiProfile) s.note(p("dim", `${name} has one profile. Add another with \`npx arca profiles add <name>\`.`));
3038
+ else s.note(p("dim", `Profiles: ${s.profiles.join(", ")}. Switch with \`npx arca use <profile>\`.`));
3039
+ return 0;
3040
+ }
3041
+ const profile = s.checkProfile(project, opts.profile, "arca use");
3042
+ s.store.writeState(id, {
3043
+ activeProfile: profile,
3044
+ setAt: (/* @__PURE__ */ new Date()).toISOString()
3045
+ });
3046
+ s.note(`${p("green", "✔")} ${name} now uses profile ${profile} for every arca run on this machine`);
3047
+ s.note(` Back to ${defaultProfile}: npx arca use --reset`);
3048
+ const missing = await missingFor(s, profile);
3049
+ if (missing.length) s.note(p("yellow", `⚠ ${profile} has no value for ${missing.join(", ")}. Run \`npx arca setup --profile ${profile}\`.`));
3050
+ envOverrideNote(s);
3051
+ return 0;
3052
+ }
3053
+ function envOverrideNote(s) {
3054
+ if (s.profileSource === "env") s.note(s.err("yellow", `⚠ ARCA_PROFILE=${s.io.env.ARCA_PROFILE} is set in this terminal and wins over \`arca use\` here.`));
3055
+ }
3056
+ /** Required secrets the profile has no value for (names only). */
3057
+ async function missingFor(s, profile) {
3058
+ const values = s.hasVault(profile) ? await s.resolve(profile) : /* @__PURE__ */ new Map();
3059
+ return Object.entries(s.requireProject().config.vars).filter(([n, d]) => d.required && isSensitive(d) && !values.has(n)).map(([n]) => n);
3060
+ }
3061
+ async function profilesCommand(s, args, opts) {
3062
+ const [action = "list", ...rest] = args;
3063
+ switch (action) {
3064
+ case "list":
3065
+ case "ls": return listProfiles(s);
3066
+ case "add": return addProfile(s, rest[0], opts);
3067
+ case "diff": return diffProfiles(s, rest[0], rest[1]);
3068
+ case "copy":
3069
+ case "cp": return copyProfile(s, rest[0], rest[1], opts);
3070
+ case "rm":
3071
+ case "remove": return removeProfile(s, rest[0], opts);
3072
+ }
3073
+ throw new ArcaError("arca: usage: npx arca profiles [list] | add <name> [--extends p] | diff <a> <b> | copy <from> <to> [--only A,B] | rm <name>", 2);
3074
+ }
3075
+ async function listProfiles(s) {
3076
+ const project = s.requireProject();
3077
+ s.warnConfig();
3078
+ const list = [];
3079
+ for (const profile of s.profiles) {
3080
+ const values = s.hasVault(profile) ? await s.resolve(profile) : /* @__PURE__ */ new Map();
3081
+ const own = [...values.values()].filter((v) => v.profile === profile).length;
3082
+ list.push({
3083
+ name: profile,
3084
+ active: profile === s.profile,
3085
+ default: profile === project.config.defaultProfile,
3086
+ extends: project.config.profiles[profile]?.extends ?? null,
3087
+ envFiles: project.config.profiles[profile]?.envFiles ?? [],
3088
+ own,
3089
+ inherited: values.size - own,
3090
+ missing: await missingFor(s, profile)
3091
+ });
3092
+ }
3093
+ if (s.json) {
3094
+ s.print(JSON.stringify(list, null, 2));
3095
+ return 0;
3096
+ }
3097
+ const p = s.out;
3098
+ const w = Math.max(7, ...list.map((x) => x.name.length + 2)) + 2;
3099
+ s.print(p("bold", `${pad("PROFILE", w)}${pad("EXTENDS", 14)}${pad("VALUES", 22)}MISSING`));
3100
+ for (const x of list) {
3101
+ const label = `${x.active ? "●" : " "} ${x.name}`;
3102
+ const values = `${x.own} set${x.inherited ? `, ${x.inherited} inherited` : ""}`;
3103
+ const missing = x.missing.length ? p("red", x.missing.join(", ")) : p("dim", "–");
3104
+ const tags = [x.default ? "default" : "", ...x.envFiles].filter(Boolean);
3105
+ s.print(`${pad(label, w)}${pad(x.extends ?? "–", 14)}${pad(values, 22)}${missing}${tags.length ? p("dim", ` (${tags.join(", ")})`) : ""}`);
3106
+ }
3107
+ s.print(p("dim", `\n● active (${sourceLabel(s)}). Switch: npx arca use <profile> Add: npx arca profiles add <name> [--extends ${project.config.defaultProfile}]`));
3108
+ return 0;
3109
+ }
3110
+ async function addProfile(s, raw, opts) {
3111
+ const project = s.requireProject();
3112
+ if (!raw) throw new ArcaError("arca: missing profile name. Usage: npx arca profiles add <name> [--extends <profile>]", 2);
3113
+ const name = normalizeProfile(raw);
3114
+ const bad = invalidProfileName(name);
3115
+ if (bad) throw new ArcaError(`arca: ${bad}.`, 2);
3116
+ if (name in project.config.profiles) throw new ArcaError(`arca: profile ${name} already exists in arca.json.`);
3117
+ const parent = opts.extends !== void 0 ? s.checkProfile(project, opts.extends, "--extends") : void 0;
3118
+ addProfileToConfig(project, name, { extends: parent });
3119
+ const p = s.err;
3120
+ s.note(`${p("green", "✔")} Added profile ${name}${parent ? ` (extends ${parent}, so it only stores what differs)` : ""} to arca.json`);
3121
+ s.note(" Next, in your own terminal:");
3122
+ if (parent) s.note(` npx arca set NAME --profile ${name} override a value from ${parent}`);
3123
+ else {
3124
+ s.note(` npx arca profiles copy ${project.config.defaultProfile} ${name} start from ${project.config.defaultProfile}'s values, or`);
3125
+ s.note(` npx arca setup --profile ${name} enter each value`);
3126
+ }
3127
+ s.note(` npx arca use ${name} switch to it`);
3128
+ return 0;
3129
+ }
3130
+ /** Names only: which values each profile sets itself, and which it inherits. */
3131
+ async function diffProfiles(s, rawA, rawB) {
3132
+ const project = s.requireProject();
3133
+ if (!rawA || !rawB) throw new ArcaError("arca: usage: npx arca profiles diff <a> <b>", 2);
3134
+ const a = s.checkProfile(project, rawA, "profiles diff");
3135
+ const b = s.checkProfile(project, rawB, "profiles diff");
3136
+ const va = s.hasVault(a) ? await s.resolve(a) : /* @__PURE__ */ new Map();
3137
+ const vb = s.hasVault(b) ? await s.resolve(b) : /* @__PURE__ */ new Map();
3138
+ const names = [.../* @__PURE__ */ new Set([
3139
+ ...Object.keys(project.config.vars).filter((n) => isSensitive(project.config.vars[n])),
3140
+ ...va.keys(),
3141
+ ...vb.keys()
3142
+ ])];
3143
+ const describe = (values, profile, name) => {
3144
+ const v = values.get(name);
3145
+ if (!v) return {
3146
+ text: "– not set",
3147
+ color: "dim",
3148
+ kind: "missing"
3149
+ };
3150
+ if (v.profile === profile) return {
3151
+ text: "✔ own value",
3152
+ color: "green",
3153
+ kind: "own"
3154
+ };
3155
+ return {
3156
+ text: `↳ ${v.profile}`,
3157
+ color: "cyan",
3158
+ kind: `inherits:${v.profile}`
3159
+ };
3160
+ };
3161
+ const rowsOut = names.map((name) => ({
3162
+ name,
3163
+ a: describe(va, a, name),
3164
+ b: describe(vb, b, name)
3165
+ }));
3166
+ if (s.json) {
3167
+ s.print(JSON.stringify(rowsOut.map((r) => ({
3168
+ name: r.name,
3169
+ [a]: r.a.kind,
3170
+ [b]: r.b.kind
3171
+ })), null, 2));
3172
+ return 0;
3173
+ }
3174
+ const p = s.out;
3175
+ const w = Math.max(4, ...names.map((n) => n.length)) + 2;
3176
+ const cw = Math.max(a.length, ...rowsOut.map((r) => r.a.text.length)) + 3;
3177
+ s.print(p("bold", `${pad("", w)}${pad(a, cw)}${b}`));
3178
+ for (const r of rowsOut) s.print(`${pad(r.name, w)}${pad(p(r.a.color, r.a.text), cw)}${p(r.b.color, r.b.text)}`);
3179
+ const ownB = rowsOut.filter((r) => r.b.kind === "own").map((r) => r.name);
3180
+ s.print(p("dim", `\n${b} sets ${ownB.length ? ownB.join(", ") : "nothing"} itself. Names only: values are never compared or shown.`));
3181
+ return 0;
3182
+ }
3183
+ async function copyProfile(s, rawFrom, rawTo, opts) {
3184
+ const project = s.requireProject();
3185
+ if (!rawFrom || !rawTo) throw new ArcaError("arca: usage: npx arca profiles copy <from> <to> [--only A,B]", 2);
3186
+ const from = s.checkProfile(project, rawFrom, "profiles copy");
3187
+ const to = s.checkProfile(project, rawTo, "profiles copy");
3188
+ if (from === to) throw new ArcaError("arca: <from> and <to> are the same profile.", 2);
3189
+ await humanGate(s.io, s.ctx, {
3190
+ kind: "write",
3191
+ command: `npx arca profiles copy ${from} ${to}${opts.only ? ` --only ${opts.only.join(",")}` : ""}`
3192
+ });
3193
+ const source = await s.resolve(from);
3194
+ const only = opts.only ? new Set(opts.only) : null;
3195
+ const picked = [...source].filter(([n]) => !only || only.has(n));
3196
+ if (only) {
3197
+ const unknown = [...only].filter((n) => !source.has(n));
3198
+ if (unknown.length) s.note(s.err("yellow", `arca: not stored in ${from}: ${unknown.join(", ")}`));
3199
+ }
3200
+ if (picked.length === 0) throw new ArcaError(`arca: ${from} has no values to copy.`);
3201
+ const target = await s.readVault(to);
3202
+ const conflicts = picked.filter(([n, v]) => target.vars[n] && target.vars[n].value !== v.value).map(([n]) => n);
3203
+ let overwrite = Boolean(opts.yes);
3204
+ if (conflicts.length && !opts.yes) {
3205
+ const answer = await s.io.prompts.confirm(`${to} already has its own ${conflicts.join(", ")}. Overwrite with ${from}'s?`, false);
3206
+ if (answer === null) throw new ArcaError("arca: cancelled. Nothing was changed.");
3207
+ overwrite = answer;
3208
+ }
3209
+ const copied = [];
3210
+ for (const [n, v] of picked) {
3211
+ if (target.vars[n]?.value === v.value) continue;
3212
+ if (target.vars[n] && !overwrite) continue;
3213
+ storeValue(target, n, v.value, `copy:${from}`);
3214
+ copied.push(n);
3215
+ }
3216
+ if (copied.length) await s.writeVault(target, to);
3217
+ const skipped = picked.length - copied.length;
3218
+ s.note(`${s.err("green", "✔")} Copied ${plural(copied.length, "value")} from ${from} to ${to}${copied.length ? `: ${copied.join(", ")}` : ""}`);
3219
+ if (skipped) s.note(s.err("dim", ` ${plural(skipped, "value")} left as ${to} had ${skipped === 1 ? "it" : "them"}`));
3220
+ if (copied.length) s.note(` Change the ones that differ: npx arca set NAME --profile ${to}`);
3221
+ return 0;
3222
+ }
3223
+ async function removeProfile(s, raw, opts) {
3224
+ const project = s.requireProject();
3225
+ if (!raw) throw new ArcaError("arca: usage: npx arca profiles rm <name>", 2);
3226
+ const name = s.checkProfile(project, raw, "profiles rm");
3227
+ const { id, defaultProfile } = project.config;
3228
+ if (name === defaultProfile) throw new ArcaError(`arca: ${name} is the default profile. Set another "defaultProfile" in arca.json first.`);
3229
+ const children = profileChildren(project.config, name);
3230
+ if (children.length) throw new ArcaError(`arca: ${children.join(", ")} ${children.length === 1 ? "extends" : "extend"} ${name}. Change or remove ${children.length === 1 ? "it" : "them"} first.`);
3231
+ await humanGate(s.io, s.ctx, {
3232
+ kind: "write",
3233
+ command: `npx arca profiles rm ${name}`
3234
+ });
3235
+ const stored = s.ownsVault(name) ? Object.keys((await s.readVault(name)).vars).length : 0;
3236
+ if (!opts.yes) {
3237
+ const what = stored ? ` and its ${plural(stored, "value")} on this machine` : "";
3238
+ if (!await s.io.prompts.confirm(`Remove profile ${name} from arca.json${what}? This can't be undone.`, false)) throw new ArcaError("arca: cancelled. Nothing was changed.");
3239
+ }
3240
+ removeProfileFromConfig(project.file, name);
3241
+ s.store.removeVault(id, name);
3242
+ if (s.store.readState(id).activeProfile === name) s.store.writeState(id, {});
3243
+ s.note(`${s.err("green", "✔")} Removed profile ${name}${stored ? ` and its ${plural(stored, "value")}` : ""}`);
3244
+ s.note(s.err("dim", " Teammates keep their own values for it until they remove it too."));
3245
+ return 0;
3246
+ }
3247
+ //#endregion
2510
3248
  //#region src/main.ts
2511
3249
  const VERSION = "0.1.0";
2512
3250
  const HELP = `arca: keep secrets out of .env files and out of reach of AI agents
@@ -2520,8 +3258,17 @@ Everyday
2520
3258
  --no-override let values inherited from the shell win over arca
2521
3259
  list, ls Variables and where each one comes from (names only, never values)
2522
3260
  check [--strict] Exit 1 if required variables are missing (--strict: also on leak-guard findings)
3261
+ [--all-profiles]
2523
3262
  doctor Diagnose keychain, project, scripts and .env files
2524
3263
 
3264
+ Profiles (sets of values for the same variables, e.g. ontwikkel and acceptatie)
3265
+ use [profile] Show or switch the active profile for every arca run of this project
3266
+ use --reset Back to the default profile
3267
+ profiles List profiles: active, extends, values set, missing
3268
+ profiles add <name> [--extends p] profiles diff <a> <b> (names only)
3269
+ profiles copy <from> <to> [--only A,B] profiles rm <name> (person only)
3270
+ Every value command takes --profile <name> (-p). ARCA_PROFILE=<name> picks one for a terminal.
3271
+
2525
3272
  Values (only a person at a terminal)
2526
3273
  set NAME [--generate] Store a value (hidden prompt, or piped: … | arca set NAME)
2527
3274
  setup [--all] Set every missing required variable, one prompt each
@@ -2582,6 +3329,9 @@ const COMMANDS = [
2582
3329
  "export",
2583
3330
  "import",
2584
3331
  "init",
3332
+ "use",
3333
+ "profiles",
3334
+ "profile",
2585
3335
  "policy",
2586
3336
  "projects",
2587
3337
  "help",
@@ -2610,7 +3360,7 @@ async function dispatch(argv, io) {
2610
3360
  return 0;
2611
3361
  }
2612
3362
  if (command === void 0 || command.startsWith("-") && command !== "--") {
2613
- const { values } = parse(argv);
3363
+ const { values } = parse(argv, profileOption);
2614
3364
  return statusCommand(session(io, values), VERSION);
2615
3365
  }
2616
3366
  if (command === "run" || command === "--") return run(command === "--" ? ["--", ...rest] : rest, io);
@@ -2627,14 +3377,42 @@ async function dispatch(argv, io) {
2627
3377
  case "check": {
2628
3378
  const { values } = parse(rest, {
2629
3379
  strict: { type: "boolean" },
3380
+ "all-profiles": { type: "boolean" },
2630
3381
  ...profileOption
2631
3382
  });
2632
- return checkCommand(session(io, values), { strict: Boolean(values.strict) });
3383
+ return checkCommand(session(io, values), {
3384
+ strict: Boolean(values.strict),
3385
+ allProfiles: Boolean(values["all-profiles"])
3386
+ });
2633
3387
  }
2634
3388
  case "doctor": {
2635
- const { values } = parse(rest);
3389
+ const { values } = parse(rest, profileOption);
2636
3390
  return doctorCommand(session(io, values));
2637
3391
  }
3392
+ case "use": {
3393
+ const { values, positionals } = parse(rest, { reset: { type: "boolean" } });
3394
+ if (positionals.length > 1) throw new ArcaError("arca: too many arguments. Usage: npx arca use [profile] | npx arca use --reset", 2);
3395
+ return useCommand(session(io, values), {
3396
+ profile: positionals[0],
3397
+ reset: Boolean(values.reset)
3398
+ });
3399
+ }
3400
+ case "profile":
3401
+ case "profiles": {
3402
+ const { values, positionals } = parse(rest, {
3403
+ extends: { type: "string" },
3404
+ only: { type: "string" },
3405
+ yes: {
3406
+ type: "boolean",
3407
+ short: "y"
3408
+ }
3409
+ });
3410
+ return profilesCommand(session(io, values), positionals, {
3411
+ extends: values.extends,
3412
+ only: splitList(values.only),
3413
+ yes: Boolean(values.yes)
3414
+ });
3415
+ }
2638
3416
  case "set": {
2639
3417
  const { values, positionals } = parse(rest.map((a) => a === "--generate" || a === "-g" ? "--generate=default" : a), {
2640
3418
  generate: { type: "string" },
@@ -2741,8 +3519,10 @@ function session(io, values) {
2741
3519
  io.stdout.write(HELP + "\n");
2742
3520
  throw new ArcaError("", 0);
2743
3521
  }
2744
- if (values.profile !== void 0) throw new ArcaError("arca: profiles are not available in this version yet. Every command uses the project's single set of values.", 2);
2745
- const s = new Session(io, { projectDir: values["project-dir"] });
3522
+ const s = new Session(io, {
3523
+ projectDir: values["project-dir"],
3524
+ profile: values.profile
3525
+ });
2746
3526
  s.json = Boolean(values.json);
2747
3527
  s.quiet = Boolean(values.quiet);
2748
3528
  return s;