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