configenvy 0.1.4 → 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 +23 -4
- package/dist/index.js +152 -17
- package/package.json +2 -2
- package/src/index.ts +197 -15
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
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
|
+
};
|
|
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;
|
|
@@ -22,11 +28,24 @@ type CliDependencies = {
|
|
|
22
28
|
writeFile: typeof writeFile;
|
|
23
29
|
};
|
|
24
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 -->";
|
|
25
37
|
declare function runCli(argv: string[], dependencies?: CliDependencies): Promise<void>;
|
|
26
38
|
declare function runDoctor(projectPath: string, options: DoctorOptions, dependencies?: CliDependencies): Promise<void>;
|
|
27
|
-
declare function runInit(projectPath: string, 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;
|
|
28
47
|
declare function resolveOutputPath(projectPath: string, outputPath: string, resolvePath?: typeof resolve): string;
|
|
29
48
|
declare function printHumanReport(diagnostics: Diagnostic[], log?: (...values: unknown[]) => void): void;
|
|
30
49
|
declare function printGitHubAnnotations(diagnostics: Diagnostic[], log?: (...values: unknown[]) => void): void;
|
|
31
50
|
|
|
32
|
-
export { type CliDependencies, createProgram, printGitHubAnnotations, printHumanReport, resolveOutputPath, runCli, runDoctor, runInit };
|
|
51
|
+
export { type CliDependencies, buildInitFiles, createProgram, printGitHubAnnotations, printHumanReport, resolveOutputPath, runCli, runDoctor, runInit, runTableUpdate, tableBlockEnd, tableBlockStart, updateMarkdownTableBlock };
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
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,
|
|
@@ -27,19 +34,21 @@ function createProgram(dependencies = defaultDependencies) {
|
|
|
27
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) => {
|
|
28
35
|
await runDoctor(projectPath, { ...options, strict: Boolean(options.ci), ci: Boolean(options.ci) }, dependencies);
|
|
29
36
|
});
|
|
30
|
-
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) => {
|
|
31
38
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
32
39
|
const result = await dependencies.scanProject({ rootDir });
|
|
33
40
|
const table = dependencies.buildMarkdownTable(result);
|
|
34
|
-
if (options.
|
|
41
|
+
if (options.update) {
|
|
42
|
+
await runTableUpdate(rootDir, table, { ...options, update: options.update }, dependencies);
|
|
43
|
+
} else if (options.out) {
|
|
35
44
|
await dependencies.writeFile(resolveOutputPath(rootDir, options.out, dependencies.resolvePath), `${table}
|
|
36
45
|
`, "utf8");
|
|
37
46
|
} else {
|
|
38
47
|
dependencies.log(table);
|
|
39
48
|
}
|
|
40
49
|
});
|
|
41
|
-
program.command("init").argument("[path]", "project directory", ".").description("create
|
|
42
|
-
await runInit(projectPath, dependencies);
|
|
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);
|
|
43
52
|
});
|
|
44
53
|
program.command("explain").argument("<variable>", "environment variable name").argument("[path]", "project directory", ".").action(async (variable, projectPath) => {
|
|
45
54
|
const result = await dependencies.scanProject({ rootDir: dependencies.resolvePath(projectPath) });
|
|
@@ -53,6 +62,8 @@ var starterConfig = {
|
|
|
53
62
|
ignore: ["NODE_ENV"],
|
|
54
63
|
docs: ["README.md", "docs"]
|
|
55
64
|
};
|
|
65
|
+
var tableBlockStart = "<!-- configenvy:start -->";
|
|
66
|
+
var tableBlockEnd = "<!-- configenvy:end -->";
|
|
56
67
|
async function runCli(argv, dependencies = defaultDependencies) {
|
|
57
68
|
const program = createProgram(dependencies);
|
|
58
69
|
await program.parseAsync(argv);
|
|
@@ -77,22 +88,138 @@ async function runDoctor(projectPath, options, dependencies = defaultDependencie
|
|
|
77
88
|
if (hasError || options.ci && hasWarning) dependencies.exit(2);
|
|
78
89
|
if (hasWarning) dependencies.exit(1);
|
|
79
90
|
}
|
|
80
|
-
async function runInit(projectPath, dependencies = defaultDependencies) {
|
|
91
|
+
async function runInit(projectPath, options = {}, dependencies = defaultDependencies) {
|
|
81
92
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
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) {
|
|
85
154
|
try {
|
|
86
|
-
await dependencies.
|
|
155
|
+
return String(await dependencies.readFile(path, "utf8"));
|
|
87
156
|
} catch (error) {
|
|
88
|
-
if (isNodeError(error) && error.code === "
|
|
89
|
-
|
|
90
|
-
dependencies.exit(1);
|
|
91
|
-
return;
|
|
157
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
158
|
+
return void 0;
|
|
92
159
|
}
|
|
93
160
|
throw error;
|
|
94
161
|
}
|
|
95
|
-
|
|
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.`);
|
|
96
223
|
}
|
|
97
224
|
function resolveOutputPath(projectPath, outputPath, resolvePath = resolve) {
|
|
98
225
|
if (isAbsolute(outputPath)) return outputPath;
|
|
@@ -131,6 +258,9 @@ function escapeAnnotationProperty(value) {
|
|
|
131
258
|
function escapeAnnotationMessage(value) {
|
|
132
259
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
133
260
|
}
|
|
261
|
+
function escapeRegExp(value) {
|
|
262
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
263
|
+
}
|
|
134
264
|
function isNodeError(error) {
|
|
135
265
|
return error instanceof Error && "code" in error;
|
|
136
266
|
}
|
|
@@ -142,11 +272,16 @@ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
|
|
|
142
272
|
});
|
|
143
273
|
}
|
|
144
274
|
export {
|
|
275
|
+
buildInitFiles,
|
|
145
276
|
createProgram,
|
|
146
277
|
printGitHubAnnotations,
|
|
147
278
|
printHumanReport,
|
|
148
279
|
resolveOutputPath,
|
|
149
280
|
runCli,
|
|
150
281
|
runDoctor,
|
|
151
|
-
runInit
|
|
282
|
+
runInit,
|
|
283
|
+
runTableUpdate,
|
|
284
|
+
tableBlockEnd,
|
|
285
|
+
tableBlockStart,
|
|
286
|
+
updateMarkdownTableBlock
|
|
152
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,9 +1,17 @@
|
|
|
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
17
|
format?: "text" | "json" | "sarif";
|
|
@@ -11,12 +19,19 @@ type DoctorOptions = {
|
|
|
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;
|
|
@@ -30,6 +45,7 @@ const defaultDependencies: CliDependencies = {
|
|
|
30
45
|
exit: process.exit,
|
|
31
46
|
explainVariable,
|
|
32
47
|
log: console.log,
|
|
48
|
+
readFile,
|
|
33
49
|
resolvePath: resolve,
|
|
34
50
|
scanProject,
|
|
35
51
|
toJson,
|
|
@@ -67,11 +83,16 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
67
83
|
.command("table")
|
|
68
84
|
.argument("[path]", "project directory", ".")
|
|
69
85
|
.option("--out <file>", "write markdown table to a file")
|
|
70
|
-
.
|
|
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 }) => {
|
|
71
90
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
72
91
|
const result = await dependencies.scanProject({ rootDir });
|
|
73
92
|
const table = dependencies.buildMarkdownTable(result);
|
|
74
|
-
if (options.
|
|
93
|
+
if (options.update) {
|
|
94
|
+
await runTableUpdate(rootDir, table, { ...options, update: options.update }, dependencies);
|
|
95
|
+
} else if (options.out) {
|
|
75
96
|
await dependencies.writeFile(resolveOutputPath(rootDir, options.out, dependencies.resolvePath), `${table}\n`, "utf8");
|
|
76
97
|
} else {
|
|
77
98
|
dependencies.log(table);
|
|
@@ -81,9 +102,12 @@ export function createProgram(dependencies: CliDependencies = defaultDependencie
|
|
|
81
102
|
program
|
|
82
103
|
.command("init")
|
|
83
104
|
.argument("[path]", "project directory", ".")
|
|
84
|
-
.description("create
|
|
85
|
-
.
|
|
86
|
-
|
|
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);
|
|
87
111
|
});
|
|
88
112
|
|
|
89
113
|
program
|
|
@@ -105,6 +129,14 @@ const starterConfig = {
|
|
|
105
129
|
docs: ["README.md", "docs"]
|
|
106
130
|
};
|
|
107
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
|
+
|
|
108
140
|
export async function runCli(argv: string[], dependencies: CliDependencies = defaultDependencies): Promise<void> {
|
|
109
141
|
const program = createProgram(dependencies);
|
|
110
142
|
await program.parseAsync(argv);
|
|
@@ -139,24 +171,170 @@ export async function runDoctor(
|
|
|
139
171
|
|
|
140
172
|
export async function runInit(
|
|
141
173
|
projectPath: string,
|
|
174
|
+
options: InitOptions = {},
|
|
142
175
|
dependencies: CliDependencies = defaultDependencies
|
|
143
176
|
): Promise<void> {
|
|
144
177
|
const rootDir = dependencies.resolvePath(projectPath);
|
|
145
|
-
const
|
|
146
|
-
const
|
|
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
|
+
}
|
|
147
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> {
|
|
148
255
|
try {
|
|
149
|
-
await dependencies.
|
|
256
|
+
return String(await dependencies.readFile(path, "utf8"));
|
|
150
257
|
} catch (error) {
|
|
151
|
-
if (isNodeError(error) && error.code === "
|
|
152
|
-
|
|
153
|
-
dependencies.exit(1);
|
|
154
|
-
return;
|
|
258
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
259
|
+
return undefined;
|
|
155
260
|
}
|
|
156
261
|
throw error;
|
|
157
262
|
}
|
|
263
|
+
}
|
|
158
264
|
|
|
159
|
-
|
|
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.`);
|
|
160
338
|
}
|
|
161
339
|
|
|
162
340
|
export function resolveOutputPath(
|
|
@@ -214,6 +392,10 @@ function escapeAnnotationMessage(value: string): string {
|
|
|
214
392
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
215
393
|
}
|
|
216
394
|
|
|
395
|
+
function escapeRegExp(value: string): string {
|
|
396
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
397
|
+
}
|
|
398
|
+
|
|
217
399
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
218
400
|
return error instanceof Error && "code" in error;
|
|
219
401
|
}
|