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