@bremmdev/arca-env 0.1.1 → 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.
- package/README.md +38 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +1 -1
- package/dist/{main-Cy_OrS0c.mjs → main-B2iLn7kM.mjs} +941 -161
- package/package.json +1 -1
- package/schema.json +22 -0
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
919
|
-
s.note(s.err("dim", `arca: no secrets stored for ${
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
947
|
-
const secrets = s.
|
|
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,
|
|
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.
|
|
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(
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
1522
|
-
|
|
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
|
-
/**
|
|
1531
|
-
async readVault() {
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
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
|
-
|
|
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
|
|
1557
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1772
|
-
const v =
|
|
2090
|
+
const values = await s.resolve();
|
|
2091
|
+
const v = values.get(name);
|
|
1773
2092
|
if (!v) {
|
|
1774
|
-
const guess = suggest(name,
|
|
1775
|
-
throw new ArcaError(`arca: ${name} is not stored in arca for ${
|
|
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:
|
|
2109
|
+
command: `npx arca export${s.profileFlag()}`
|
|
1791
2110
|
});
|
|
1792
|
-
const
|
|
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:
|
|
2127
|
+
command: `npx arca setup${s.profileFlag()}`
|
|
1810
2128
|
});
|
|
1811
2129
|
const vault = await s.readVault();
|
|
1812
|
-
const
|
|
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 ${
|
|
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 ${
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
const
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
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,
|
|
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.
|
|
2246
|
-
const
|
|
2247
|
-
|
|
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 =
|
|
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
|
-
|
|
2699
|
+
s.requireProject();
|
|
2286
2700
|
s.warnConfig();
|
|
2287
|
-
const list = rows(s, await s.
|
|
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 ${
|
|
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
|
|
2314
|
-
const
|
|
2315
|
-
const
|
|
2316
|
-
const
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
2332
|
-
|
|
2333
|
-
s.print(`${p("
|
|
2334
|
-
|
|
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 (
|
|
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
|
-
|
|
2394
|
-
for (const
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2454
|
-
const missing = rows(s,
|
|
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(`
|
|
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 ?
|
|
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), {
|
|
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
|
-
|
|
2745
|
-
|
|
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;
|