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 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 { buildMarkdownTable, explainVariable, scanProject, toJson, toSarif } from "@configenvy/core";
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("0.1.4");
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.out) {
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 a starter configenvy.config.json file").action(async (projectPath) => {
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 configPath = dependencies.resolvePath(rootDir, "configenvy.config.json");
83
- const content = `${JSON.stringify(starterConfig, null, 2)}
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.writeFile(configPath, content, { encoding: "utf8", flag: "wx" });
195
+ return String(await dependencies.readFile(path, "utf8"));
87
196
  } catch (error) {
88
- if (isNodeError(error) && error.code === "EEXIST") {
89
- dependencies.error("configenvy.config.json already exists.");
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
- dependencies.log(`Created ${configPath}`);
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.4",
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.4",
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 { buildMarkdownTable, explainVariable, scanProject, toJson, toSarif, type Diagnostic } from "@configenvy/core";
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("0.1.4");
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
- .action(async (projectPath: string, options: { out?: string }) => {
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.out) {
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 a starter configenvy.config.json file")
85
- .action(async (projectPath: string) => {
86
- await runInit(projectPath, dependencies);
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
- const starterConfig = {
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 configPath = dependencies.resolvePath(rootDir, "configenvy.config.json");
146
- const content = `${JSON.stringify(starterConfig, null, 2)}\n`;
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.writeFile(configPath, content, { encoding: "utf8", flag: "wx" });
313
+ return String(await dependencies.readFile(path, "utf8"));
150
314
  } catch (error) {
151
- if (isNodeError(error) && error.code === "EEXIST") {
152
- dependencies.error("configenvy.config.json already exists.");
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
- dependencies.log(`Created ${configPath}`);
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
  }