Skip to content

Commit 65593e9

Browse files
Merge v1.3.0 polish: validate command, snapshot safety, dry-run/validate-only, bug fixes
2 parents 71fdb3b + 976c79c commit 65593e9

26 files changed

Lines changed: 779 additions & 71 deletions

.dev-env.example.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ databases:
2626
command: npm run migrate
2727
seed:
2828
command: npm run seed
29+
path: . # optional: directory to run the seed command in (default ".")
2930

3031
# - type: redis
3132
# version: "7"
@@ -53,6 +54,13 @@ health_checks:
5354
# - name: redis
5455
# type: redis
5556
# url: redis://localhost:6379
57+
# Generic (non-database) services can define a raw healthcheck command with `test`:
58+
# - name: rabbitmq_1 # matches the generated service name, or use type
59+
# type: rabbitmq
60+
# test: rabbitmq-diagnostics -q ping
61+
# interval: 30s
62+
# timeout: 10s
63+
# retries: 5
5664

5765
# Snapshot configuration
5866
snapshot:

.github/workflows/ci.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,12 @@ jobs:
3131
run: npm run build
3232

3333
- name: Test
34-
run: npm test
34+
run: npm test -- --coverage
35+
36+
- name: Upload coverage report
37+
if: always()
38+
uses: actions/upload-artifact@v4
39+
with:
40+
name: coverage-node-${{ matrix.node-version }}
41+
path: coverage/
42+
if-no-files-found: ignore

README.md

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,35 @@ This installs dependencies, runs database migrations, and runs seed commands acc
7373
| `--skip-db` | Skip database migrations and seed |
7474
| `--dry-run` | Show what would run, without running |
7575

76-
### 3. Generate Docker Compose
76+
### 3. Validate your config
77+
78+
Check that `.dev-env.yml` (and any `.env`) is well-formed without running setup or generate — handy for CI and pre-commit hooks:
79+
80+
```bash
81+
envkit validate
82+
```
83+
84+
It exits non-zero on a missing or invalid config. Add `--strict` to also fail on warnings (e.g. hardcoded passwords, port conflicts) and unresolved `${VAR}` references:
85+
86+
```bash
87+
envkit validate --strict
88+
```
89+
90+
### 4. Generate Docker Compose
7791

7892
When `docker.enabled` is set in your config, you can generate a Compose file from it:
7993

8094
```bash
8195
envkit generate
8296
```
8397

84-
Output goes to `docker-compose.yml` by default. Use `-o <file>` to write elsewhere, e.g. `envkit generate -o docker-compose.dev.yml`.
98+
Output goes to `docker-compose.yml` by default. Use `-o <file>` to write elsewhere, e.g. `envkit generate -o docker-compose.dev.yml`. To preview the generated Compose without writing a file (e.g. to pipe or inspect it), use `--dry-run`:
8599

86-
### 4. Snapshots
100+
```bash
101+
envkit generate --dry-run
102+
```
103+
104+
### 5. Snapshots
87105

88106
Save the current config as a named snapshot under `.devkit/snapshots/`:
89107

@@ -97,13 +115,19 @@ If you omit the name, a timestamp-based name is used. List existing snapshots:
97115
envkit snapshot list
98116
```
99117

100-
Restore a snapshot (overwrites `.dev-env.yml`):
118+
Restore a snapshot. This overwrites `.dev-env.yml`, but your current config is automatically backed up to a `pre-restore-<timestamp>` snapshot first, and you'll be asked to confirm in an interactive terminal (use `--yes` to skip the prompt in scripts):
101119

102120
```bash
103121
envkit snapshot restore <name>
104122
```
105123

106-
### 5. Share config (export / import)
124+
Delete a snapshot you no longer need:
125+
126+
```bash
127+
envkit snapshot delete <name>
128+
```
129+
130+
### 6. Share config (export / import)
107131

108132
Export a safe-to-share copy of your config (passwords and secrets become placeholders like `${DB_PASSWORD}`):
109133

@@ -119,7 +143,11 @@ Import a shared config file (it is validated and then written to `.dev-env.yml`
119143
envkit share import path/to/dev-env.shared.yml
120144
```
121145

122-
Use `-o <file>` to write to a different path (also relative to the current directory unless you pass an absolute path).
146+
Use `-o <file>` to write to a different path (also relative to the current directory unless you pass an absolute path). To check a shared file is valid without writing anything (useful in CI), pass `--validate-only`:
147+
148+
```bash
149+
envkit share import path/to/dev-env.shared.yml --validate-only
150+
```
123151

124152
### Global options
125153

