deploy-stack 0.17.7 → 0.17.9
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/.github/workflows/publish.yml +25 -0
- package/.github/workflows/test.yml +43 -0
- package/README.md +3 -3
- package/bin/cli.js +12 -42
- package/docs/ROADMAP.md +2 -2
- package/docs/adr/0001-s3-native-state-locking.md +35 -0
- package/docs/adr/0002-eject-mechanism-pure-iac.md +37 -0
- package/docs/adr/0003-sync-ai-context-strategy.md +46 -0
- package/docs/examples.md +4 -1
- package/docs/frameworks.md +34 -0
- package/docs/testing-strategy.md +23 -0
- package/package.json +7 -2
- package/src/commands/init.js +10 -2
- package/src/core/parser.js +43 -0
- package/src/utils/detector.js +22 -0
- package/src/utils/generator.js +1 -0
- package/src/utils/prompts.js +2 -1
- package/src/utils/warnings.js +7 -0
- package/templates/README.md +2 -0
- package/templates/docker/nestjs.Dockerfile +21 -0
- package/templates/docker/python.Dockerfile +1 -1
- package/tests/__snapshots__/generator.test.js.snap +7194 -0
- package/tests/ai.test.js +60 -0
- package/tests/cli.test.js +21 -0
- package/tests/generator.test.js +130 -0
- package/tests/parser.test.js +32 -0
- package/tests/secrets.test.js +93 -0
package/tests/ai.test.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { syncAi } from '../src/commands/sync-ai.js';
|
|
5
|
+
|
|
6
|
+
// 1. Mock the interactive prompts to simulate user input
|
|
7
|
+
vi.mock('@clack/prompts', () => ({
|
|
8
|
+
intro: vi.fn(),
|
|
9
|
+
outro: vi.fn(),
|
|
10
|
+
// Simulate the user selecting 'claude' from the list and hitting Enter
|
|
11
|
+
multiselect: vi.fn().mockResolvedValue(['claude']),
|
|
12
|
+
spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
|
|
13
|
+
log: { success: vi.fn(), warn: vi.fn(), error: vi.fn(), message: vi.fn() }
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// 2. Mock telemetry to prevent real network calls
|
|
17
|
+
vi.mock('../src/core/telemetry.js', () => ({
|
|
18
|
+
trackEvent: vi.fn(),
|
|
19
|
+
flushTelemetry: vi.fn().mockResolvedValue(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe('AI Context Synchronization', () => {
|
|
23
|
+
const originalCwd = process.cwd();
|
|
24
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-ai-env');
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
// Create a fake project directory and step into it
|
|
28
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
29
|
+
process.chdir(testDir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
// Step back out and clean up
|
|
34
|
+
process.chdir(originalCwd);
|
|
35
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('safely injects managed blocks without overwriting existing user instructions', async () => {
|
|
40
|
+
// Setup 1: Create a CLAUDE.md with pre-existing user instructions
|
|
41
|
+
const existingUserText = 'Always use async/await. Never use promises directly.\n';
|
|
42
|
+
await fs.writeFile('CLAUDE.md', existingUserText);
|
|
43
|
+
|
|
44
|
+
// Setup 2: Create a dummy terraform state so syncAi can extract the region/port
|
|
45
|
+
await fs.mkdir('terraform', { recursive: true });
|
|
46
|
+
await fs.writeFile(path.join('terraform', 'main.tf'), 'region = "us-east-2"\nport = "8000"');
|
|
47
|
+
|
|
48
|
+
// Execute the CLI command
|
|
49
|
+
await syncAi();
|
|
50
|
+
|
|
51
|
+
// Assert: Read the file back and verify both contents exist
|
|
52
|
+
const finalContent = await fs.readFile('CLAUDE.md', 'utf-8');
|
|
53
|
+
|
|
54
|
+
// 1. The user's original rules MUST remain intact
|
|
55
|
+
expect(finalContent).toContain(existingUserText);
|
|
56
|
+
|
|
57
|
+
// 2. The deploy-stack managed block MUST be injected
|
|
58
|
+
expect(finalContent).toContain('deploy-stack');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
describe('CLI Executable', () => {
|
|
6
|
+
it('must have the Node.js shebang at the top to prevent bash execution errors', () => {
|
|
7
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
8
|
+
const content = fs.readFileSync(cliPath, 'utf8');
|
|
9
|
+
|
|
10
|
+
expect(content.startsWith('#!/usr/bin/env node')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('must have executable permissions', () => {
|
|
14
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
15
|
+
const stats = fs.statSync(cliPath);
|
|
16
|
+
|
|
17
|
+
// Checks if the file is executable by the owner (Unix permission check)
|
|
18
|
+
const isExecutable = (stats.mode & fs.constants.S_IXUSR) !== 0;
|
|
19
|
+
expect(isExecutable).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { generateTemplates } from '../src/utils/generator.js';
|
|
5
|
+
|
|
6
|
+
describe('Infrastructure Generator', () => {
|
|
7
|
+
const testTargetDir = path.join(process.cwd(), 'tests', '.tmp-test-env');
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
await fs.mkdir(testTargetDir, { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
await fs.rm(testTargetDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const matrix = [
|
|
18
|
+
// 1. Backend APIs & Monoliths
|
|
19
|
+
{ name: 'Django_Postgres', framework: 'django', needsDb: true, buildDir: '' },
|
|
20
|
+
{ name: 'Rails_Postgres', framework: 'rails', needsDb: true, buildDir: '' },
|
|
21
|
+
{ name: 'Go_Distroless', framework: 'go', needsDb: false, buildDir: '' },
|
|
22
|
+
{ name: 'FastAPI_Python', framework: 'python', needsDb: false, buildDir: '' },
|
|
23
|
+
|
|
24
|
+
// 2. Frontend & Meta-Frameworks
|
|
25
|
+
{ name: 'NextJS_Standalone', framework: 'nextjs', needsDb: false, buildDir: '.next/standalone' },
|
|
26
|
+
{ name: 'Nuxt_SSR', framework: 'nuxt', needsDb: false, buildDir: '.output/server' },
|
|
27
|
+
{ name: 'Vite_Static_SPA', framework: 'static', needsDb: false, buildDir: 'dist' },
|
|
28
|
+
{ name: 'SvelteKit_Node', framework: 'svelte', needsDb: false, buildDir: 'build' },
|
|
29
|
+
|
|
30
|
+
// 3. Migration Engines
|
|
31
|
+
{
|
|
32
|
+
name: 'Heroku_Procfile_Migration',
|
|
33
|
+
framework: 'django',
|
|
34
|
+
needsDb: true,
|
|
35
|
+
buildDir: '',
|
|
36
|
+
procfile: { web: ['gunicorn config.wsgi'], worker: ['celery -A config worker'] }
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'Vercel_Edge_Migration',
|
|
40
|
+
framework: 'nextjs',
|
|
41
|
+
needsDb: false,
|
|
42
|
+
buildDir: '.next/standalone',
|
|
43
|
+
vercelRouting: '{"routes": [{"src": "/api/(.*)", "dest": "https://api.example.com/$1"}]}'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Docker_Compose_Sidecars',
|
|
47
|
+
framework: 'node',
|
|
48
|
+
needsDb: false,
|
|
49
|
+
buildDir: '',
|
|
50
|
+
dockerCompose: [{ name: 'web', port: 3000 }, { name: 'redis', image: 'redis:alpine' }]
|
|
51
|
+
}
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const tc of matrix) {
|
|
55
|
+
it(`generates correct infrastructure, CI/CD, and Dockerfile for ${tc.name}`, async () => {
|
|
56
|
+
await fs.rm(testTargetDir, { recursive: true, force: true }).catch(() => { });
|
|
57
|
+
await fs.mkdir(path.join(testTargetDir, '.github', 'workflows'), { recursive: true });
|
|
58
|
+
|
|
59
|
+
const dummyConfig = {
|
|
60
|
+
PROJECT_NAME: `test-${tc.name.toLowerCase()}`,
|
|
61
|
+
REGION: 'us-east-2',
|
|
62
|
+
PORT: '8000',
|
|
63
|
+
CPU: '256',
|
|
64
|
+
MEMORY: '512',
|
|
65
|
+
COMPUTE_TIER: 'Micro',
|
|
66
|
+
ESTIMATED_COST: '~$30',
|
|
67
|
+
STATE_BUCKET: 'test-bucket-123',
|
|
68
|
+
AWS_ACCOUNT_ID: '123456789012',
|
|
69
|
+
HEALTH_CHECK_PATH: '/health',
|
|
70
|
+
DESIRED_COUNT: '1',
|
|
71
|
+
DEPLOY_BRANCH: 'main',
|
|
72
|
+
BUILD_DIR: tc.buildDir,
|
|
73
|
+
finalFramework: tc.framework,
|
|
74
|
+
NEEDS_DATABASE: tc.needsDb,
|
|
75
|
+
DJANGO_WSGI: tc.framework === 'django' ? 'gunicorn config.wsgi' : '',
|
|
76
|
+
DISABLE_DEFAULT_CI: false,
|
|
77
|
+
PROCFILE: tc.procfile || null,
|
|
78
|
+
VERCEL_RULES: tc.vercelRouting ? { routes: [] } : null,
|
|
79
|
+
VERCEL_EDGE_ROUTING: tc.vercelRouting || '',
|
|
80
|
+
DOCKER_COMPOSE: tc.dockerCompose || null,
|
|
81
|
+
ENABLE_PR_PREVIEWS: true,
|
|
82
|
+
TASK_COMMAND: '',
|
|
83
|
+
WORKER_COMMAND: '',
|
|
84
|
+
DB_ENV_VARS: '',
|
|
85
|
+
COMPOSE_WEB_ENV_VARS: '',
|
|
86
|
+
EXTRA_CONTAINERS: '',
|
|
87
|
+
TASK_SECRETS: '',
|
|
88
|
+
INITIAL_SECRET_MAP: '{\n }',
|
|
89
|
+
SAFE_ALB_NAME: `test-alb`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await generateTemplates(testTargetDir, dummyConfig);
|
|
93
|
+
|
|
94
|
+
const mainTfPath = path.join(testTargetDir, 'terraform', 'main.tf');
|
|
95
|
+
const networkTfPath = path.join(testTargetDir, 'terraform', 'network.tf');
|
|
96
|
+
const databaseTfPath = path.join(testTargetDir, 'terraform', 'database.tf');
|
|
97
|
+
const workerTfPath = path.join(testTargetDir, 'terraform', 'worker.tf');
|
|
98
|
+
|
|
99
|
+
const deployYmlPath = path.join(testTargetDir, '.github', 'workflows', 'deploy.yml');
|
|
100
|
+
const previewYmlPath = path.join(testTargetDir, '.github', 'workflows', 'preview.yml');
|
|
101
|
+
const teardownYmlPath = path.join(testTargetDir, '.github', 'workflows', 'teardown.yml');
|
|
102
|
+
|
|
103
|
+
const dockerfilePath = path.join(testTargetDir, 'Dockerfile');
|
|
104
|
+
|
|
105
|
+
// Read contents (falling back to a string if they correctly don't exist)
|
|
106
|
+
const mainTfContent = await fs.readFile(mainTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
107
|
+
const networkTfContent = await fs.readFile(networkTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
108
|
+
const databaseTfContent = await fs.readFile(databaseTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
109
|
+
const workerTfContent = await fs.readFile(workerTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
110
|
+
|
|
111
|
+
const deployYmlContent = await fs.readFile(deployYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
112
|
+
const previewYmlContent = await fs.readFile(previewYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
113
|
+
const teardownYmlContent = await fs.readFile(teardownYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
114
|
+
|
|
115
|
+
const dockerfileContent = await fs.readFile(dockerfilePath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
|
|
116
|
+
|
|
117
|
+
// Snapshot everything
|
|
118
|
+
expect(mainTfContent).toMatchSnapshot(`${tc.name} - main.tf`);
|
|
119
|
+
expect(networkTfContent).toMatchSnapshot(`${tc.name} - network.tf`);
|
|
120
|
+
expect(databaseTfContent).toMatchSnapshot(`${tc.name} - database.tf`);
|
|
121
|
+
expect(workerTfContent).toMatchSnapshot(`${tc.name} - worker.tf`);
|
|
122
|
+
|
|
123
|
+
expect(deployYmlContent).toMatchSnapshot(`${tc.name} - deploy.yml`);
|
|
124
|
+
expect(previewYmlContent).toMatchSnapshot(`${tc.name} - preview.yml`);
|
|
125
|
+
expect(teardownYmlContent).toMatchSnapshot(`${tc.name} - teardown.yml`);
|
|
126
|
+
|
|
127
|
+
expect(dockerfileContent).toMatchSnapshot(`${tc.name} - Dockerfile`);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseCliArgs } from '../src/core/parser.js';
|
|
3
|
+
|
|
4
|
+
describe('CLI Argument Parser', () => {
|
|
5
|
+
it('safely extracts telemetry variations and prevents positional hijacking', () => {
|
|
6
|
+
// Simulating: npx deploy-stack secrets push .env --no-telemetry=true
|
|
7
|
+
const args = ['secrets', 'push', '.env', '--no-telemetry=true'];
|
|
8
|
+
const result = parseCliArgs(args);
|
|
9
|
+
|
|
10
|
+
expect(result.hasNoTelemetry).toBe(true);
|
|
11
|
+
expect(result.positionalArgs).toEqual(['secrets', 'push', '.env']);
|
|
12
|
+
expect(result.baseCommand).toBe('secrets push');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('correctly parses headless boolean flags without assignments', () => {
|
|
16
|
+
// Simulating: npx deploy-stack --headless --needsDatabase
|
|
17
|
+
const args = ['--headless', '--needsDatabase'];
|
|
18
|
+
const result = parseCliArgs(args);
|
|
19
|
+
|
|
20
|
+
expect(result.isHeadless).toBe(true);
|
|
21
|
+
expect(result.headlessOptions.needsDatabase).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('correctly maps headless assignment flags', () => {
|
|
25
|
+
// Simulating: npx deploy-stack --headless --framework=django --region=us-east-2
|
|
26
|
+
const args = ['--headless', '--framework=django', '--region=us-east-2'];
|
|
27
|
+
const result = parseCliArgs(args);
|
|
28
|
+
|
|
29
|
+
expect(result.headlessOptions.framework).toBe('django');
|
|
30
|
+
expect(result.headlessOptions.region).toBe('us-east-2');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { pushSecrets } from '../src/commands/secrets.js';
|
|
5
|
+
|
|
6
|
+
// 1. Use vi.hoisted() so these variables are available when vi.mock() runs at the top of the file
|
|
7
|
+
const { mockSend, MockSecretsManagerClient, MockUpdateSecretCommand } = vi.hoisted(() => {
|
|
8
|
+
const sendFn = vi.fn().mockResolvedValue({});
|
|
9
|
+
return {
|
|
10
|
+
mockSend: sendFn,
|
|
11
|
+
MockSecretsManagerClient: vi.fn(function (config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.send = sendFn;
|
|
14
|
+
}),
|
|
15
|
+
MockUpdateSecretCommand: vi.fn(function (input) {
|
|
16
|
+
Object.assign(this, input);
|
|
17
|
+
})
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 2. Inject the hoisted mocks into the AWS SDK
|
|
22
|
+
vi.mock('@aws-sdk/client-secrets-manager', () => ({
|
|
23
|
+
SecretsManagerClient: MockSecretsManagerClient,
|
|
24
|
+
UpdateSecretCommand: MockUpdateSecretCommand
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// 3. Mock telemetry to prevent real network calls during testing
|
|
28
|
+
vi.mock('../src/core/telemetry.js', () => ({
|
|
29
|
+
trackEvent: vi.fn(),
|
|
30
|
+
flushTelemetry: vi.fn().mockResolvedValue(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
describe('Secrets Push Command', () => {
|
|
34
|
+
const originalCwd = process.cwd();
|
|
35
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-env');
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
// Create a fake project directory and step into it
|
|
39
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
40
|
+
process.chdir(testDir);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
// Step back out, clean up the files, and reset mocks
|
|
45
|
+
process.chdir(originalCwd);
|
|
46
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
mockSend.mockReset();
|
|
49
|
+
mockSend.mockResolvedValue({}); // Reset to default success state
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reads .env, pushes to AWS, and writes secret_keys.json', async () => {
|
|
53
|
+
// Setup: Create a fake .env and a fake main.tf (to test region extraction)
|
|
54
|
+
await fs.writeFile('.env', 'GITHUB_TOKEN=ghp_12345\nDB_PASS=supersecret');
|
|
55
|
+
await fs.writeFile(path.join('terraform', 'main.tf'), 'region = "us-east-2"');
|
|
56
|
+
|
|
57
|
+
// Execute the CLI command
|
|
58
|
+
await pushSecrets('.env', 'my-project');
|
|
59
|
+
|
|
60
|
+
// Assert 1: Did we initialize the AWS client with the correct region from main.tf?
|
|
61
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
62
|
+
|
|
63
|
+
// Assert 2: Did we package the exact right payload for AWS?
|
|
64
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
65
|
+
SecretId: 'my-project-secrets',
|
|
66
|
+
SecretString: JSON.stringify({ GITHUB_TOKEN: 'ghp_12345', DB_PASS: 'supersecret' })
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Assert 3: Did we write the keys to the local JSON file for Terraform to use?
|
|
70
|
+
const keysPath = path.join('terraform', 'secret_keys.json');
|
|
71
|
+
const keysContent = await fs.readFile(keysPath, 'utf-8');
|
|
72
|
+
expect(JSON.parse(keysContent)).toEqual(['GITHUB_TOKEN', 'DB_PASS']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles a missing AWS vault (ResourceNotFoundException) gracefully', async () => {
|
|
76
|
+
// Setup: Force the mocked send method to reject
|
|
77
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'Vault missing' });
|
|
78
|
+
await fs.writeFile('.env', 'API_KEY=123');
|
|
79
|
+
|
|
80
|
+
// We must mock process.exit so the test runner doesn't crash when the CLI tries to exit
|
|
81
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
82
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
83
|
+
|
|
84
|
+
// Execute
|
|
85
|
+
await pushSecrets('.env', 'my-project');
|
|
86
|
+
|
|
87
|
+
// Assert: Ensure the CLI caught the error and attempted a clean exit
|
|
88
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
89
|
+
|
|
90
|
+
exitSpy.mockRestore();
|
|
91
|
+
consoleSpy.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
});
|