@super-repo/envx 0.2.3-b.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -1006
- package/dist/auto.js +1 -1
- package/dist/chunks/{commands-Cg8YMJHj.js → commands-B70xh15E.js} +368 -25
- package/dist/chunks/commands-B70xh15E.js.map +1 -0
- package/dist/chunks/src-B_rA7_sV.js +64 -0
- package/dist/chunks/src-B_rA7_sV.js.map +1 -0
- package/dist/chunks/{src-jaqb5pGP.js → src-BeMTu_ms.js} +0 -0
- package/dist/chunks/{src-jaqb5pGP.js.map → src-BeMTu_ms.js.map} +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/audit.d.ts +12 -0
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/template-ai.d.ts +39 -0
- package/dist/commands/template-ai.d.ts.map +1 -0
- package/dist/commands/template.d.ts +6 -2
- package/dist/commands/template.d.ts.map +1 -1
- package/dist/index.js +2 -63
- package/docs/defaults.md +130 -0
- package/docs/encryption.md +57 -0
- package/docs/hooks.md +267 -0
- package/docs/library-api.md +121 -0
- package/docs/public-variables.md +49 -0
- package/docs/security-models.md +103 -0
- package/docs/template.md +87 -0
- package/package.json +5 -4
- package/dist/chunks/commands-Cg8YMJHj.js.map +0 -1
- package/dist/index.js.map +0 -1
package/dist/auto.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { A as isEncrypted, C as parseEnv, D as decryptValueAsymmetric, S as log, T as toRecord, _ as resolveCwdOrWorkspace, a as auditFiles, b as expandEnvSrc, c as encryptFiles, f as DEFAULT_NODE_ENV_MAP, g as loadEnv, h as listEnvFiles, l as defaultKeysPath, m as findWorkspaceRoot, o as rotateFiles, p as detectEnvironment, r as loadDotenvxConfig, s as decryptFiles, t as writeProcessed, u as readKeysFile, v as resolveEnvPaths, x as expandRecord, y as validateCmdVariable } from "./src-
|
|
1
|
+
import { A as isEncrypted, C as parseEnv, D as decryptValueAsymmetric, S as log, T as toRecord, _ as resolveCwdOrWorkspace, a as auditFiles, b as expandEnvSrc, c as encryptFiles, f as DEFAULT_NODE_ENV_MAP, g as loadEnv, h as listEnvFiles, l as defaultKeysPath, m as findWorkspaceRoot, o as rotateFiles, p as detectEnvironment, r as loadDotenvxConfig, s as decryptFiles, t as writeProcessed, u as readKeysFile, v as resolveEnvPaths, x as expandRecord, y as validateCmdVariable } from "./src-BeMTu_ms.js";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import yargs from "yargs";
|
|
5
|
-
import { execSync, spawn } from "child_process";
|
|
5
|
+
import { execFileSync, execSync, spawn } from "child_process";
|
|
6
6
|
//#region src/commands/audit.ts
|
|
7
7
|
/**
|
|
8
8
|
* Walk the repo looking for plaintext secrets — committed AWS keys,
|
|
@@ -30,6 +30,14 @@ var auditCommand = {
|
|
|
30
30
|
type: "number",
|
|
31
31
|
default: 50,
|
|
32
32
|
describe: "Stop after reporting this many findings (0 = unlimited)."
|
|
33
|
+
}).option("respect-gitignore", {
|
|
34
|
+
type: "boolean",
|
|
35
|
+
default: true,
|
|
36
|
+
describe: "Skip files and directories matched by .gitignore (and .git/info/exclude). Pass --no-respect-gitignore to scan everything."
|
|
37
|
+
}).option("staged", {
|
|
38
|
+
type: "boolean",
|
|
39
|
+
default: false,
|
|
40
|
+
describe: "Scan only files staged for the next commit (added/copied/modified/renamed). Mutually exclusive with positional paths. Suitable as the body of a pre-commit hook."
|
|
33
41
|
}).option("json", {
|
|
34
42
|
type: "boolean",
|
|
35
43
|
default: false,
|
|
@@ -37,14 +45,37 @@ var auditCommand = {
|
|
|
37
45
|
}),
|
|
38
46
|
handler: (argv) => {
|
|
39
47
|
const rawPaths = argv["paths"];
|
|
40
|
-
const
|
|
48
|
+
const staged = argv["staged"];
|
|
41
49
|
const ignore = argv["ignore"];
|
|
42
50
|
const max = argv["max"];
|
|
51
|
+
const respectGitignore = argv["respect-gitignore"];
|
|
43
52
|
const asJson = argv["json"];
|
|
53
|
+
let roots;
|
|
54
|
+
if (staged) {
|
|
55
|
+
if (rawPaths && rawPaths.length > 0) {
|
|
56
|
+
log.error("audit: --staged cannot be combined with positional paths (it scans the git-staged set)");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
const stagedFiles = listStagedFiles();
|
|
60
|
+
if (stagedFiles === null) {
|
|
61
|
+
log.error("audit: --staged requires a git repository (no .git directory found above cwd)");
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
if (stagedFiles.length === 0) {
|
|
65
|
+
if (asJson) process.stdout.write(JSON.stringify({
|
|
66
|
+
filesScanned: 0,
|
|
67
|
+
findings: []
|
|
68
|
+
}) + "\n");
|
|
69
|
+
else log.info("audit: no staged files — nothing to scan");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
roots = stagedFiles.map((p) => path.resolve(process.cwd(), p));
|
|
73
|
+
} else roots = (rawPaths && rawPaths.length > 0 ? rawPaths : ["."]).map((p) => path.resolve(process.cwd(), p));
|
|
44
74
|
const { findings, filesScanned } = auditFiles({
|
|
45
75
|
roots,
|
|
46
76
|
ignore,
|
|
47
|
-
max
|
|
77
|
+
max,
|
|
78
|
+
respectGitignore
|
|
48
79
|
});
|
|
49
80
|
if (asJson) {
|
|
50
81
|
process.stdout.write(JSON.stringify({
|
|
@@ -86,6 +117,36 @@ function groupByPattern(findings) {
|
|
|
86
117
|
}
|
|
87
118
|
return [...map.entries()];
|
|
88
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Returns the list of files staged for the next commit (added, copied,
|
|
122
|
+
* modified, or renamed — `--diff-filter=ACMR` excludes deletions). NUL-
|
|
123
|
+
* delimited (`-z`) so paths with spaces or newlines round-trip safely.
|
|
124
|
+
*
|
|
125
|
+
* Returns `null` when not inside a git repo (so the caller can render a
|
|
126
|
+
* nicer error than git's stderr). Empty array means "in a repo, but
|
|
127
|
+
* nothing staged" — distinct from the failure case.
|
|
128
|
+
*
|
|
129
|
+
* Exported for unit testing.
|
|
130
|
+
*/
|
|
131
|
+
function listStagedFiles() {
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync("git", [
|
|
134
|
+
"diff",
|
|
135
|
+
"--cached",
|
|
136
|
+
"--name-only",
|
|
137
|
+
"--diff-filter=ACMR",
|
|
138
|
+
"-z"
|
|
139
|
+
], { stdio: [
|
|
140
|
+
"ignore",
|
|
141
|
+
"pipe",
|
|
142
|
+
"pipe"
|
|
143
|
+
] }).toString("utf8");
|
|
144
|
+
if (out.length === 0) return [];
|
|
145
|
+
return (out.endsWith("\0") ? out.slice(0, -1) : out).split("\0").filter((p) => p.length > 0);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
89
150
|
//#endregion
|
|
90
151
|
//#region src/commands/bake.ts
|
|
91
152
|
/**
|
|
@@ -1062,11 +1123,190 @@ var runCommand = {
|
|
|
1062
1123
|
}
|
|
1063
1124
|
};
|
|
1064
1125
|
//#endregion
|
|
1126
|
+
//#region src/commands/template-ai.ts
|
|
1127
|
+
var ANTHROPIC_DEFAULT_BASE = "https://api.anthropic.com";
|
|
1128
|
+
var OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
|
|
1129
|
+
var ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20";
|
|
1130
|
+
var ANTHROPIC_OAUTH_ENVS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"];
|
|
1131
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
1132
|
+
var AiError = class extends Error {
|
|
1133
|
+
constructor(message) {
|
|
1134
|
+
super(`envx template ai: ${message}`);
|
|
1135
|
+
this.name = "AiError";
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolve an API key from `process.env`. Looks at `apiKeyEnv` first
|
|
1140
|
+
* (defaults to provider-appropriate name), then known OAuth fallbacks
|
|
1141
|
+
* for Anthropic. Returns `null` when nothing is set — caller decides
|
|
1142
|
+
* how to surface the failure.
|
|
1143
|
+
*/
|
|
1144
|
+
function resolveAuth(provider, apiKeyEnv) {
|
|
1145
|
+
const direct = process.env[apiKeyEnv];
|
|
1146
|
+
if (direct) return {
|
|
1147
|
+
token: direct,
|
|
1148
|
+
kind: provider === "anthropic" && direct.startsWith("sk-ant-oat") ? "oauth" : "api-key",
|
|
1149
|
+
sourceEnv: apiKeyEnv
|
|
1150
|
+
};
|
|
1151
|
+
if (provider === "anthropic") for (const name of ANTHROPIC_OAUTH_ENVS) {
|
|
1152
|
+
const v = process.env[name];
|
|
1153
|
+
if (v) return {
|
|
1154
|
+
token: v,
|
|
1155
|
+
kind: v.startsWith("sk-ant-oat") ? "oauth" : "api-key",
|
|
1156
|
+
sourceEnv: name
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Generate an example value per key. Returns a Map keyed by env-var
|
|
1163
|
+
* name. Keys without a usable AI response fall back to an empty string
|
|
1164
|
+
* so the template still serializes cleanly.
|
|
1165
|
+
*
|
|
1166
|
+
* `context` is an optional per-key hint the renderer can supply (e.g.
|
|
1167
|
+
* the trailing comment from the source line, or the comment block
|
|
1168
|
+
* directly above) to anchor the generation.
|
|
1169
|
+
*/
|
|
1170
|
+
async function generateExamples(keys, opts = {}) {
|
|
1171
|
+
if (keys.length === 0) return /* @__PURE__ */ new Map();
|
|
1172
|
+
const provider = opts.provider ?? "anthropic";
|
|
1173
|
+
const apiKeyEnv = opts.apiKeyEnv ?? (provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY");
|
|
1174
|
+
const auth = resolveAuth(provider, apiKeyEnv);
|
|
1175
|
+
if (!auth) throw new AiError(`${apiKeyEnv} not set in process.env (load it from your .env, or export it). For Anthropic, ${ANTHROPIC_OAUTH_ENVS.join(" / ")} are also accepted.`);
|
|
1176
|
+
const model = opts.model ?? (provider === "anthropic" ? "claude-haiku-4-5-20251001" : "gpt-4o-mini");
|
|
1177
|
+
const prompt = buildPrompt(keys);
|
|
1178
|
+
return parseResponse(provider === "anthropic" ? await callAnthropic({
|
|
1179
|
+
auth,
|
|
1180
|
+
model,
|
|
1181
|
+
prompt,
|
|
1182
|
+
baseUrl: opts.baseUrl
|
|
1183
|
+
}) : await callOpenAi({
|
|
1184
|
+
apiKey: auth.token,
|
|
1185
|
+
model,
|
|
1186
|
+
prompt,
|
|
1187
|
+
baseUrl: opts.baseUrl
|
|
1188
|
+
}), keys.map((k) => k.name));
|
|
1189
|
+
}
|
|
1190
|
+
function buildPrompt(keys) {
|
|
1191
|
+
return [
|
|
1192
|
+
"You are generating placeholder values for a `.env.example` file.",
|
|
1193
|
+
"Each value should be a realistic-looking but obviously-fake example a developer can copy and replace.",
|
|
1194
|
+
"",
|
|
1195
|
+
"Rules:",
|
|
1196
|
+
"- Output JSON only, no prose, no code fences. A single object mapping each key (verbatim) to its example string value.",
|
|
1197
|
+
"- Use shape that matches the key name: URLs for *_URL, ports for PORT/*_PORT, booleans (`true`/`false`) for IS_*/HAS_*/ENABLE_*, hex/base64-ish strings for *_KEY/*_TOKEN/*_SECRET, integers for *_COUNT/*_LIMIT, log levels for LOG_LEVEL, etc.",
|
|
1198
|
+
"- Never use a real-looking secret. Prefix sensitive-looking values with `your-` or end with `-here` so reviewers see them as placeholders.",
|
|
1199
|
+
"- Keep each value short — under 60 chars unless a JWT or URL forces it.",
|
|
1200
|
+
"- If a hint is given in parentheses after a key, use it as context.",
|
|
1201
|
+
"",
|
|
1202
|
+
"Keys:",
|
|
1203
|
+
keys.map((k) => k.hint && k.hint.length > 0 ? `- ${k.name} (${k.hint.trim()})` : `- ${k.name}`).join("\n"),
|
|
1204
|
+
"",
|
|
1205
|
+
"Return JSON object:"
|
|
1206
|
+
].join("\n");
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Parse the AI's JSON response. Tolerant of code fences, leading prose,
|
|
1210
|
+
* trailing junk — extracts the first {…} block and JSON.parses it. Any
|
|
1211
|
+
* key missing from the parsed object is omitted from the result map
|
|
1212
|
+
* (caller falls back to an empty value).
|
|
1213
|
+
*/
|
|
1214
|
+
function parseResponse(text, expectedKeys) {
|
|
1215
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
1216
|
+
const candidate = fenced ? fenced[1] : text;
|
|
1217
|
+
const start = candidate.indexOf("{");
|
|
1218
|
+
const end = candidate.lastIndexOf("}");
|
|
1219
|
+
if (start === -1 || end === -1 || end <= start) throw new AiError(`AI returned no JSON object: ${text.slice(0, 200)}`);
|
|
1220
|
+
const slice = candidate.slice(start, end + 1);
|
|
1221
|
+
let parsed;
|
|
1222
|
+
try {
|
|
1223
|
+
parsed = JSON.parse(slice);
|
|
1224
|
+
} catch (e) {
|
|
1225
|
+
throw new AiError(`AI returned invalid JSON: ${e.message} — ${slice.slice(0, 200)}`);
|
|
1226
|
+
}
|
|
1227
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new AiError("AI returned a non-object payload");
|
|
1228
|
+
const out = /* @__PURE__ */ new Map();
|
|
1229
|
+
const obj = parsed;
|
|
1230
|
+
for (const k of expectedKeys) {
|
|
1231
|
+
const v = obj[k];
|
|
1232
|
+
if (typeof v === "string") out.set(k, v);
|
|
1233
|
+
else if (typeof v === "number" || typeof v === "boolean") out.set(k, String(v));
|
|
1234
|
+
}
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1237
|
+
async function fetchWithTimeout(url, init, ms) {
|
|
1238
|
+
const ctrl = new AbortController();
|
|
1239
|
+
const timer = setTimeout(() => ctrl.abort(), ms);
|
|
1240
|
+
try {
|
|
1241
|
+
return await fetch(url, {
|
|
1242
|
+
...init,
|
|
1243
|
+
signal: ctrl.signal
|
|
1244
|
+
});
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
if (err.name === "AbortError") throw new AiError(`request timed out after ${ms}ms (${url})`);
|
|
1247
|
+
throw err;
|
|
1248
|
+
} finally {
|
|
1249
|
+
clearTimeout(timer);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
async function callAnthropic(opts) {
|
|
1253
|
+
const base = opts.baseUrl ?? ANTHROPIC_DEFAULT_BASE;
|
|
1254
|
+
const headers = {
|
|
1255
|
+
"content-type": "application/json",
|
|
1256
|
+
"anthropic-version": "2023-06-01"
|
|
1257
|
+
};
|
|
1258
|
+
if (opts.auth.kind === "oauth") {
|
|
1259
|
+
headers.authorization = `Bearer ${opts.auth.token}`;
|
|
1260
|
+
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
1261
|
+
} else headers["x-api-key"] = opts.auth.token;
|
|
1262
|
+
const res = await fetchWithTimeout(`${base}/v1/messages`, {
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
headers,
|
|
1265
|
+
body: JSON.stringify({
|
|
1266
|
+
model: opts.model,
|
|
1267
|
+
max_tokens: 2048,
|
|
1268
|
+
messages: [{
|
|
1269
|
+
role: "user",
|
|
1270
|
+
content: opts.prompt
|
|
1271
|
+
}]
|
|
1272
|
+
})
|
|
1273
|
+
}, REQUEST_TIMEOUT_MS);
|
|
1274
|
+
if (!res.ok) throw new AiError(`anthropic ${res.status}: ${await res.text()}`);
|
|
1275
|
+
const block = (await res.json()).content?.find((b) => b.type === "text");
|
|
1276
|
+
if (!block?.text) throw new AiError("anthropic returned no text content");
|
|
1277
|
+
return block.text;
|
|
1278
|
+
}
|
|
1279
|
+
async function callOpenAi(opts) {
|
|
1280
|
+
const res = await fetchWithTimeout(`${opts.baseUrl ?? OPENAI_DEFAULT_BASE}/chat/completions`, {
|
|
1281
|
+
method: "POST",
|
|
1282
|
+
headers: {
|
|
1283
|
+
"content-type": "application/json",
|
|
1284
|
+
authorization: `Bearer ${opts.apiKey}`
|
|
1285
|
+
},
|
|
1286
|
+
body: JSON.stringify({
|
|
1287
|
+
model: opts.model,
|
|
1288
|
+
messages: [{
|
|
1289
|
+
role: "user",
|
|
1290
|
+
content: opts.prompt
|
|
1291
|
+
}],
|
|
1292
|
+
temperature: .2
|
|
1293
|
+
})
|
|
1294
|
+
}, REQUEST_TIMEOUT_MS);
|
|
1295
|
+
if (!res.ok) throw new AiError(`openai ${res.status}: ${await res.text()}`);
|
|
1296
|
+
const text = (await res.json()).choices?.[0]?.message?.content;
|
|
1297
|
+
if (!text) throw new AiError("openai returned no text content");
|
|
1298
|
+
return text;
|
|
1299
|
+
}
|
|
1300
|
+
//#endregion
|
|
1065
1301
|
//#region src/commands/template.ts
|
|
1066
1302
|
/**
|
|
1067
1303
|
* Generate (or check) a stripped `.env.example` from one or more real
|
|
1068
|
-
* env files.
|
|
1069
|
-
*
|
|
1304
|
+
* env files. The output mirrors the source files' organization —
|
|
1305
|
+
* comments, blank lines, and declaration order are preserved verbatim.
|
|
1306
|
+
* Values are stripped (or replaced with AI-generated placeholders when
|
|
1307
|
+
* `--ai` is set). The encryption banner block (`#/-----[ENVX_PUBLIC_KEY]…`)
|
|
1308
|
+
* and the `*PUBLIC_KEY*` kv lines themselves are dropped — the example
|
|
1309
|
+
* file should be encryption-agnostic.
|
|
1070
1310
|
*
|
|
1071
1311
|
* Three modes:
|
|
1072
1312
|
* - default: write the rendered template to --out (default `.env.example`)
|
|
@@ -1087,17 +1327,27 @@ var templateCommand = {
|
|
|
1087
1327
|
type: "boolean",
|
|
1088
1328
|
default: false,
|
|
1089
1329
|
describe: "Compare the rendered template against the on-disk file; exit 1 on drift. Use this in CI."
|
|
1090
|
-
}).option("
|
|
1330
|
+
}).option("ai", {
|
|
1091
1331
|
type: "boolean",
|
|
1092
|
-
default:
|
|
1093
|
-
describe: "
|
|
1332
|
+
default: false,
|
|
1333
|
+
describe: "Use Claude (or OpenAI) to generate realistic-looking placeholder values for each key. Reads ANTHROPIC_API_KEY (or OPENAI_API_KEY) from your loaded env files. Non-deterministic — usually paired with manual review, not --check."
|
|
1334
|
+
}).option("ai-provider", {
|
|
1335
|
+
type: "string",
|
|
1336
|
+
choices: ["anthropic", "openai"],
|
|
1337
|
+
default: "anthropic",
|
|
1338
|
+
describe: "Which AI provider --ai uses. Default: anthropic."
|
|
1339
|
+
}).option("ai-model", {
|
|
1340
|
+
type: "string",
|
|
1341
|
+
describe: "Override the AI model. Defaults: claude-haiku-4-5-20251001 (anthropic), gpt-4o-mini (openai)."
|
|
1094
1342
|
}),
|
|
1095
|
-
handler: (argv) => {
|
|
1343
|
+
handler: async (argv) => {
|
|
1096
1344
|
const cfg = argv["__envxConfig"] ?? {};
|
|
1097
1345
|
const outArg = argv["out"];
|
|
1098
1346
|
const useStdout = argv["stdout"];
|
|
1099
1347
|
const check = argv["check"];
|
|
1100
|
-
const
|
|
1348
|
+
const useAi = argv["ai"];
|
|
1349
|
+
const aiProvider = argv["ai-provider"];
|
|
1350
|
+
const aiModel = argv["ai-model"];
|
|
1101
1351
|
const paths = resolveEnvPaths({
|
|
1102
1352
|
...argv["env"] !== void 0 ? { envFiles: argv["env"] } : {},
|
|
1103
1353
|
...argv["cascade"] !== void 0 ? { cascade: argv["cascade"] } : {},
|
|
@@ -1110,7 +1360,32 @@ var templateCommand = {
|
|
|
1110
1360
|
log.error("no env files found to template from");
|
|
1111
1361
|
process.exit(1);
|
|
1112
1362
|
}
|
|
1113
|
-
|
|
1363
|
+
let aiValues = /* @__PURE__ */ new Map();
|
|
1364
|
+
if (useAi) {
|
|
1365
|
+
try {
|
|
1366
|
+
loadEnv({
|
|
1367
|
+
envFiles: existing,
|
|
1368
|
+
quiet: true
|
|
1369
|
+
});
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
log.error(`--ai: failed to load env for API-key lookup: ${e.message}`);
|
|
1372
|
+
process.exit(1);
|
|
1373
|
+
}
|
|
1374
|
+
const keys = collectKeys(existing);
|
|
1375
|
+
if (keys.length === 0) log.warn("--ai: no keys to generate examples for");
|
|
1376
|
+
else try {
|
|
1377
|
+
aiValues = await generateExamples(keys, {
|
|
1378
|
+
provider: aiProvider,
|
|
1379
|
+
...aiModel !== void 0 ? { model: aiModel } : {}
|
|
1380
|
+
});
|
|
1381
|
+
log.info(`--ai: generated ${aiValues.size}/${keys.length} example value(s)`);
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
if (e instanceof AiError) log.error(e.message);
|
|
1384
|
+
else log.error(`--ai: ${e.message}`);
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const rendered = renderTemplate(existing, { aiValues });
|
|
1114
1389
|
if (useStdout) {
|
|
1115
1390
|
process.stdout.write(rendered);
|
|
1116
1391
|
return;
|
|
@@ -1129,32 +1404,100 @@ var templateCommand = {
|
|
|
1129
1404
|
log.success(`wrote template: ${outPath} (${countKeys(rendered)} key(s))`);
|
|
1130
1405
|
}
|
|
1131
1406
|
};
|
|
1407
|
+
/**
|
|
1408
|
+
* Render the template by walking each source file in order. Comments,
|
|
1409
|
+
* blank lines, and declaration order are preserved verbatim. A kv line
|
|
1410
|
+
* becomes `KEY=` (or `KEY=<ai-value>` when `--ai` populated `aiValues`).
|
|
1411
|
+
*
|
|
1412
|
+
* Two filters apply to every line, in this order:
|
|
1413
|
+
* 1. The encryption banner block — any line starting with `#/` (e.g.
|
|
1414
|
+
* `#/-------------------[ENVX_PUBLIC_KEY]------------------/`).
|
|
1415
|
+
* The full 4-line banner is dropped.
|
|
1416
|
+
* 2. Public-key kv lines — `ENVX_PUBLIC_KEY*` and the legacy
|
|
1417
|
+
* `DOTENV_PUBLIC_KEY*`. They're committed alongside encrypted
|
|
1418
|
+
* values, but `.env.example` is meant to be encryption-agnostic.
|
|
1419
|
+
*
|
|
1420
|
+
* Multi-file dedup: when a key appears in more than one source file,
|
|
1421
|
+
* only the first occurrence's line is emitted. Subsequent files'
|
|
1422
|
+
* comments and blank lines still pass through (so the per-file
|
|
1423
|
+
* structure stays visible), but their kv duplicates are skipped.
|
|
1424
|
+
*/
|
|
1132
1425
|
function renderTemplate(files, opts) {
|
|
1133
1426
|
const seen = /* @__PURE__ */ new Set();
|
|
1134
1427
|
const out = [];
|
|
1135
1428
|
out.push("# Generated by `envx template` — do not edit by hand.");
|
|
1136
1429
|
out.push("# Run `envx template` to regenerate, or `envx template --check` in CI.");
|
|
1137
1430
|
out.push("");
|
|
1138
|
-
for (
|
|
1431
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
1432
|
+
const file = files[i];
|
|
1139
1433
|
const display = path.relative(process.cwd(), file) || file;
|
|
1140
1434
|
const lines = parseEnv(fs.readFileSync(file, "utf8"));
|
|
1141
|
-
|
|
1435
|
+
if (files.length > 1) out.push(`# ── ${display} ─────────────────────────────────`);
|
|
1436
|
+
appendFile(out, lines, seen, opts.aiValues);
|
|
1437
|
+
if (i < files.length - 1 && (out.length === 0 || out[out.length - 1] !== "")) out.push("");
|
|
1438
|
+
}
|
|
1439
|
+
return out.join("\n");
|
|
1440
|
+
}
|
|
1441
|
+
function appendFile(out, lines, seen, aiValues) {
|
|
1442
|
+
for (const ln of lines) {
|
|
1443
|
+
if (ln.type === "raw") {
|
|
1444
|
+
if (isBannerLine(ln.raw)) continue;
|
|
1445
|
+
out.push(ln.raw);
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
if (isPublicKeyName(ln.key)) continue;
|
|
1449
|
+
if (seen.has(ln.key)) continue;
|
|
1450
|
+
seen.add(ln.key);
|
|
1451
|
+
const aiValue = aiValues.get(ln.key);
|
|
1452
|
+
if (aiValue !== void 0 && aiValue !== "") {
|
|
1453
|
+
const quoted = /[\s#'"\\$&;|<>(){}]/.test(aiValue) ? `"${aiValue.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : aiValue;
|
|
1454
|
+
out.push(`${ln.key}=${quoted}`);
|
|
1455
|
+
} else out.push(`${ln.key}=`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Banner lines emitted by `envx encrypt` start with `#/`. Be permissive:
|
|
1460
|
+
* any comment beginning with that two-char prefix is treated as banner
|
|
1461
|
+
* decoration. Real user comments use `#` (with or without a space).
|
|
1462
|
+
*/
|
|
1463
|
+
function isBannerLine(raw) {
|
|
1464
|
+
return /^\s*#\//.test(raw);
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* `ENVX_PUBLIC_KEY*` is the canonical public-key var; `DOTENV_PUBLIC_KEY*`
|
|
1468
|
+
* is the upstream-dotenvx-compat variant envx also reads. Both are an
|
|
1469
|
+
* artifact of encryption, never user-meaningful — drop from `.env.example`.
|
|
1470
|
+
*/
|
|
1471
|
+
function isPublicKeyName(name) {
|
|
1472
|
+
return /^(ENVX|DOTENV)_PUBLIC_KEY/.test(name);
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Collect every unique kv key (in declaration order) across the source
|
|
1476
|
+
* files, skipping the encryption-banner public-key vars. Used to feed
|
|
1477
|
+
* `--ai` exactly the keys that will appear in the rendered template.
|
|
1478
|
+
*
|
|
1479
|
+
* Each entry includes a `hint` derived from the kv line's trailing
|
|
1480
|
+
* comment (e.g. `# database connection string`) — the AI prompt uses
|
|
1481
|
+
* the hint to anchor the example value.
|
|
1482
|
+
*/
|
|
1483
|
+
function collectKeys(files) {
|
|
1484
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1485
|
+
const out = [];
|
|
1486
|
+
for (const file of files) {
|
|
1487
|
+
const lines = parseEnv(fs.readFileSync(file, "utf8"));
|
|
1142
1488
|
for (const ln of lines) {
|
|
1143
1489
|
if (ln.type !== "kv") continue;
|
|
1490
|
+
if (isPublicKeyName(ln.key)) continue;
|
|
1144
1491
|
if (seen.has(ln.key)) continue;
|
|
1145
1492
|
seen.add(ln.key);
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (ln.type !== "kv") continue;
|
|
1152
|
-
if (opts.annotate) out.push(`# from ${display}`);
|
|
1153
|
-
out.push(`${ln.key}=`);
|
|
1154
|
-
out.push("");
|
|
1493
|
+
const hint = ln.trailing.replace(/^\s*#\s*/, "").trim();
|
|
1494
|
+
out.push(hint.length > 0 ? {
|
|
1495
|
+
name: ln.key,
|
|
1496
|
+
hint
|
|
1497
|
+
} : { name: ln.key });
|
|
1155
1498
|
}
|
|
1156
1499
|
}
|
|
1157
|
-
return out
|
|
1500
|
+
return out;
|
|
1158
1501
|
}
|
|
1159
1502
|
function countKeys(rendered) {
|
|
1160
1503
|
return rendered.split("\n").filter((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l)).length;
|
|
@@ -1499,4 +1842,4 @@ function createCli(argvInput) {
|
|
|
1499
1842
|
//#endregion
|
|
1500
1843
|
export { createCli as t };
|
|
1501
1844
|
|
|
1502
|
-
//# sourceMappingURL=commands-
|
|
1845
|
+
//# sourceMappingURL=commands-B70xh15E.js.map
|