@@ -136,12 +164,14 @@ Example: `envkit -q setup`
136164
|--------|-------------|
137165
| `envkit init` | Create `.dev-env.yml` interactively (wizard). Run from a project folder when you don’t have a config yet. |
138166
| `envkit setup` | Install dependencies, run database migrations, and run seed commands from your config. Use `--skip-deps`, `--skip-db`, or `--dry-run` to limit what runs. |
139-
| `envkit generate` | Generate `docker-compose.yml` from your config (requires `docker.enabled` in config). Use `-o <file>` to set the output path. |
167+
| `envkit validate` | Validate `.dev-env.yml` (and `.env`) without running setup or generate. Add `--strict` to fail on warnings and unresolved `${VAR}` references. CI/pre-commit friendly. |
168+
| `envkit generate` | Generate `docker-compose.yml` from your config (requires `docker.enabled` in config). Use `-o <file>` to set the output path, or `--dry-run` to print without writing. |
140169
| `envkit snapshot create [name]` | Save the current `.dev-env.yml` as a snapshot under `.devkit/snapshots/`. Omit `name` to use a timestamp. |
141170
| `envkit snapshot list` | List all saved snapshots. |
142-
| `envkit snapshot restore <name>` | Restore a snapshot; overwrites `.dev-env.yml`. |
171+
| `envkit snapshot restore <name>` | Restore a snapshot; overwrites `.dev-env.yml` (current config is backed up first). Use `--yes` to skip the confirmation prompt. |
172+
| `envkit snapshot delete <name>` | Delete a saved snapshot. |
143173
| `envkit share export` | Export a sanitized config (secrets → placeholders) to share safely. Default output: `dev-env.shared.yml`; use `-o <file>` to override. |
144-
| `envkit share import <file>` | Import a shared config file; writes to `.dev-env.yml` by default. Use `-o <file>` to write elsewhere. |
174+
| `envkit share import <file>` | Import a shared config file; writes to `.dev-env.yml` by default. Use `-o <file>` to write elsewhere, or `--validate-only` to validate without writing. |
145175
| `envkit path-setup` or `envkit pathsetup` | Add the npm global bin folder to your PATH so `envkit` works from any directory. Use if the shell says “envkit” is not recognized after a global install. |
146176

147177
For more detail: `envkit --help` or `envkit <command> --help`.

jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ module.exports = {
1111
],
1212
coverageDirectory: 'coverage',
1313
coverageReporters: ['text', 'lcov', 'html'],
14+
coverageThreshold: {
15+
global: {
16+
statements: 80,
17+
branches: 55,
18+
functions: 72,
19+
lines: 80,
20+
},
21+
},
1422
verbose: true,
1523
clearMocks: true,
1624
restoreMocks: true,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dev-env-kit",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "One-command project setup, environment snapshots, and Docker Compose generation for local development",
55
"main": "dist/cli/index.js",
66
"bin": {

src/cli/index.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ try {
1717

1818
import { runSetup } from '../commands/setup';
1919
import { runGenerate } from '../commands/generate';
20+
import { runValidate } from '../commands/validate';
2021
import { runInit, isInteractive } from '../commands/init';
21-
import { runSnapshotCreate, runSnapshotList, runSnapshotRestore } from '../commands/snapshot';
22+
import { runSnapshotCreate, runSnapshotList, runSnapshotRestore, runSnapshotDelete } from '../commands/snapshot';
2223
import { runShareExport, runShareImport } from '../commands/share';
2324
import { runPathSetup } from '../commands/path-setup';
2425
import { findProjectRoot } from '../core/config/loader';
@@ -80,6 +81,19 @@ program
8081
}
8182
});
8283

84+
program
85+
.command('validate')
86+
.description('Validate .dev-env.yml (and .env) without running setup or generate')
87+
.option('--strict', 'Fail on warnings or unresolved ${VAR} references')
88+
.action(async (options) => {
89+
try {
90+
await runValidate({ strict: options.strict });
91+
} catch (err: unknown) {
92+
logger.error((err as Error).message ?? 'Validation failed');
93+
process.exit(1);
94+
}
95+
});
96+
8397
const snapshotCommand = program
8498
.command('snapshot')
8599
.description('Manage environment snapshots');
@@ -111,24 +125,39 @@ snapshotCommand
111125

