configenvy 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +31 -4
- package/dist/index.js +194 -18
- package/package.json +2 -2
- package/src/index.ts +256 -17
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { buildMarkdownTable, explainVariable, scanProject, toJson, toSarif, Diagnostic } from '@configenvy/core';
|
|
5
|
+
import { buildMarkdownTable, explainVariable, scanProject, toJson, toSarif, ScanResult, Diagnostic } from '@configenvy/core';
|
|
6
6
|
|
|
7
7
|
type DoctorOptions = {
|
|
8
8
|
format?: "text" | "json" | "sarif";
|
|
9
9
|
strict?: boolean;
|
|
10
10
|
ci?: boolean;
|
|
11
11
|
};
|
|
12
|
+
type InitOptions = {
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
envExample?: boolean;
|
|
15
|
+
force?: boolean;
|
|
16
|
+
preset?: string;
|
|
17
|
+
};
|
|
12
18
|
type CliDependencies = {
|
|
13
19
|
buildMarkdownTable: typeof buildMarkdownTable;
|
|
14
20
|
error: (...values: unknown[]) => void;
|
|
15
21
|
exit: (code: number) => never | void;
|
|
16
22
|
explainVariable: typeof explainVariable;
|
|
17
23
|
log: (...values: unknown[]) => void;
|
|
24
|
+
readFile: typeof readFile;
|
|
18
25
|
resolvePath: typeof resolve;
|
|
19
26
|
scanProject: typeof scanProject;
|
|
20
27
|
toJson: typeof toJson;
|
|
@@ -22,11 +29,31 @@ type CliDependencies = {
|
|
|
22
29
|
writeFile: typeof writeFile;
|
|
23
30
|
};
|
|
24
31
|
declare function createProgram(dependencies?: CliDependencies): Command;
|
|
32
|
+
type StarterConfig = {
|
|
33
|
+
docs: string[];
|
|
34
|
+
ignore: string[];
|
|
35
|
+
optional: string[];
|
|
36
|
+
required: string[];
|
|
37
|
+
};
|
|
38
|
+
type InitFile = {
|
|
39
|
+
content: string;
|
|
40
|
+
path: string;
|
|
41
|
+
};
|
|
42
|
+
declare const cliVersion: string;
|
|
43
|
+
declare const tableBlockStart = "<!-- configenvy:start -->";
|
|
44
|
+
declare const tableBlockEnd = "<!-- configenvy:end -->";
|
|
25
45
|
declare function runCli(argv: string[], dependencies?: CliDependencies): Promise<void>;
|
|
26
46
|
declare function runDoctor(projectPath: string, options: DoctorOptions, dependencies?: CliDependencies): Promise<void>;
|
|
27
|
-
declare function runInit(projectPath: string, dependencies?: CliDependencies): Promise<void>;
|
|
47
|
+
declare function runInit(projectPath: string, options?: InitOptions, dependencies?: CliDependencies): Promise<void>;
|
|
48
|
+
declare function buildInitFiles(rootDir: string, result: ScanResult, includeEnvExample: boolean, resolvePath?: typeof resolve, existingEnvExample?: string, preset?: Partial<StarterConfig>): InitFile[];
|
|
49
|
+
declare function runTableUpdate(rootDir: string, table: string, options: {
|
|
50
|
+
dryRun?: boolean;
|
|
51
|
+
force?: boolean;
|
|
52
|
+
update: string;
|
|
53
|
+
}, dependencies?: CliDependencies): Promise<void>;
|
|
54
|
+
declare function updateMarkdownTableBlock(markdown: string, table: string, force: boolean): string;
|
|
28
55
|
declare function resolveOutputPath(projectPath: string, outputPath: string, resolvePath?: typeof resolve): string;
|
|
29
56
|
declare function printHumanReport(diagnostics: Diagnostic[], log?: (...values: unknown[]) => void): void;
|
|
30
57
|
declare function printGitHubAnnotations(diagnostics: Diagnostic[], log?: (...values: unknown[]) => void): void;
|
|
31
58
|
|
|
32
|
-
export { type CliDependencies, createProgram, printGitHubAnnotations, printHumanReport, resolveOutputPath, runCli, runDoctor, runInit };
|
|
59
|
+
export { type CliDependencies, buildInitFiles, cliVersion, createProgram, printGitHubAnnotations, printHumanReport, resolveOutputPath, runCli, runDoctor, runInit, runTableUpdate, tableBlockEnd, tableBlockStart, updateMarkdownTableBlock };
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { writeFile } from "fs/promises";
|
|
4
|
+
import { readFile, writeFile } from "fs/promises";
|
|
5
|
+
import { createRequire } from "module";
|
|
5
6
|
import { isAbsolute, resolve } from "path";
|
|
6
7
|
import { pathToFileURL } from "url";
|
|
7
8
|
import { Command } from "commander";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildMarkdownTable,
|
|
11
|
+
explainVariable,
|
|
12
|
+
scanProject,
|
|
13
|
+
toJson,
|
|
14
|
+
toSarif
|
|
15
|
+
} from "@configenvy/core";
|
|
9
16
|
var defaultDependencies = {
|
|
10
17
|
buildMarkdownTable,
|
|
11
18
|
error: console.error,
|
|
12
19
|
exit: process.exit,
|
|
13
20
|
explainVariable,
|
|
14
21
|
log: console.log,
|
|
22
|
+
readFile,
|
|
15
23
|
resolvePath: resolve,
|
|
16
24
|
scanProject,
|
|
17
25
|
toJson,
|
|
@@ -20,26 +28,28 @@ var defaultDependencies = {
|
|
|
20
28
|
};
|
|
21
29
|
function createProgram(dependencies = defaultDependencies) {
|
|
22
30
|
const program = new Command();
|
|
23
|
-
program.name("configenvy").description("Find missing, unused, undocumented, and risky environment variables.").version(
|
|
31
|
+
program.name("configenvy").description("Find missing, unused, undocumented, and risky environment variables.").version(cliVersion);
|
|
24
32
|
program.command("doctor").argument("[path]", "project directory", ".").option("--format <format>", "output format: text, json, or sarif", "text").option("--strict", "treat documentation warnings as errors").action(async (projectPath, options) => {
|
|
25
33
|
await runDoctor(projectPath, options, dependencies);
|
|
26
34
|
});
|
|
27
35
|
program.command("check").argument("[path]", "project directory", ".").option("--ci", "fail on warnings and errors").option("--format <format>", "output format: text, json, or sarif", "text").action(async (projectPath, options) => {
|
|
28
36
|
await runDoctor(projectPath, { ...options, strict: Boolean(options.ci), ci: Boolean(options.ci) }, dependencies);
|
|
29
37
|
});
|
|
30
|
-
program.command("table").argument("[path]", "project directory", ".").option("--out <file>", "write markdown table to a file").action(async (projectPath, options) => {
|
|
38
|
+
program.command("table").argument("[path]", "project directory", ".").option("--out <file>", "write markdown table to a file").option("--update <file>", "replace a marked configenvy table block in a markdown file").option("--force", "append a configenvy table block when --update target has no marked block").option("--dry-run", "print the updated markdown instead of writing it").action(async (projectPath, options) => {
|
|
31
39
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
32
40
|
const result = await dependencies.scanProject({ rootDir });
|
|
33
41
|
const table = dependencies.buildMarkdownTable(result);
|
|
34
|
-
if (options.
|
|
42
|
+
if (options.update) {
|
|
43
|
+
await runTableUpdate(rootDir, table, { ...options, update: options.update }, dependencies);
|
|
44
|
+
} else if (options.out) {
|
|
35
45
|
await dependencies.writeFile(resolveOutputPath(rootDir, options.out, dependencies.resolvePath), `${table}
|
|
36
46
|
`, "utf8");
|
|
37
47
|
} else {
|
|
38
48
|
dependencies.log(table);
|
|
39
49
|
}
|
|
40
50
|
});
|
|
41
|
-
program.command("init").argument("[path]", "project directory", ".").description("create
|
|
42
|
-
await runInit(projectPath, dependencies);
|
|
51
|
+
program.command("init").argument("[path]", "project directory", ".").description("create starter configenvy files").option("--dry-run", "print planned files instead of writing them").option("--env-example", "also create a .env.example draft from detected variables").option("--force", "overwrite generated files if they already exist").option("--preset <name>", `apply a preset: ${availablePresetList}`).action(async (projectPath, options) => {
|
|
52
|
+
await runInit(projectPath, options, dependencies);
|
|
43
53
|
});
|
|
44
54
|
program.command("explain").argument("<variable>", "environment variable name").argument("[path]", "project directory", ".").action(async (variable, projectPath) => {
|
|
45
55
|
const result = await dependencies.scanProject({ rootDir: dependencies.resolvePath(projectPath) });
|
|
@@ -53,6 +63,30 @@ var starterConfig = {
|
|
|
53
63
|
ignore: ["NODE_ENV"],
|
|
54
64
|
docs: ["README.md", "docs"]
|
|
55
65
|
};
|
|
66
|
+
var presetConfigs = {
|
|
67
|
+
docker: {
|
|
68
|
+
optional: ["COMPOSE_PROJECT_NAME"],
|
|
69
|
+
ignore: ["HOSTNAME"]
|
|
70
|
+
},
|
|
71
|
+
nextjs: {
|
|
72
|
+
optional: ["NEXT_PUBLIC_APP_URL"],
|
|
73
|
+
ignore: ["NEXT_RUNTIME"]
|
|
74
|
+
},
|
|
75
|
+
vercel: {
|
|
76
|
+
ignore: ["VERCEL", "VERCEL_ENV", "VERCEL_URL", "VERCEL_BRANCH_URL", "VERCEL_PROJECT_PRODUCTION_URL", "VERCEL_REGION"]
|
|
77
|
+
},
|
|
78
|
+
vite: {
|
|
79
|
+
optional: ["VITE_PUBLIC_URL"],
|
|
80
|
+
ignore: ["BASE_URL", "DEV", "MODE", "PROD", "SSR"]
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var availablePresetNames = Object.keys(presetConfigs).sort();
|
|
84
|
+
var availablePresetList = availablePresetNames.join(", ");
|
|
85
|
+
var require2 = createRequire(import.meta.url);
|
|
86
|
+
var cliPackage = require2("../package.json");
|
|
87
|
+
var cliVersion = cliPackage.version;
|
|
88
|
+
var tableBlockStart = "<!-- configenvy:start -->";
|
|
89
|
+
var tableBlockEnd = "<!-- configenvy:end -->";
|
|
56
90
|
async function runCli(argv, dependencies = defaultDependencies) {
|
|
57
91
|
const program = createProgram(dependencies);
|
|
58
92
|
await program.parseAsync(argv);
|
|
@@ -77,22 +111,155 @@ async function runDoctor(projectPath, options, dependencies = defaultDependencie
|
|
|
77
111
|
if (hasError || options.ci && hasWarning) dependencies.exit(2);
|
|
78
112
|
if (hasWarning) dependencies.exit(1);
|
|
79
113
|
}
|
|
80
|
-
async function runInit(projectPath, dependencies = defaultDependencies) {
|
|
114
|
+
async function runInit(projectPath, options = {}, dependencies = defaultDependencies) {
|
|
81
115
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
116
|
+
const result = await dependencies.scanProject({ rootDir });
|
|
117
|
+
const preset = resolvePreset(options.preset, dependencies);
|
|
118
|
+
if (options.preset && !preset) return;
|
|
119
|
+
const existingEnvExample = options.envExample && options.force ? await readOptionalText(dependencies.resolvePath(rootDir, ".env.example"), dependencies) : void 0;
|
|
120
|
+
const files = buildInitFiles(rootDir, result, Boolean(options.envExample), dependencies.resolvePath, existingEnvExample, preset);
|
|
121
|
+
if (options.dryRun) {
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
dependencies.log(`Would write ${file.path}`);
|
|
124
|
+
dependencies.log(file.content.trimEnd());
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!options.force && !await ensureInitTargetsDoNotExist(files, dependencies)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const flag = options.force ? "w" : "wx";
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
try {
|
|
134
|
+
await dependencies.writeFile(file.path, file.content, { encoding: "utf8", flag });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isNodeError(error) && error.code === "EEXIST") {
|
|
137
|
+
dependencies.error(`${file.path} already exists. Re-run with --force to overwrite.`);
|
|
138
|
+
dependencies.exit(1);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
dependencies.log(`Created ${file.path}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function buildInitFiles(rootDir, result, includeEnvExample, resolvePath = resolve, existingEnvExample, preset) {
|
|
147
|
+
const required = detectedRuntimeVariables(result);
|
|
148
|
+
const config = {
|
|
149
|
+
...starterConfig,
|
|
150
|
+
docs: mergeUnique(starterConfig.docs, preset?.docs ?? []),
|
|
151
|
+
ignore: mergeUnique(starterConfig.ignore, preset?.ignore ?? []),
|
|
152
|
+
optional: mergeUnique(starterConfig.optional, preset?.optional ?? []),
|
|
153
|
+
required
|
|
154
|
+
};
|
|
155
|
+
const files = [
|
|
156
|
+
{
|
|
157
|
+
path: resolvePath(rootDir, "configenvy.config.json"),
|
|
158
|
+
content: `${JSON.stringify(config, null, 2)}
|
|
159
|
+
`
|
|
160
|
+
}
|
|
161
|
+
];
|
|
162
|
+
if (includeEnvExample) {
|
|
163
|
+
files.push({
|
|
164
|
+
path: resolvePath(rootDir, ".env.example"),
|
|
165
|
+
content: buildEnvExampleDraft(result, existingEnvExample)
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
function resolvePreset(name, dependencies) {
|
|
171
|
+
if (!name) return void 0;
|
|
172
|
+
if (name in presetConfigs) {
|
|
173
|
+
return presetConfigs[name];
|
|
174
|
+
}
|
|
175
|
+
dependencies.error(`Unknown preset "${name}". Available presets: ${availablePresetList}.`);
|
|
176
|
+
dependencies.exit(1);
|
|
177
|
+
return void 0;
|
|
178
|
+
}
|
|
179
|
+
function mergeUnique(base, extra) {
|
|
180
|
+
return [.../* @__PURE__ */ new Set([...base, ...extra])].sort();
|
|
181
|
+
}
|
|
182
|
+
async function ensureInitTargetsDoNotExist(files, dependencies) {
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
const existing = await readOptionalText(file.path, dependencies);
|
|
185
|
+
if (existing !== void 0) {
|
|
186
|
+
dependencies.error(`${file.path} already exists. Re-run with --force to overwrite.`);
|
|
187
|
+
dependencies.exit(1);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
async function readOptionalText(path, dependencies) {
|
|
85
194
|
try {
|
|
86
|
-
await dependencies.
|
|
195
|
+
return String(await dependencies.readFile(path, "utf8"));
|
|
87
196
|
} catch (error) {
|
|
88
|
-
if (isNodeError(error) && error.code === "
|
|
89
|
-
|
|
90
|
-
dependencies.exit(1);
|
|
91
|
-
return;
|
|
197
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
198
|
+
return void 0;
|
|
92
199
|
}
|
|
93
200
|
throw error;
|
|
94
201
|
}
|
|
95
|
-
|
|
202
|
+
}
|
|
203
|
+
function detectedRuntimeVariables(result) {
|
|
204
|
+
const runtimeKinds = /* @__PURE__ */ new Set(["code", "ci", "config"]);
|
|
205
|
+
return [...new Set(result.references.filter((reference) => runtimeKinds.has(reference.kind)).map((reference) => reference.name))].sort();
|
|
206
|
+
}
|
|
207
|
+
function buildEnvExampleDraft(result, existingContent) {
|
|
208
|
+
const runtimeVars = detectedRuntimeVariables(result);
|
|
209
|
+
if (existingContent !== void 0) {
|
|
210
|
+
const existingVars = extractEnvExampleNames(existingContent);
|
|
211
|
+
const missingVars = runtimeVars.filter((variable) => !existingVars.has(variable));
|
|
212
|
+
if (missingVars.length === 0) {
|
|
213
|
+
return existingContent.endsWith("\n") ? existingContent : `${existingContent}
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
return [
|
|
217
|
+
existingContent.trimEnd(),
|
|
218
|
+
"# Added by configenvy init",
|
|
219
|
+
...missingVars.map((variable) => `${variable}=`)
|
|
220
|
+
].filter(Boolean).join("\n") + "\n";
|
|
221
|
+
}
|
|
222
|
+
const lines = ["# Generated by configenvy init"];
|
|
223
|
+
for (const variable of runtimeVars) {
|
|
224
|
+
lines.push(`${variable}=`);
|
|
225
|
+
}
|
|
226
|
+
return `${lines.join("\n")}
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
function extractEnvExampleNames(content) {
|
|
230
|
+
const names = /* @__PURE__ */ new Set();
|
|
231
|
+
for (const line of content.split(/\r?\n/)) {
|
|
232
|
+
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
233
|
+
if (match?.[1]) {
|
|
234
|
+
names.add(match[1]);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return names;
|
|
238
|
+
}
|
|
239
|
+
async function runTableUpdate(rootDir, table, options, dependencies = defaultDependencies) {
|
|
240
|
+
const targetPath = resolveOutputPath(rootDir, options.update, dependencies.resolvePath);
|
|
241
|
+
const current = await dependencies.readFile(targetPath, "utf8");
|
|
242
|
+
const updated = updateMarkdownTableBlock(current, table, Boolean(options.force));
|
|
243
|
+
if (options.dryRun) {
|
|
244
|
+
dependencies.log(updated);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
await dependencies.writeFile(targetPath, updated, "utf8");
|
|
248
|
+
}
|
|
249
|
+
function updateMarkdownTableBlock(markdown, table, force) {
|
|
250
|
+
const block = `${tableBlockStart}
|
|
251
|
+
${table}
|
|
252
|
+
${tableBlockEnd}`;
|
|
253
|
+
const blockPattern = new RegExp(`${escapeRegExp(tableBlockStart)}[\\s\\S]*?${escapeRegExp(tableBlockEnd)}`);
|
|
254
|
+
if (blockPattern.test(markdown)) {
|
|
255
|
+
return markdown.replace(blockPattern, block);
|
|
256
|
+
}
|
|
257
|
+
if (force) {
|
|
258
|
+
const separator = markdown.endsWith("\n") ? "\n" : "\n\n";
|
|
259
|
+
return `${markdown}${separator}${block}
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`No configenvy table block found. Add ${tableBlockStart} and ${tableBlockEnd}, or rerun with --force.`);
|
|
96
263
|
}
|
|
97
264
|
function resolveOutputPath(projectPath, outputPath, resolvePath = resolve) {
|
|
98
265
|
if (isAbsolute(outputPath)) return outputPath;
|
|
@@ -131,6 +298,9 @@ function escapeAnnotationProperty(value) {
|
|
|
131
298
|
function escapeAnnotationMessage(value) {
|
|
132
299
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
133
300
|
}
|
|
301
|
+
function escapeRegExp(value) {
|
|
302
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
303
|
+
}
|
|
134
304
|
function isNodeError(error) {
|
|
135
305
|
return error instanceof Error && "code" in error;
|
|
136
306
|
}
|
|
@@ -142,11 +312,17 @@ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
|
|
|
142
312
|
});
|
|
143
313
|
}
|
|
144
314
|
export {
|
|
315
|
+
buildInitFiles,
|
|
316
|
+
cliVersion,
|
|
145
317
|
createProgram,
|
|
146
318
|
printGitHubAnnotations,
|
|
147
319
|
printHumanReport,
|
|
148
320
|
resolveOutputPath,
|
|
149
321
|
runCli,
|
|
150
322
|
runDoctor,
|
|
151
|
-
runInit
|
|
323
|
+
runInit,
|
|
324
|
+
runTableUpdate,
|
|
325
|
+
tableBlockEnd,
|
|
326
|
+
tableBlockStart,
|
|
327
|
+
updateMarkdownTableBlock
|
|
152
328
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "configenvy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Find missing, unused, undocumented, and risky environment variables before setup breaks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"build": "tsup src/index.ts --format esm --dts --clean"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@configenvy/core": "0.1.
|
|
38
|
+
"@configenvy/core": "0.1.6",
|
|
39
39
|
"commander": "^12.1.0"
|
|
40
40
|
},
|
|
41
41
|
"keywords": [
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import { isAbsolute, resolve } from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
5
6
|
import { Command } from "commander";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildMarkdownTable,
|
|
9
|
+
explainVariable,
|
|
10
|
+
scanProject,
|
|
11
|
+
toJson,
|
|
12
|
+
toSarif,
|
|
13
|
+
type Diagnostic,
|
|
14
|
+
type ScanResult
|
|
15
|
+
} from "@configenvy/core";
|
|
7
16
|
|
|
8
17
|
type DoctorOptions = {
|
|
9
18
|
format?: "text" | "json" | "sarif";
|
|
@@ -11,12 +20,20 @@ type DoctorOptions = {
|
|
|
11
20
|
ci?: boolean;
|
|
12
21
|
};
|
|
13
22
|
|
|
23
|
+
type InitOptions = {
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
envExample?: boolean;
|
|
26
|
+
force?: boolean;
|
|
27
|
+
preset?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
14
30
|
export type CliDependencies = {
|
|
15
31
|
buildMarkdownTable: typeof buildMarkdownTable;
|
|
16
32
|
error: (...values: unknown[]) => void;
|
|
17
33
|
exit: (code: number) => never | void;
|
|
18
34
|
explainVariable: typeof explainVariable;
|
|
19
35
|
log: (...values: unknown[]) => void;
|
|
36
|
+
readFile: typeof readFile;
|
|
20
37
|
resolvePath: typeof resolve;
|
|
21
38
|
scanProject: typeof scanProject;
|
|
22
39
|
toJson: typeof toJson;
|
|
@@ -30,6 +47,7 @@ const defaultDependencies: CliDependencies = {
|
|
|
30
47
|
exit: process.exit,
|
|
31
48
|
explainVariable,
|
|
32
49
|
log: console.log,
|
|
50
|
+
readFile,
|
|
33
51
|
resolvePath: resolve,
|
|
34
52
|
scanProject,
|
|
35
53
|
toJson,
|
|
@@ -43,7 +61,7 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
43
61
|
program
|
|
44
62
|
.name("configenvy")
|
|
45
63
|
.description("Find missing, unused, undocumented, and risky environment variables.")
|
|
46
|
-
.version(
|
|
64
|
+
.version(cliVersion);
|
|
47
65
|
|
|
48
66
|
program
|
|
49
67
|
.command("doctor")
|
|
@@ -67,11 +85,16 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
67
85
|
.command("table")
|
|
68
86
|
.argument("[path]", "project directory", ".")
|
|
69
87
|
.option("--out <file>", "write markdown table to a file")
|
|
70
|
-
.
|
|
88
|
+
.option("--update <file>", "replace a marked configenvy table block in a markdown file")
|
|
89
|
+
.option("--force", "append a configenvy table block when --update target has no marked block")
|
|
90
|
+
.option("--dry-run", "print the updated markdown instead of writing it")
|
|
91
|
+
.action(async (projectPath: string, options: { dryRun?: boolean; force?: boolean; out?: string; update?: string }) => {
|
|
71
92
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
72
93
|
const result = await dependencies.scanProject({ rootDir });
|
|
73
94
|
const table = dependencies.buildMarkdownTable(result);
|
|
74
|
-
if (options.
|
|
95
|
+
if (options.update) {
|
|
96
|
+
await runTableUpdate(rootDir, table, { ...options, update: options.update }, dependencies);
|
|
97
|
+
} else if (options.out) {
|
|
75
98
|
await dependencies.writeFile(resolveOutputPath(rootDir, options.out, dependencies.resolvePath), `${table}\n`, "utf8");
|
|
76
99
|
} else {
|
|
77
100
|
dependencies.log(table);
|
|
@@ -81,9 +104,13 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
81
104
|
program
|
|
82
105
|
.command("init")
|
|
83
106
|
.argument("[path]", "project directory", ".")
|
|
84
|
-
.description("create
|
|
85
|
-
.
|
|
86
|
-
|
|
107
|
+
.description("create starter configenvy files")
|
|
108
|
+
.option("--dry-run", "print planned files instead of writing them")
|
|
109
|
+
.option("--env-example", "also create a .env.example draft from detected variables")
|
|
110
|
+
.option("--force", "overwrite generated files if they already exist")
|
|
111
|
+
.option("--preset <name>", `apply a preset: ${availablePresetList}`)
|
|
112
|
+
.action(async (projectPath: string, options: InitOptions) => {
|
|
113
|
+
await runInit(projectPath, options, dependencies);
|
|
87
114
|
});
|
|
88
115
|
|
|
89
116
|
program
|
|
@@ -98,13 +125,55 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
98
125
|
return program;
|
|
99
126
|
}
|
|
100
127
|
|
|
101
|
-
|
|
128
|
+
type StarterConfig = {
|
|
129
|
+
docs: string[];
|
|
130
|
+
ignore: string[];
|
|
131
|
+
optional: string[];
|
|
132
|
+
required: string[];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const starterConfig: StarterConfig = {
|
|
102
136
|
required: [],
|
|
103
137
|
optional: [],
|
|
104
138
|
ignore: ["NODE_ENV"],
|
|
105
139
|
docs: ["README.md", "docs"]
|
|
106
140
|
};
|
|
107
141
|
|
|
142
|
+
type PresetName = keyof typeof presetConfigs;
|
|
143
|
+
|
|
144
|
+
const presetConfigs = {
|
|
145
|
+
docker: {
|
|
146
|
+
optional: ["COMPOSE_PROJECT_NAME"],
|
|
147
|
+
ignore: ["HOSTNAME"]
|
|
148
|
+
},
|
|
149
|
+
nextjs: {
|
|
150
|
+
optional: ["NEXT_PUBLIC_APP_URL"],
|
|
151
|
+
ignore: ["NEXT_RUNTIME"]
|
|
152
|
+
},
|
|
153
|
+
vercel: {
|
|
154
|
+
ignore: ["VERCEL", "VERCEL_ENV", "VERCEL_URL", "VERCEL_BRANCH_URL", "VERCEL_PROJECT_PRODUCTION_URL", "VERCEL_REGION"]
|
|
155
|
+
},
|
|
156
|
+
vite: {
|
|
157
|
+
optional: ["VITE_PUBLIC_URL"],
|
|
158
|
+
ignore: ["BASE_URL", "DEV", "MODE", "PROD", "SSR"]
|
|
159
|
+
}
|
|
160
|
+
} satisfies Record<string, Partial<StarterConfig>>;
|
|
161
|
+
|
|
162
|
+
const availablePresetNames = Object.keys(presetConfigs).sort();
|
|
163
|
+
const availablePresetList = availablePresetNames.join(", ");
|
|
164
|
+
|
|
165
|
+
type InitFile = {
|
|
166
|
+
content: string;
|
|
167
|
+
path: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const require = createRequire(import.meta.url);
|
|
171
|
+
const cliPackage = require("../package.json") as { version: string };
|
|
172
|
+
export const cliVersion = cliPackage.version;
|
|
173
|
+
|
|
174
|
+
export const tableBlockStart = "<!-- configenvy:start -->";
|
|
175
|
+
export const tableBlockEnd = "<!-- configenvy:end -->";
|
|
176
|
+
|
|
108
177
|
export async function runCli(argv: string[], dependencies: CliDependencies = defaultDependencies): Promise<void> {
|
|
109
178
|
const program = createProgram(dependencies);
|
|
110
179
|
await program.parseAsync(argv);
|
|
@@ -139,24 +208,190 @@ export async function runDoctor(
|
|
|
139
208
|
|
|
140
209
|
export async function runInit(
|
|
141
210
|
projectPath: string,
|
|
211
|
+
options: InitOptions = {},
|
|
142
212
|
dependencies: CliDependencies = defaultDependencies
|
|
143
213
|
): Promise<void> {
|
|
144
214
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
145
|
-
const
|
|
146
|
-
const
|
|
215
|
+
const result = await dependencies.scanProject({ rootDir });
|
|
216
|
+
const preset = resolvePreset(options.preset, dependencies);
|
|
217
|
+
if (options.preset && !preset) return;
|
|
218
|
+
const existingEnvExample = options.envExample && options.force
|
|
219
|
+
? await readOptionalText(dependencies.resolvePath(rootDir, ".env.example"), dependencies)
|
|
220
|
+
: undefined;
|
|
221
|
+
const files = buildInitFiles(rootDir, result, Boolean(options.envExample), dependencies.resolvePath, existingEnvExample, preset);
|
|
222
|
+
|
|
223
|
+
if (options.dryRun) {
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
dependencies.log(`Would write ${file.path}`);
|
|
226
|
+
dependencies.log(file.content.trimEnd());
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!options.force && !(await ensureInitTargetsDoNotExist(files, dependencies))) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const flag = options.force ? "w" : "wx";
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
try {
|
|
238
|
+
await dependencies.writeFile(file.path, file.content, { encoding: "utf8", flag });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (isNodeError(error) && error.code === "EEXIST") {
|
|
241
|
+
dependencies.error(`${file.path} already exists. Re-run with --force to overwrite.`);
|
|
242
|
+
dependencies.exit(1);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
dependencies.log(`Created ${file.path}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function buildInitFiles(
|
|
253
|
+
rootDir: string,
|
|
254
|
+
result: ScanResult,
|
|
255
|
+
includeEnvExample: boolean,
|
|
256
|
+
resolvePath: typeof resolve = resolve,
|
|
257
|
+
existingEnvExample?: string,
|
|
258
|
+
preset?: Partial<StarterConfig>
|
|
259
|
+
): InitFile[] {
|
|
260
|
+
const required = detectedRuntimeVariables(result);
|
|
261
|
+
const config = {
|
|
262
|
+
...starterConfig,
|
|
263
|
+
docs: mergeUnique(starterConfig.docs, preset?.docs ?? []),
|
|
264
|
+
ignore: mergeUnique(starterConfig.ignore, preset?.ignore ?? []),
|
|
265
|
+
optional: mergeUnique(starterConfig.optional, preset?.optional ?? []),
|
|
266
|
+
required
|
|
267
|
+
};
|
|
268
|
+
const files: InitFile[] = [
|
|
269
|
+
{
|
|
270
|
+
path: resolvePath(rootDir, "configenvy.config.json"),
|
|
271
|
+
content: `${JSON.stringify(config, null, 2)}\n`
|
|
272
|
+
}
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
if (includeEnvExample) {
|
|
276
|
+
files.push({
|
|
277
|
+
path: resolvePath(rootDir, ".env.example"),
|
|
278
|
+
content: buildEnvExampleDraft(result, existingEnvExample)
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return files;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function resolvePreset(name: string | undefined, dependencies: CliDependencies): Partial<StarterConfig> | undefined {
|
|
286
|
+
if (!name) return undefined;
|
|
287
|
+
if (name in presetConfigs) {
|
|
288
|
+
return presetConfigs[name as PresetName];
|
|
289
|
+
}
|
|
290
|
+
dependencies.error(`Unknown preset "${name}". Available presets: ${availablePresetList}.`);
|
|
291
|
+
dependencies.exit(1);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
147
294
|
|
|
295
|
+
function mergeUnique(base: string[], extra: readonly string[]): string[] {
|
|
296
|
+
return [...new Set([...base, ...extra])].sort();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function ensureInitTargetsDoNotExist(files: InitFile[], dependencies: CliDependencies): Promise<boolean> {
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
const existing = await readOptionalText(file.path, dependencies);
|
|
302
|
+
if (existing !== undefined) {
|
|
303
|
+
dependencies.error(`${file.path} already exists. Re-run with --force to overwrite.`);
|
|
304
|
+
dependencies.exit(1);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readOptionalText(path: string, dependencies: CliDependencies): Promise<string | undefined> {
|
|
148
312
|
try {
|
|
149
|
-
await dependencies.
|
|
313
|
+
return String(await dependencies.readFile(path, "utf8"));
|
|
150
314
|
} catch (error) {
|
|
151
|
-
if (isNodeError(error) && error.code === "
|
|
152
|
-
|
|
153
|
-
dependencies.exit(1);
|
|
154
|
-
return;
|
|
315
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
316
|
+
return undefined;
|
|
155
317
|
}
|
|
156
318
|
throw error;
|
|
157
319
|
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function detectedRuntimeVariables(result: ScanResult): string[] {
|
|
323
|
+
const runtimeKinds = new Set(["code", "ci", "config"]);
|
|
324
|
+
return [...new Set(result.references.filter((reference) => runtimeKinds.has(reference.kind)).map((reference) => reference.name))].sort();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildEnvExampleDraft(result: ScanResult, existingContent?: string): string {
|
|
328
|
+
const runtimeVars = detectedRuntimeVariables(result);
|
|
329
|
+
if (existingContent !== undefined) {
|
|
330
|
+
const existingVars = extractEnvExampleNames(existingContent);
|
|
331
|
+
const missingVars = runtimeVars.filter((variable) => !existingVars.has(variable));
|
|
332
|
+
if (missingVars.length === 0) {
|
|
333
|
+
return existingContent.endsWith("\n") ? existingContent : `${existingContent}\n`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return [
|
|
337
|
+
existingContent.trimEnd(),
|
|
338
|
+
"# Added by configenvy init",
|
|
339
|
+
...missingVars.map((variable) => `${variable}=`)
|
|
340
|
+
].filter(Boolean).join("\n") + "\n";
|
|
341
|
+
}
|
|
158
342
|
|
|
159
|
-
|
|
343
|
+
const lines = ["# Generated by configenvy init"];
|
|
344
|
+
|
|
345
|
+
for (const variable of runtimeVars) {
|
|
346
|
+
lines.push(`${variable}=`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return `${lines.join("\n")}\n`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function extractEnvExampleNames(content: string): Set<string> {
|
|
353
|
+
const names = new Set<string>();
|
|
354
|
+
for (const line of content.split(/\r?\n/)) {
|
|
355
|
+
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
356
|
+
if (match?.[1]) {
|
|
357
|
+
names.add(match[1]);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return names;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function runTableUpdate(
|
|
364
|
+
rootDir: string,
|
|
365
|
+
table: string,
|
|
366
|
+
options: { dryRun?: boolean; force?: boolean; update: string },
|
|
367
|
+
dependencies: CliDependencies = defaultDependencies
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const targetPath = resolveOutputPath(rootDir, options.update, dependencies.resolvePath);
|
|
370
|
+
const current = await dependencies.readFile(targetPath, "utf8");
|
|
371
|
+
const updated = updateMarkdownTableBlock(current, table, Boolean(options.force));
|
|
372
|
+
|
|
373
|
+
if (options.dryRun) {
|
|
374
|
+
dependencies.log(updated);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await dependencies.writeFile(targetPath, updated, "utf8");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function updateMarkdownTableBlock(markdown: string, table: string, force: boolean): string {
|
|
382
|
+
const block = `${tableBlockStart}\n${table}\n${tableBlockEnd}`;
|
|
383
|
+
const blockPattern = new RegExp(`${escapeRegExp(tableBlockStart)}[\\s\\S]*?${escapeRegExp(tableBlockEnd)}`);
|
|
384
|
+
|
|
385
|
+
if (blockPattern.test(markdown)) {
|
|
386
|
+
return markdown.replace(blockPattern, block);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (force) {
|
|
390
|
+
const separator = markdown.endsWith("\n") ? "\n" : "\n\n";
|
|
391
|
+
return `${markdown}${separator}${block}\n`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
throw new Error(`No configenvy table block found. Add ${tableBlockStart} and ${tableBlockEnd}, or rerun with --force.`);
|
|
160
395
|
}
|
|
161
396
|
|
|
162
397
|
export function resolveOutputPath(
|
|
@@ -214,6 +449,10 @@ function escapeAnnotationMessage(value: string): string {
|
|
|
214
449
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
215
450
|
}
|
|
216
451
|
|
|
452
|
+
function escapeRegExp(value: string): string {
|
|
453
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
454
|
+
}
|
|
455
|
+
|
|
217
456
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
218
457
|
return error instanceof Error && "code" in error;
|
|
219
458
|
}
|