@activate-core/test-forge 0.1.0

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/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # testforge
2
+
3
+ AI-powered QA automation. Point it at a project and it scans the code,
4
+ drafts a journey doc for each feature, and generates seeded Jest and
5
+ Playwright tests that match the project's own structure.
6
+
7
+ Works in any project, with any AI CLI (defaults to Claude).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -D testforge
13
+ ```
14
+
15
+ testforge calls a local AI CLI to do its work. By default it uses
16
+ `claude -p`, so make sure the Claude CLI is installed and logged in.
17
+ To use a different CLI, see [Configuration](#).
18
+
19
+ ## Quick start
20
+
21
+ From your project root:
22
+
23
+ ```bash
24
+ testforge setup # one-time: inspect project, map it, draft journey docs
25
+ testforge generate # generate tests + seed files from the docs
26
+ ```
27
+
28
+ After `setup`, review the drafted docs in `docs/` and fix anything the
29
+ AI got wrong — they are the source of truth for the tests. Then run
30
+ `generate`.
31
+
32
+ When your code changes later:
33
+
34
+ ```bash
35
+ testforge update # re-check and refresh only what changed
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
42
+ | `testforge setup` | One-time setup: runs init, map, and discover in order, with progress. |
43
+ | `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. |
44
+ | `testforge update` | Detects source-code changes and refreshes the map, docs, and tests when needed. Skips everything if nothing changed (no AI calls). |
45
+
46
+ ### Advanced (run a single step)
47
+
48
+ | Command | What it does |
49
+ | -------------------- | ----------------------------------------------------------- |
50
+ | `testforge init` | Inspect the project and write `testforge.config.json` only. |
51
+ | `testforge map` | Rebuild the project map (`testforge.md`) only. |
52
+ | `testforge discover` | Redraft the per-journey docs in `docs/` only. |
53
+ | `testforge env` | Rebuild `.env.testing.example` from the docs only. |
54
+
55
+ ## How it works
56
+
57
+ **setup** prepares the project in three stages:
58
+
59
+ 1. **init** — detects the layout (monorepo or single app, where tests
60
+ live, JS or TS) and writes `testforge.config.json`.
61
+ 2. **map** — writes `testforge.md`: a short list of journeys and a
62
+ summary of the data layer (database, models, key fields). The AI
63
+ reads this instead of re-scanning all the code each time.
64
+ 3. **discover** — writes one rich doc per journey into `docs/` — fields,
65
+ actions, expected results, edge cases, security notes, and the data a
66
+ test needs.
67
+
68
+ **generate** turns each doc into a test. A journey with `Type: api`
69
+ becomes a Jest test (HTTP + database checks); `Type: web` becomes a
70
+ Playwright test. Other areas use the framework set in config. It also
71
+ generates a seed file that provisions the test data each test needs.
72
+
73
+ **update** hashes your source code and compares it to the last run. No
74
+ change means no work and no AI calls; a change triggers a refresh.
75
+
76
+ ## Running the generated tests
77
+
78
+ testforge generates the tests and seed files — you run them with your
79
+ own test runner, the way your project already does. testforge does not
80
+ run tests for you; that keeps it out of your test setup and CI.
81
+
82
+ A typical run:
83
+
84
+ 1. **Provide test credentials.** The tests and seeds read the database
85
+ URL and any secrets from environment variables — never hardcoded.
86
+
87
+ `testforge setup` writes a `.env.testing.example` for you. It lists
88
+ every env var the journeys need, grouped by area (api / web / ops),
89
+ and annotates each one with the journeys that use it — so you know
90
+ exactly which credentials a feature needs, and can skip the ones for
91
+ features you don't run. For example:
92
+
93
+ ```bash
94
+ # ========================================
95
+ # API
96
+ # ========================================
97
+ DB_URL= # used by: (all)
98
+ TOKEN_SECRET= # used by: auth, user
99
+ STRIPE_SECRET_KEY= # used by: payments, subscription
100
+
101
+ # ========================================
102
+ # WEB
103
+ # ========================================
104
+ StripePublicKey= # used by: payments
105
+ ```
106
+
107
+ Copy it to `.env.testing`, fill in real values, and keep that file
108
+ out of version control (add it to `.gitignore`). Re-running `setup`
109
+ or `update` refreshes the example as your code changes.
110
+
111
+ Note: some credentials aren't static — e.g. a user access/refresh
112
+ token from a login flow. Those belong in your own test setup (a
113
+ global setup or seed step), not in the env file.
114
+
115
+ Keep this file out of version control (add it to `.gitignore`). The
116
+ exact variable names come from your own code — the journey docs and
117
+ the project map list the env var names each journey needs.
118
+
119
+ 2. **Run the seeds** to put known test data in the database. Run the
120
+ seed files in `seedOutput` (see config) with your runtime, e.g.:
121
+
122
+ ```bash
123
+ npx tsx path/to/seed/<journey>.seed.ts # or: node <journey>.seed.js
124
+ ```
125
+
126
+ 3. **Run the tests** with the project's own command, e.g.:
127
+
128
+ ```bash
129
+ # api (Jest)
130
+ NODE_ENV=testing npx jest
131
+
132
+ # web (Playwright)
133
+ npx playwright test
134
+ ```
135
+
136
+ The test runner produces its own pass/fail report.
137
+
138
+ ## Configuration
139
+
140
+ `testforge setup` writes `testforge.config.json` for you. You can edit it:
141
+
142
+ ```json
143
+ {
144
+ "aiCommand": "claude -p",
145
+ "testOutput": {
146
+ "api": "packages/api/test",
147
+ "web": "packages/webapp/tests",
148
+ "ops": "packages/ops/tests"
149
+ },
150
+ "framework": {
151
+ "api": "jest",
152
+ "web": "playwright",
153
+ "ops": "playwright"
154
+ },
155
+ "seedOutput": "packages/api/test/helpers/seed",
156
+ "testExtension": ".test.ts"
157
+ }
158
+ ```
159
+
160
+ - **aiCommand** — the CLI used to call the AI. Any tool that reads a
161
+ prompt from stdin and prints the reply works, so testforge is not tied
162
+ to one provider.
163
+ - **testOutput** — where tests go, per area. Areas are not fixed — use
164
+ whatever your project has (`api`, `web`, `ops`, `lambda`, ...).
165
+ - **framework** — which test framework each area uses.
166
+ - **seedOutput** — where seed files go.
167
+ - **testExtension** — `.test.ts` or `.test.js`.
168
+
169
+ ## Notes
170
+
171
+ - Generated tests and docs are a strong draft, not final. Review them —
172
+ the AI leaves `TODO` comments where it could not be certain.
173
+ - Secrets are never written to docs, configs, or tests. Connections use
174
+ environment variable names only; real values come from your
175
+ environment at run time.
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * testforge — command-line entry point.
4
+ *
5
+ * Commands:
6
+ * testforge generate Generate tests for changed journeys (AI).
7
+ * testforge generate --basic Use the built-in basic generator only.
8
+ * testforge generate --force Regenerate everything, ignoring the cache.
9
+ */
10
+ import { VERSION } from '../core/index.js';
11
+ import { runGenerate } from '../core/run-generate.js';
12
+ import { runDiscover } from '../core/discover.js';
13
+ import { runMap } from '../core/map.js';
14
+ import { runInit } from '../core/init.js';
15
+ import { runSetup } from '../core/setup.js';
16
+ import { runUpdate } from '../core/update.js';
17
+ import { runEnvTemplate } from '../core/env-template.js';
18
+ const command = process.argv[2];
19
+ const useBasic = process.argv.includes('--basic');
20
+ const force = process.argv.includes('--force');
21
+ if (command === 'update') {
22
+ runUpdate('.');
23
+ }
24
+ else if (command === 'setup') {
25
+ runSetup('.');
26
+ }
27
+ else if (command === 'init') {
28
+ console.log('Inspecting your project to set up testforge...\n');
29
+ const output = runInit();
30
+ console.log(output);
31
+ console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
32
+ }
33
+ else if (command === 'map') {
34
+ console.log('Building a high-level map of your project with AI...');
35
+ console.log('This scans your source code and writes testforge.md\n');
36
+ const output = runMap('.');
37
+ console.log(output);
38
+ console.log('\nProject map written to testforge.md');
39
+ }
40
+ else if (command === 'discover') {
41
+ console.log('Scanning your project with AI to draft journey files...');
42
+ console.log('This reads your source code and writes drafts into docs/.\n');
43
+ const output = runDiscover('.', 'docs');
44
+ console.log(output);
45
+ console.log('\nDraft journeys written to docs/. Please review and edit them,');
46
+ console.log('then run `testforge generate`.');
47
+ }
48
+ else if (command === 'env') {
49
+ runEnvTemplate('docs', '.env.testing.example');
50
+ }
51
+ else if (command === 'generate') {
52
+ const useAI = !useBasic;
53
+ console.log(useAI
54
+ ? 'Generating tests with AI for changed journeys...\n'
55
+ : 'Generating tests with the basic generator...\n');
56
+ const results = runGenerate('docs', useAI, force);
57
+ if (results.length === 0) {
58
+ console.log('No journey files found in docs/. Add a .md file and try again.');
59
+ }
60
+ else {
61
+ for (const r of results) {
62
+ if (r.status === 'skipped') {
63
+ console.log(` [skip] ${r.journeyName} (unchanged)`);
64
+ }
65
+ else {
66
+ const tag = r.status === 'ai' ? '[AI]' : '[basic]';
67
+ console.log(` ${tag} ${r.journeyName}`);
68
+ console.log(` test: ${r.testPath}`);
69
+ if (r.status === 'ai')
70
+ console.log(` seed: ${r.seedPath}`);
71
+ }
72
+ }
73
+ const generated = results.filter((r) => r.status !== 'skipped').length;
74
+ const skipped = results.filter((r) => r.status === 'skipped').length;
75
+ console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
76
+ }
77
+ }
78
+ else {
79
+ console.log(`testforge v${VERSION}`);
80
+ console.log('');
81
+ console.log('Usage:');
82
+ console.log(' testforge generate Generate tests for changed journeys');
83
+ console.log(' testforge generate --basic Use the basic generator only');
84
+ console.log(' testforge generate --force Regenerate everything');
85
+ console.log(' testforge discover Scan source code and draft journey files');
86
+ console.log(' testforge map Scan project, write journey + data map');
87
+ console.log(' testforge init Inspect project and write config');
88
+ console.log(' testforge setup Run init + map + discover in order');
89
+ console.log(' testforge update Re-check and update only what changed');
90
+ console.log(' testforge env Write .env.testing.example from docs');
91
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * testforge — AI-powered test generator.
3
+ *
4
+ * Instead of using a fixed list of hardcoded scenarios, this asks
5
+ * the locally installed Claude Code CLI to act as a senior QA
6
+ * engineer: it reads the journey and decides what to test, then
7
+ * writes a complete Playwright test file.
8
+ *
9
+ * Requirements: the `claude` CLI must be installed and logged in.
10
+ * We call it in print mode (`claude -p`) and feed the prompt via
11
+ * stdin so long prompts and special characters are handled safely.
12
+ */
13
+ import { execFileSync } from 'node:child_process';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { loadConfig, splitCommand } from './config.js';
16
+ const MAP_PATH = 'testforge.md';
17
+ /** Build the prompt we send to the AI for one journey. */
18
+ export function buildPrompt(journey, map) {
19
+ const config = loadConfig();
20
+ const fw = config.framework?.[journey.type];
21
+ const isApi = journey.type === 'api' || fw === 'jest' || fw === 'supertest';
22
+ const framework = fw
23
+ ? `${fw} for a "${journey.type}" test`
24
+ : isApi
25
+ ? 'Jest (with supertest for HTTP) for an API/backend test'
26
+ : 'an appropriate test framework for this kind of journey';
27
+ const ext = config.testExtension ?? '.test.ts';
28
+ const isJs = ext.endsWith('.js');
29
+ const language = isJs
30
+ ? 'plain JavaScript (CommonJS: use require/module.exports, NOT import; NO TypeScript type annotations)'
31
+ : 'TypeScript';
32
+ return [
33
+ `You are a senior QA engineer. Generate a complete ${framework}`,
34
+ `for the user journey described below.`,
35
+ ``,
36
+ `Think like an experienced tester whose goal is to break the`,
37
+ `application. Cover, where relevant: happy path, missing/empty`,
38
+ `required fields, invalid formats and boundary values (very long,`,
39
+ `very short, special characters, unicode), and — if security`,
40
+ `coverage is requested — security attacks (XSS, SQL/NoSQL injection,`,
41
+ `auth bypass, idempotency/duplicate charge, and others you judge`,
42
+ `useful). For payment/webhook journeys, test idempotency, amount/`,
43
+ `price tampering, and signature/verification where relevant.`,
44
+ ``,
45
+ isApi
46
+ ? [
47
+ `Because this is an API journey, write Jest tests that call the`,
48
+ `endpoint over HTTP, assert status codes, response body and`,
49
+ `schema, auth/authorization, and invalid payloads.`,
50
+ `If the journey's Data section names models/collections, VERIFY`,
51
+ `the database state after calls (e.g. a record was created, no`,
52
+ `duplicate exists, correct fields) using the database/ORM the`,
53
+ `project uses. Connect via the env var named in the map (e.g.`,
54
+ `process.env.DB_URL) — never hardcode a connection string.`,
55
+ ].join('\n')
56
+ : [
57
+ `Because this is a web journey, write Playwright tests that drive`,
58
+ `the UI. Use placeholder selectors with a TODO comment where the`,
59
+ `real page structure is unknown — do not invent selectors as if`,
60
+ `they are real.`,
61
+ ].join('\n'),
62
+ ``,
63
+ `This test relies on seeded test data (a separate seed file provisions`,
64
+ `the database before tests run). Assume the seed has created the`,
65
+ `records described in the journey's Data section, using stable test`,
66
+ `ids prefixed with "qa-"/"QA-TEST". Reference those known test ids`,
67
+ `rather than creating fresh data inside the test where possible. After`,
68
+ `a test mutates data, clean up what it created so reruns stay valid.`,
69
+ ``,
70
+ `Rules for your output:`,
71
+ `- Write the test in ${language}.`,
72
+ `- Output ONLY the raw test code. Your entire response must be valid`,
73
+ ` ${isJs ? 'JavaScript' : 'TypeScript'} that can be saved directly to a file and run.`,
74
+ `- The VERY FIRST characters of your response must be the first line of`,
75
+ ` code. Do NOT write any sentence, preamble, acknowledgement, or`,
76
+ ` explanation before or after the code.`,
77
+ `- Do NOT ask for approval or describe what you are doing.`,
78
+ `- Do NOT wrap the code in markdown fences.`,
79
+ `- Tag the top with a comment: // @generated by testforge`,
80
+ `- Where you must assume something uncertain, add a "// TODO:" note`,
81
+ ` rather than inventing it confidently.`,
82
+ ``,
83
+ `--- PROJECT MAP (data layer) ---`,
84
+ map,
85
+ ``,
86
+ `--- JOURNEY ---`,
87
+ `It may include sections beyond fields — actions, expected results,`,
88
+ `negative cases, race conditions, webhooks, security notes, and a`,
89
+ `Data section. Use ALL of it. Respect any "# TODO:" notes.`,
90
+ ``,
91
+ journey.rawMarkdown,
92
+ ].join('\n');
93
+ }
94
+ /**
95
+ * Call the configured AI command to generate a test file for one
96
+ * journey. Reads the project map (testforge.md) for data-layer
97
+ * context. Returns the test file content.
98
+ */
99
+ export function generateWithAI(journey) {
100
+ const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
101
+ const prompt = buildPrompt(journey, map);
102
+ const { program, args } = splitCommand(loadConfig().aiCommand);
103
+ const output = execFileSync(program, args, {
104
+ input: prompt,
105
+ encoding: 'utf-8',
106
+ maxBuffer: 10 * 1024 * 1024,
107
+ });
108
+ return cleanOutput(output);
109
+ }
110
+ /**
111
+ * Extract clean code from the AI's reply.
112
+ *
113
+ * 1. If there's a fenced code block, take its contents.
114
+ * 2. Otherwise, drop any leading prose by starting from the first line
115
+ * that looks like code (a comment, import, require, or known
116
+ * statement keyword). This rescues cases where the AI adds a
117
+ * sentence before the code despite instructions.
118
+ */
119
+ function cleanOutput(text) {
120
+ // 1. Prefer a fenced code block if present.
121
+ const fence = text.match(/```[a-zA-Z]*\n([\s\S]*?)\n```/);
122
+ if (fence) {
123
+ return fence[1].trim();
124
+ }
125
+ // 2. No fence — find the first line that looks like real code.
126
+ const lines = text.split('\n');
127
+ const codeStart = lines.findIndex((line) => {
128
+ const t = line.trim();
129
+ return (t.startsWith('//') ||
130
+ t.startsWith('/*') ||
131
+ t.startsWith('import ') ||
132
+ t.startsWith('const ') ||
133
+ t.startsWith('require(') ||
134
+ t.startsWith("'use strict'") ||
135
+ t.startsWith('"use strict"'));
136
+ });
137
+ if (codeStart > 0) {
138
+ return lines.slice(codeStart).join('\n').trim();
139
+ }
140
+ return text.trim();
141
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * testforge — change detection cache.
3
+ *
4
+ * Remembers a fingerprint (hash) of each journey file from the last
5
+ * run. On the next run we compare hashes: if a file's hash is the
6
+ * same, it has not changed and we can skip regenerating it. If it is
7
+ * different (edited) or missing (new file), we regenerate.
8
+ *
9
+ * The cache is stored as a small JSON file in the project so it
10
+ * survives between runs.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, } from 'node:fs';
14
+ import { dirname, join, extname } from 'node:path';
15
+ /** Make a short fingerprint of a file's content. */
16
+ export function hashContent(content) {
17
+ return createHash('sha256').update(content).digest('hex');
18
+ }
19
+ /** Load the saved cache, or an empty one if it does not exist yet. */
20
+ export function loadCache(cachePath) {
21
+ if (!existsSync(cachePath)) {
22
+ return {};
23
+ }
24
+ try {
25
+ return JSON.parse(readFileSync(cachePath, 'utf-8'));
26
+ }
27
+ catch {
28
+ // Corrupted cache — treat as empty so we just regenerate everything.
29
+ return {};
30
+ }
31
+ }
32
+ /** Save the cache back to disk. */
33
+ export function saveCache(cachePath, cache) {
34
+ mkdirSync(dirname(cachePath), { recursive: true });
35
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
36
+ }
37
+ /**
38
+ * Build a single hash representing the current state of a project's
39
+ * source code. Reads source files (by extension), concatenates their
40
+ * paths + contents, and hashes the result. If any file changes, is
41
+ * added, or removed, the hash changes — deterministically, with no AI
42
+ * involved.
43
+ *
44
+ * Skips non-source folders (node_modules, .git, dist, etc.) and
45
+ * testforge's own output, so unrelated churn is ignored.
46
+ */
47
+ const SOURCE_EXTENSIONS = [
48
+ '.js',
49
+ '.jsx',
50
+ '.ts',
51
+ '.tsx',
52
+ '.py',
53
+ '.go',
54
+ '.rb',
55
+ '.php',
56
+ '.java',
57
+ ];
58
+ const SKIP_DIRS = new Set([
59
+ 'node_modules',
60
+ '.git',
61
+ 'dist',
62
+ 'build',
63
+ 'coverage',
64
+ '.next',
65
+ 'docs',
66
+ '.testforge',
67
+ 'generated-tests',
68
+ ]);
69
+ export function hashProjectSource(rootDir) {
70
+ const parts = [];
71
+ function walk(dir) {
72
+ let entries;
73
+ try {
74
+ entries = readdirSync(dir);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ for (const entry of entries.sort()) {
80
+ const full = join(dir, entry);
81
+ let stat;
82
+ try {
83
+ stat = statSync(full);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (stat.isDirectory()) {
89
+ if (SKIP_DIRS.has(entry) || entry.startsWith('.'))
90
+ continue;
91
+ walk(full);
92
+ }
93
+ else if (SOURCE_EXTENSIONS.includes(extname(entry))) {
94
+ try {
95
+ parts.push(full + '\n' + readFileSync(full, 'utf-8'));
96
+ }
97
+ catch {
98
+ // unreadable file — skip
99
+ }
100
+ }
101
+ }
102
+ }
103
+ walk(rootDir);
104
+ return hashContent(parts.join('\n---\n'));
105
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * testforge — configuration.
3
+ *
4
+ * testforge does not depend on any one AI provider. Instead, the user
5
+ * tells it which command to run to talk to their AI. That command must
6
+ * read a prompt from stdin and print the AI's reply to stdout.
7
+ *
8
+ * Configuration lives in testforge.config.json at the project root,
9
+ * for example:
10
+ *
11
+ * {
12
+ * "aiCommand": "claude -p"
13
+ * }
14
+ *
15
+ * If no config file exists, we default to "claude -p" so users with
16
+ * Claude installed work out of the box. Users of other tools (Gemini,
17
+ * Codex, etc.) set aiCommand to their own CLI.
18
+ */
19
+ import { readFileSync, existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ const CONFIG_PATH = 'testforge.config.json';
22
+ const DEFAULT_CONFIG = {
23
+ aiCommand: 'claude -p',
24
+ testOutput: {},
25
+ framework: { api: 'jest', web: 'playwright' },
26
+ seedOutput: 'generated-tests/seed',
27
+ testExtension: '.test.ts',
28
+ testCommand: {},
29
+ envFile: '.env.testing',
30
+ };
31
+ /** Load config from the project, falling back to defaults. */
32
+ export function loadConfig() {
33
+ if (!existsSync(CONFIG_PATH)) {
34
+ return DEFAULT_CONFIG;
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
38
+ return { ...DEFAULT_CONFIG, ...parsed };
39
+ }
40
+ catch {
41
+ // Bad config file — fall back to defaults rather than crashing.
42
+ return DEFAULT_CONFIG;
43
+ }
44
+ }
45
+ /**
46
+ * Split an AI command string into a program and its arguments.
47
+ * Example: "claude -p" -> { program: "claude", args: ["-p"] }
48
+ */
49
+ export function splitCommand(command) {
50
+ const parts = command.trim().split(/\s+/);
51
+ const program = parts[0];
52
+ const args = parts.slice(1);
53
+ return { program, args };
54
+ }
55
+ /**
56
+ * Decide where a journey's test file should go, based on its type
57
+ * and the config. Falls back to "generated-tests/<area>" when the
58
+ * type is not configured.
59
+ *
60
+ * "area" is the journey type today (web / api). This keeps web and
61
+ * api tests in separate, configurable locations to match a project's
62
+ * existing layout.
63
+ */
64
+ export function testDirForType(config, type) {
65
+ const configured = config.testOutput?.[type];
66
+ if (configured) {
67
+ return configured;
68
+ }
69
+ return join('generated-tests', type);
70
+ }
71
+ /**
72
+ * Parse a simple .env file into a key/value object. Ignores blank
73
+ * lines and comments. Does not expand or validate — just reads what is
74
+ * there so we can pass it to a child process. Returns {} if the file
75
+ * is missing.
76
+ */
77
+ export function loadEnvFile(path) {
78
+ if (!existsSync(path)) {
79
+ return {};
80
+ }
81
+ const out = {};
82
+ const text = readFileSync(path, 'utf-8');
83
+ for (const line of text.split('\n')) {
84
+ const trimmed = line.trim();
85
+ if (!trimmed || trimmed.startsWith('#'))
86
+ continue;
87
+ const eq = trimmed.indexOf('=');
88
+ if (eq === -1)
89
+ continue;
90
+ const key = trimmed.slice(0, eq).trim();
91
+ let value = trimmed.slice(eq + 1).trim();
92
+ // strip surrounding quotes if present
93
+ if ((value.startsWith('"') && value.endsWith('"')) ||
94
+ (value.startsWith("'") && value.endsWith("'"))) {
95
+ value = value.slice(1, -1);
96
+ }
97
+ out[key] = value;
98
+ }
99
+ return out;
100
+ }