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 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 { buildMarkdownTable, explainVariable, scanProject, toJson } 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,
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.2");
23
- program.command("doctor").argument("[path]", "project directory", ".").option("--format <format>", "output format: text or json", "text").option("--strict", "treat documentation warnings as errors").action(async (projectPath, options) => {
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 json", "text").action(async (projectPath, options) => {
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.out) {
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.2",
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.2",
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 { buildMarkdownTable, explainVariable, scanProject, toJson, 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
- 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.2");
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 json", "text")
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 json", "text")
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
- .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 }) => {
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.out) {
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) => {