112126
snapshotCommand
113127
.command('restore')
114-
.description('Restore .dev-env.yml from a snapshot')
128+
.description('Restore .dev-env.yml from a snapshot (backs up current config first)')
115129
.argument('<name>', 'Snapshot name')
116-
.action(async (name) => {
130+
.option('-y, --yes', 'Skip the overwrite confirmation prompt')
131+
.action(async (name, options) => {
117132
try {
118-
await runSnapshotRestore(name);
133+
await runSnapshotRestore(name, { yes: options.yes });
119134
} catch (err: unknown) {
120135
logger.error((err as Error).message ?? 'Snapshot restore failed');
121136
process.exit(1);
122137
}
123138
});
124139

140+
snapshotCommand
141+
.command('delete')
142+
.description('Delete a snapshot')
143+
.argument('<name>', 'Snapshot name')
144+
.action(async (name) => {
145+
try {
146+
await runSnapshotDelete(name);
147+
} catch (err: unknown) {
148+
logger.error((err as Error).message ?? 'Snapshot delete failed');
149+
process.exit(1);
150+
}
151+
});
152+
125153
program
126154
.command('generate')
127155
.description('Generate docker-compose.yml from configuration')
128156
.option('-o, --output <file>', 'Output file path', 'docker-compose.yml')
157+
.option('--dry-run', 'Print the generated compose to stdout without writing a file')
129158
.action(async (options) => {
130159
try {
131-
await runGenerate({ output: options.output });
160+
await runGenerate({ output: options.output, dryRun: options.dryRun });
132161
} catch (err: any) {
133162
logger.error(err.message ?? 'Generate failed');
134163
process.exit(1);
@@ -157,9 +186,10 @@ shareCommand
157186
.description('Import shared configuration into project')
158187
.argument('<file>', 'Configuration file to import')
159188
.option('-o, --output <file>', 'Output file path', '.dev-env.yml')
189+
.option('--validate-only', 'Validate the file without writing it')
160190
.action(async (file, options) => {
161191
try {
162-
await runShareImport(file, { output: options.output });
192+
await runShareImport(file, { output: options.output, validateOnly: options.validateOnly });
163193
} catch (err: unknown) {
164194
logger.error((err as Error).message ?? 'Share import failed');
165195
process.exit(1);

src/commands/generate.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { writeFile } from '../utils/file-ops';
77

88
export interface GenerateOptions {
99
output?: string;
10+
dryRun?: boolean;
1011
}
1112

1213
function getTemplatesDir(): string {
@@ -36,6 +37,13 @@ export async function runGenerate(options: GenerateOptions = {}): Promise<void>
3637

3738
const templatesDir = getTemplatesDir();
3839
const content = generateComposeContent(config, templatesDir);
40+
41+
if (options.dryRun) {
42+
// Print to stdout without writing, so it can be inspected or piped.
43+
process.stdout.write(content.endsWith('\n') ? content : content + '\n');
44+
return;
45+
}
46+
3947
const outPath = path.isAbsolute(outputFile) ? outputFile : path.join(process.cwd(), outputFile);
4048

4149
await writeFile(outPath, content);

src/commands/init.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,13 @@ async function runWizard(projectRoot: string): Promise<DevEnvConfig> {
184184
);
185185
if (addSeed.value) {
186186
const seedCmd = await prompts(
187-
{ type: 'text', name: 'command', message: 'Seed command', initial: 'npm run seed' },
187+
[
188+
{ type: 'text', name: 'command', message: 'Seed command', initial: 'npm run seed' },
189+
{ type: 'text', name: 'path', message: 'Seed path', initial: '.' },
190+
],
188191
{ onCancel: () => process.exit(0) }
189192
);
190-
db.seed = { command: seedCmd.command || 'npm run seed' };
193+
db.seed = { command: seedCmd.command || 'npm run seed', path: seedCmd.path?.trim() || '.' };
191194
}
192195
databases.push(db);
193196
}

src/commands/setup.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,17 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
109109
}
110110
}
111111
if (db.seed && !dryRun) {
112+
const seedPath = db.seed.path ?? '.';
113+
const cwd = path.resolve(projectRoot, seedPath);
112114
const { cmd, args } = parseCommand(db.seed.command);
113-
logger.step(`Running seed (${db.type})...`);
115+
logger.step(`Running seed (${db.type}) in ${seedPath}...`);
114116
await exec(cmd, args, {
115-
cwd: projectRoot,
117+
cwd,
116118
env: { ...process.env, ...env } as Record<string, string>,
117119
silent: false,
118120
});
119121
} else if (db.seed && dryRun) {
120-
logger.info(`[dry-run] Would run seed: ${db.seed.command}`);
122+
logger.info(`[dry-run] Would run seed in ${db.seed.path ?? '.'}: ${db.seed.command}`);
121123
}
122124
}
123125
} else if (skipDb) {

src/commands/share.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function runShareExport(options: ShareExportOptions = {}): Promise<
8686

8787
export interface ShareImportOptions {
8888
output?: string;
89+
validateOnly?: boolean;
8990
}
9091

9192
export async function runShareImport(filePath: string, options: ShareImportOptions = {}): Promise<void> {
@@ -110,6 +111,11 @@ export async function runShareImport(filePath: string, options: ShareImportOptio
110111
throw new Error(`Invalid dev-env config in ${filePath}:\n${issues}`);
111112
}
112113

114+
if (options.validateOnly) {
115+
logger.success(`${filePath} is a valid dev-env config.`);
116+
return;
117+
}
118+
113119
const outputFile = (options.output?.trim() || '.dev-env.yml');
114120
const outPath = path.isAbsolute(outputFile) ? outputFile : path.join(process.cwd(), outputFile);
115121

0 commit comments

Comments
 (0)