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.
@@ -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
+ });