grada-run 0.0.2 → 0.32.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/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +29 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +75 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +105 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +127 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +126 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +65 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { provisionStateBucket, handleAwsAuthError, isAuthError, handleAuthErrorBranch, resolveClient, hasAwsCli, resetAwsCliCache } from '../src/utils/aws.js';
|
|
3
|
+
|
|
4
|
+
const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
spawnSync: mockSpawnSync,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
mockS3Send,
|
|
12
|
+
mockStsSend,
|
|
13
|
+
MockS3Client,
|
|
14
|
+
MockSTSClient,
|
|
15
|
+
MockGetCallerIdentityCommand,
|
|
16
|
+
MockCreateBucketCommand,
|
|
17
|
+
MockPutBucketVersioningCommand,
|
|
18
|
+
MockPutBucketTaggingCommand,
|
|
19
|
+
} = vi.hoisted(() => {
|
|
20
|
+
const s3Send = vi.fn();
|
|
21
|
+
const stsSend = vi.fn();
|
|
22
|
+
const mockCommand = (input) => Object.assign({}, input);
|
|
23
|
+
return {
|
|
24
|
+
mockS3Send: s3Send,
|
|
25
|
+
mockStsSend: stsSend,
|
|
26
|
+
MockS3Client: vi.fn(function () {
|
|
27
|
+
this.send = s3Send;
|
|
28
|
+
}),
|
|
29
|
+
MockSTSClient: vi.fn(function () {
|
|
30
|
+
this.send = stsSend;
|
|
31
|
+
}),
|
|
32
|
+
MockGetCallerIdentityCommand: vi.fn(function (input) {
|
|
33
|
+
Object.assign(this, mockCommand(input));
|
|
34
|
+
}),
|
|
35
|
+
MockCreateBucketCommand: vi.fn(function (input) {
|
|
36
|
+
Object.assign(this, mockCommand(input));
|
|
37
|
+
}),
|
|
38
|
+
MockPutBucketVersioningCommand: vi.fn(function (input) {
|
|
39
|
+
Object.assign(this, mockCommand(input));
|
|
40
|
+
}),
|
|
41
|
+
MockPutBucketTaggingCommand: vi.fn(function (input) {
|
|
42
|
+
Object.assign(this, mockCommand(input));
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
vi.mock('@aws-sdk/client-s3', () => {
|
|
48
|
+
return {
|
|
49
|
+
S3Client: MockS3Client,
|
|
50
|
+
CreateBucketCommand: MockCreateBucketCommand,
|
|
51
|
+
PutBucketVersioningCommand: MockPutBucketVersioningCommand,
|
|
52
|
+
PutBucketTaggingCommand: MockPutBucketTaggingCommand,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
vi.mock('@aws-sdk/client-sts', () => {
|
|
57
|
+
return {
|
|
58
|
+
STSClient: MockSTSClient,
|
|
59
|
+
GetCallerIdentityCommand: MockGetCallerIdentityCommand,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('provisionStateBucket', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
mockS3Send.mockReset().mockResolvedValue({});
|
|
67
|
+
mockStsSend.mockReset().mockResolvedValue({ Account: '123456789012' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("when region is 'us-east-1', CreateBucketConfiguration is undefined", async () => {
|
|
71
|
+
await provisionStateBucket('us-east-1', 'my-project');
|
|
72
|
+
|
|
73
|
+
expect(MockCreateBucketCommand).toHaveBeenCalledWith(
|
|
74
|
+
expect.objectContaining({ CreateBucketConfiguration: undefined }),
|
|
75
|
+
);
|
|
76
|
+
expect(MockCreateBucketCommand.mock.calls[0][0].CreateBucketConfiguration).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("when region is 'us-east-2', CreateBucketConfiguration has LocationConstraint 'us-east-2'", async () => {
|
|
80
|
+
await provisionStateBucket('us-east-2', 'my-project');
|
|
81
|
+
|
|
82
|
+
expect(MockCreateBucketCommand).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
CreateBucketConfiguration: { LocationConstraint: 'us-east-2' },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(MockCreateBucketCommand.mock.calls[0][0].CreateBucketConfiguration).toEqual({
|
|
88
|
+
LocationConstraint: 'us-east-2',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('handleAwsAuthError', () => {
|
|
94
|
+
let exitSpy;
|
|
95
|
+
let consoleSpy;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
99
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
exitSpy.mockRestore();
|
|
104
|
+
consoleSpy.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('stops the spinner, prints install guidance, and exits 1 when the AWS CLI is missing', () => {
|
|
108
|
+
const spinner = { stop: vi.fn() };
|
|
109
|
+
|
|
110
|
+
handleAwsAuthError({ name: 'ExpiredTokenException' }, spinner, {
|
|
111
|
+
spawnSyncImpl: () => {
|
|
112
|
+
throw new Error('ENOENT');
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(spinner.stop).toHaveBeenCalledWith(expect.stringContaining('AWS session expired'));
|
|
117
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('AWS CLI not found'));
|
|
118
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('aws-credentials.md'));
|
|
119
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('prints sso login guidance when the AWS CLI is present', () => {
|
|
123
|
+
const spinner = { stop: vi.fn() };
|
|
124
|
+
|
|
125
|
+
handleAwsAuthError({ name: 'UnrecognizedClientException' }, spinner, {
|
|
126
|
+
spawnSyncImpl: () => ({ status: 0 }),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(spinner.stop).toHaveBeenCalledWith(expect.stringContaining('AWS session expired'));
|
|
130
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('aws sso login'));
|
|
131
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('aws-credentials.md'));
|
|
132
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('tolerates a null spinner', () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
handleAwsAuthError({ name: 'ExpiredTokenException' }, null, {
|
|
138
|
+
spawnSyncImpl: () => ({ status: 0 }),
|
|
139
|
+
}),
|
|
140
|
+
).not.toThrow();
|
|
141
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('aws sso login'));
|
|
142
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('isAuthError', () => {
|
|
147
|
+
it('matches expired/invalid credential errors only', () => {
|
|
148
|
+
expect(isAuthError({ name: 'ExpiredTokenException' })).toBe(true);
|
|
149
|
+
expect(isAuthError({ name: 'UnrecognizedClientException' })).toBe(true);
|
|
150
|
+
expect(isAuthError({ name: 'ResourceNotFoundException' })).toBe(false);
|
|
151
|
+
expect(isAuthError({})).toBe(false);
|
|
152
|
+
expect(isAuthError(null)).toBe(false);
|
|
153
|
+
expect(isAuthError(undefined)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('handleAuthErrorBranch', () => {
|
|
158
|
+
let exitSpy;
|
|
159
|
+
let consoleSpy;
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
163
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
exitSpy.mockRestore();
|
|
168
|
+
consoleSpy.mockRestore();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('handles auth errors and reports true', () => {
|
|
172
|
+
const handled = handleAuthErrorBranch({ name: 'ExpiredTokenException' }, null, {
|
|
173
|
+
spawnSyncImpl: () => ({ status: 0 }),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(handled).toBe(true);
|
|
177
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('aws sso login'));
|
|
178
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('ignores non-auth errors and reports false without exiting', () => {
|
|
182
|
+
const handled = handleAuthErrorBranch({ name: 'ResourceNotFoundException', message: 'nope' }, null, {
|
|
183
|
+
spawnSyncImpl: () => ({ status: 0 }),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(handled).toBe(false);
|
|
187
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('resolveClient', () => {
|
|
192
|
+
class FakeClient {
|
|
193
|
+
constructor(options) {
|
|
194
|
+
this.options = options;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it('returns the injected client when it has a send method', () => {
|
|
199
|
+
const injected = { send: () => {} };
|
|
200
|
+
expect(resolveClient(injected, FakeClient, { region: 'us-east-2' })).toBe(injected);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('constructs the client when nothing usable is injected', () => {
|
|
204
|
+
const built = resolveClient(undefined, FakeClient, { region: 'eu-west-1' });
|
|
205
|
+
expect(built).toBeInstanceOf(FakeClient);
|
|
206
|
+
expect(built.options).toEqual({ region: 'eu-west-1' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('constructs the client when the injected value has no send method', () => {
|
|
210
|
+
expect(resolveClient({ not: 'a-client' }, FakeClient)).toBeInstanceOf(FakeClient);
|
|
211
|
+
expect(resolveClient(null, FakeClient)).toBeInstanceOf(FakeClient);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('hasAwsCli result cache', () => {
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
resetAwsCliCache();
|
|
218
|
+
mockSpawnSync.mockReset().mockReturnValue({ status: 0 });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('spawns once for repeated default calls within the TTL', () => {
|
|
222
|
+
expect(hasAwsCli()).toBe(true);
|
|
223
|
+
expect(hasAwsCli()).toBe(true);
|
|
224
|
+
expect(hasAwsCli({})).toBe(true);
|
|
225
|
+
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(mockSpawnSync).toHaveBeenCalledWith('aws', ['--version'], { stdio: 'ignore' });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('spawns again after resetAwsCliCache()', () => {
|
|
230
|
+
expect(hasAwsCli()).toBe(true);
|
|
231
|
+
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
|
232
|
+
resetAwsCliCache();
|
|
233
|
+
expect(hasAwsCli()).toBe(true);
|
|
234
|
+
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('bypasses the cache for custom spawnSyncImpl without polluting it', () => {
|
|
238
|
+
expect(hasAwsCli({ spawnSyncImpl: () => ({ status: 1 }) })).toBe(false);
|
|
239
|
+
expect(hasAwsCli({ spawnSyncImpl: () => ({ status: 0 }) })).toBe(true);
|
|
240
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
241
|
+
expect(hasAwsCli()).toBe(true);
|
|
242
|
+
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { detectProjectCapabilities } from '../src/utils/capabilities.js';
|
|
6
|
+
|
|
7
|
+
let tmpDirs = [];
|
|
8
|
+
|
|
9
|
+
function makeTmp() {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'capabilities-test-'));
|
|
11
|
+
tmpDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeFile(dir, relPath, content) {
|
|
16
|
+
const full = path.join(dir, relPath);
|
|
17
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
18
|
+
fs.writeFileSync(full, content);
|
|
19
|
+
return full;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeJson(dir, relPath, value) {
|
|
23
|
+
return writeFile(dir, relPath, JSON.stringify(value, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDirs = [];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const dir of tmpDirs) {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('detectProjectCapabilities: empty and malformed projects', () => {
|
|
37
|
+
it('returns a blank shape for an empty directory', () => {
|
|
38
|
+
const result = detectProjectCapabilities(makeTmp());
|
|
39
|
+
expect(result.relationalDb).toEqual({ detected: false, evidence: [] });
|
|
40
|
+
expect(result.worker).toEqual({ detected: false, suggestedCommand: null, evidence: [] });
|
|
41
|
+
expect(result.migration).toEqual({ detected: false, command: null });
|
|
42
|
+
expect(result.upcomingHints).toEqual({ vector: false, cron: false, mysql: false });
|
|
43
|
+
expect(Object.keys(result.addons)).toEqual([
|
|
44
|
+
'storage:s3', 'db:dynamodb', 'db:redis', 'queue:sqs', 'ai:bedrock', 'email:ses', 'cron',
|
|
45
|
+
]);
|
|
46
|
+
for (const addon of Object.values(result.addons)) {
|
|
47
|
+
expect(addon).toEqual({ detected: false, evidence: [] });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('never throws on malformed manifests', () => {
|
|
52
|
+
const dir = makeTmp();
|
|
53
|
+
writeFile(dir, 'package.json', '{not json');
|
|
54
|
+
writeFile(dir, 'requirements.txt', '\x00\x01binary');
|
|
55
|
+
writeFile(dir, 'go.mod', '((((');
|
|
56
|
+
writeFile(dir, 'vercel.json', '[unclosed');
|
|
57
|
+
writeFile(dir, 'docker-compose.yml', ':\t: bad: [yaml');
|
|
58
|
+
expect(() => detectProjectCapabilities(dir)).not.toThrow();
|
|
59
|
+
const result = detectProjectCapabilities(dir);
|
|
60
|
+
expect(result.relationalDb.detected).toBe(false);
|
|
61
|
+
expect(result.migration.detected).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('detectProjectCapabilities: relational database signals', () => {
|
|
66
|
+
it('detects Node postgres drivers by exact key', () => {
|
|
67
|
+
const dir = makeTmp();
|
|
68
|
+
writeJson(dir, 'package.json', { dependencies: { pg: '^8.0.0' }, devDependencies: { 'drizzle-kit': '^1.0.0' } });
|
|
69
|
+
const result = detectProjectCapabilities(dir);
|
|
70
|
+
expect(result.relationalDb.detected).toBe(true);
|
|
71
|
+
expect(result.relationalDb.evidence).toContain('pg');
|
|
72
|
+
expect(result.relationalDb.evidence).toContain('drizzle-kit');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects Python, Go, and Ruby postgres signals with normalization', () => {
|
|
76
|
+
const dir = makeTmp();
|
|
77
|
+
writeFile(dir, 'requirements.txt', 'psycopg2-binary==2.9.9\nDjango>=4.0\n');
|
|
78
|
+
writeFile(dir, 'go.mod', 'module example.com/app\n\ngo 1.21\n\nrequire github.com/jackc/pgx/v5 v5.0.0\n');
|
|
79
|
+
writeFile(dir, 'Gemfile', "source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\n");
|
|
80
|
+
const result = detectProjectCapabilities(dir);
|
|
81
|
+
expect(result.relationalDb.detected).toBe(true);
|
|
82
|
+
expect(result.relationalDb.evidence).toContain('psycopg2-binary');
|
|
83
|
+
expect(result.relationalDb.evidence).toContain('django');
|
|
84
|
+
expect(result.relationalDb.evidence).not.toContain('github.com/jackc/pgx/v5');
|
|
85
|
+
// pgx v5 module path is distinct from the listed v4 signal
|
|
86
|
+
expect(result.relationalDb.evidence).toContain('rails');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('detects pyproject.toml and Pipfile dependencies', () => {
|
|
90
|
+
const dir = makeTmp();
|
|
91
|
+
writeFile(dir, 'pyproject.toml', '[project]\ndependencies = [\n "sqlalchemy>=2.0",\n "alembic",\n]\n');
|
|
92
|
+
const fromPyproject = detectProjectCapabilities(dir);
|
|
93
|
+
expect(fromPyproject.relationalDb.evidence).toContain('sqlalchemy');
|
|
94
|
+
expect(fromPyproject.relationalDb.evidence).toContain('alembic');
|
|
95
|
+
|
|
96
|
+
const dir2 = makeTmp();
|
|
97
|
+
writeFile(dir2, 'Pipfile', '[packages]\nasyncpg = "*"\n');
|
|
98
|
+
expect(detectProjectCapabilities(dir2).relationalDb.evidence).toContain('asyncpg');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('triggers on Drizzle, Alembic, Django, and Rails markers', () => {
|
|
102
|
+
for (const marker of ['drizzle.config.ts', 'alembic.ini', 'manage.py', 'bin/rails']) {
|
|
103
|
+
const dir = makeTmp();
|
|
104
|
+
writeFile(dir, marker, '# marker');
|
|
105
|
+
const result = detectProjectCapabilities(dir);
|
|
106
|
+
expect(result.relationalDb.detected).toBe(true);
|
|
107
|
+
expect(result.relationalDb.evidence).toContain(marker);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('honors the prisma provider: postgres yes, mysql/sqlite/mongodb no', () => {
|
|
112
|
+
const withProvider = (provider) => {
|
|
113
|
+
const dir = makeTmp();
|
|
114
|
+
writeJson(dir, 'package.json', { dependencies: { '@prisma/client': '^5.0.0' } });
|
|
115
|
+
writeFile(dir, 'prisma/schema.prisma', `datasource db {\n provider = "${provider}"\n}\n`);
|
|
116
|
+
return detectProjectCapabilities(dir);
|
|
117
|
+
};
|
|
118
|
+
expect(withProvider('postgresql').relationalDb.detected).toBe(true);
|
|
119
|
+
expect(withProvider('postgresql').relationalDb.evidence).toContain('prisma/schema.prisma');
|
|
120
|
+
expect(withProvider('mysql').relationalDb.detected).toBe(false);
|
|
121
|
+
expect(withProvider('mysql').upcomingHints.mysql).toBe(true);
|
|
122
|
+
expect(withProvider('sqlite').relationalDb.detected).toBe(false);
|
|
123
|
+
expect(withProvider('mongodb').relationalDb.detected).toBe(false);
|
|
124
|
+
|
|
125
|
+
// Bare client without a schema still counts as relational.
|
|
126
|
+
const bare = makeTmp();
|
|
127
|
+
writeJson(bare, 'package.json', { dependencies: { prisma: '^5.0.0' } });
|
|
128
|
+
expect(detectProjectCapabilities(bare).relationalDb.detected).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('detects compose postgres images and env keys', () => {
|
|
132
|
+
const dir = makeTmp();
|
|
133
|
+
writeFile(dir, 'docker-compose.yml', 'services:\n db:\n image: postgis/postgis:16-3.4\n');
|
|
134
|
+
writeFile(dir, '.env.example', 'DATABASE_URL=postgresql://user:pass@localhost/db\n');
|
|
135
|
+
const result = detectProjectCapabilities(dir);
|
|
136
|
+
expect(result.relationalDb.evidence).toContain('docker-compose postgis/postgis');
|
|
137
|
+
expect(result.relationalDb.evidence).toContain('DATABASE_URL');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps MySQL-only projects out of relationalDb with a mysql hint', () => {
|
|
141
|
+
const dir = makeTmp();
|
|
142
|
+
writeJson(dir, 'package.json', { dependencies: { mysql2: '^3.0.0' } });
|
|
143
|
+
writeFile(dir, '.env.example', 'MYSQL_URL=mysql://user:pass@localhost/db\n');
|
|
144
|
+
const result = detectProjectCapabilities(dir);
|
|
145
|
+
expect(result.relationalDb.detected).toBe(false);
|
|
146
|
+
expect(result.upcomingHints.mysql).toBe(true);
|
|
147
|
+
|
|
148
|
+
const both = makeTmp();
|
|
149
|
+
writeJson(both, 'package.json', { dependencies: { pg: '^8.0.0', mysql2: '^3.0.0' } });
|
|
150
|
+
const mixed = detectProjectCapabilities(both);
|
|
151
|
+
expect(mixed.relationalDb.detected).toBe(true);
|
|
152
|
+
expect(mixed.upcomingHints.mysql).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('detectProjectCapabilities: addon signals', () => {
|
|
157
|
+
it('detects redis across ecosystems, compose, and env keys', () => {
|
|
158
|
+
const dir = makeTmp();
|
|
159
|
+
writeJson(dir, 'package.json', { dependencies: { ioredis: '^5.0.0', bullmq: '^5.0.0' } });
|
|
160
|
+
writeFile(dir, 'requirements.txt', 'celery\nredis\n');
|
|
161
|
+
writeFile(dir, 'compose.yaml', 'services:\n cache:\n image: valkey/valkey:8\n');
|
|
162
|
+
const result = detectProjectCapabilities(dir);
|
|
163
|
+
expect(result.addons['db:redis'].detected).toBe(true);
|
|
164
|
+
expect(result.addons['db:redis'].evidence).toEqual(
|
|
165
|
+
expect.arrayContaining(['ioredis', 'bullmq', 'redis', 'celery', 'docker-compose valkey/valkey'])
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('requires celery+redis co-presence for the celery redis signal', () => {
|
|
170
|
+
const alone = makeTmp();
|
|
171
|
+
writeFile(alone, 'requirements.txt', 'celery\n');
|
|
172
|
+
expect(detectProjectCapabilities(alone).addons['db:redis'].detected).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('detects sqs, s3, dynamodb, bedrock, and ses signals', () => {
|
|
176
|
+
const dir = makeTmp();
|
|
177
|
+
writeJson(dir, 'package.json', {
|
|
178
|
+
dependencies: {
|
|
179
|
+
'@aws-sdk/client-sqs': '^3.0.0',
|
|
180
|
+
'@aws-sdk/client-s3': '^3.0.0',
|
|
181
|
+
'@aws-sdk/client-dynamodb': '^3.0.0',
|
|
182
|
+
'@aws-sdk/client-bedrock-runtime': '^3.0.0',
|
|
183
|
+
'@aws-sdk/client-sesv2': '^3.0.0',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
writeFile(dir, 'Gemfile', "gem 'shoryuken'\ngem 'aws-sdk-ses'\ngem 'aws-sdk-bedrockruntime'\ngem 'aws-sdk-dynamodb'\n");
|
|
187
|
+
const result = detectProjectCapabilities(dir);
|
|
188
|
+
expect(result.addons['queue:sqs'].evidence).toEqual(expect.arrayContaining(['@aws-sdk/client-sqs', 'shoryuken']));
|
|
189
|
+
expect(result.addons['storage:s3'].evidence).toContain('@aws-sdk/client-s3');
|
|
190
|
+
expect(result.addons['db:dynamodb'].evidence).toEqual(
|
|
191
|
+
expect.arrayContaining(['@aws-sdk/client-dynamodb', 'aws-sdk-dynamodb'])
|
|
192
|
+
);
|
|
193
|
+
expect(result.addons['ai:bedrock'].evidence).toEqual(
|
|
194
|
+
expect.arrayContaining(['@aws-sdk/client-bedrock-runtime', 'aws-sdk-bedrockruntime'])
|
|
195
|
+
);
|
|
196
|
+
expect(result.addons['email:ses'].evidence).toEqual(
|
|
197
|
+
expect.arrayContaining(['@aws-sdk/client-sesv2', 'aws-sdk-ses'])
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('detects localstack only when SERVICES includes sqs', () => {
|
|
202
|
+
const withSqs = makeTmp();
|
|
203
|
+
writeFile(withSqs, 'docker-compose.yml', 'services:\n aws:\n image: localstack/localstack:3\n environment:\n SERVICES: s3,sqs\n');
|
|
204
|
+
expect(detectProjectCapabilities(withSqs).addons['queue:sqs'].evidence)
|
|
205
|
+
.toContain('docker-compose localstack');
|
|
206
|
+
|
|
207
|
+
const withoutSqs = makeTmp();
|
|
208
|
+
writeFile(withoutSqs, 'docker-compose.yml', 'services:\n aws:\n image: localstack/localstack:3\n environment:\n SERVICES: s3\n');
|
|
209
|
+
expect(detectProjectCapabilities(withoutSqs).addons['queue:sqs'].detected).toBe(false);
|
|
210
|
+
|
|
211
|
+
const bare = makeTmp();
|
|
212
|
+
writeFile(bare, 'docker-compose.yml', 'services:\n aws:\n image: localstack/localstack:3\n');
|
|
213
|
+
expect(detectProjectCapabilities(bare).addons['queue:sqs'].evidence)
|
|
214
|
+
.toContain('docker-compose localstack');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('detects minio, dynamodb-local, and ses env keys', () => {
|
|
218
|
+
const dir = makeTmp();
|
|
219
|
+
writeFile(dir, 'docker-compose.yml', 'services:\n s3:\n image: minio/minio:latest\n ddb:\n image: amazon/dynamodb-local:latest\n');
|
|
220
|
+
writeFile(dir, '.env.sample', 'export SES_FROM_EMAIL=noreply@example.com\nSES_REGION=us-east-2\n');
|
|
221
|
+
const result = detectProjectCapabilities(dir);
|
|
222
|
+
expect(result.addons['storage:s3'].evidence).toContain('docker-compose minio/minio');
|
|
223
|
+
expect(result.addons['db:dynamodb'].evidence).toContain('docker-compose amazon/dynamodb-local');
|
|
224
|
+
expect(result.addons['email:ses'].evidence).toEqual(expect.arrayContaining(['SES_FROM_EMAIL', 'SES_REGION']));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('detectProjectCapabilities: tokenization and privacy', () => {
|
|
229
|
+
it('never fires on substring false-positives', () => {
|
|
230
|
+
const dir = makeTmp();
|
|
231
|
+
writeJson(dir, 'package.json', { dependencies: { 'node-cron-parser': '1.0.0', pgvector: '1.0.0' } });
|
|
232
|
+
writeFile(dir, 'requirements.txt', 'my-pg-helper\nnot-cron-at-all\n');
|
|
233
|
+
writeFile(dir, 'Gemfile', "gem 'pg_search'\n");
|
|
234
|
+
writeFile(dir, 'go.mod', 'module example.com/app\n\nrequire github.com/foo/pgbar v1.0.0\n');
|
|
235
|
+
const result = detectProjectCapabilities(dir);
|
|
236
|
+
expect(result.relationalDb.detected).toBe(false);
|
|
237
|
+
expect(result.upcomingHints.cron).toBe(false);
|
|
238
|
+
// pgvector is an exact package: vector hint yes, relational pg no.
|
|
239
|
+
expect(result.upcomingHints.vector).toBe(true);
|
|
240
|
+
expect(result.relationalDb.evidence).not.toContain('pg');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('treats sidekiq-cron as cron only, not as sidekiq redis', () => {
|
|
244
|
+
const dir = makeTmp();
|
|
245
|
+
writeFile(dir, 'Gemfile', "gem 'sidekiq-cron'\n");
|
|
246
|
+
const result = detectProjectCapabilities(dir);
|
|
247
|
+
expect(result.upcomingHints.cron).toBe(true);
|
|
248
|
+
expect(result.addons['db:redis'].detected).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reads env key names but never values', () => {
|
|
252
|
+
const dir = makeTmp();
|
|
253
|
+
writeFile(dir, '.env', 'REDIS_URL=redis://:s3cret-pw@db.internal:6379/0\n');
|
|
254
|
+
writeFile(dir, '.env.example', 'FOO=SQS_QUEUE_URL\n# SQS_DLQ_URL=commented-out\n');
|
|
255
|
+
const result = detectProjectCapabilities(dir);
|
|
256
|
+
expect(result.addons['db:redis'].evidence).toContain('REDIS_URL');
|
|
257
|
+
// A signal appearing as a VALUE (or in a comment) must not fire.
|
|
258
|
+
expect(result.addons['queue:sqs'].detected).toBe(false);
|
|
259
|
+
expect(JSON.stringify(result)).not.toContain('s3cret-pw');
|
|
260
|
+
expect(JSON.stringify(result)).not.toContain('db.internal');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('detectProjectCapabilities: worker, migration, and hints', () => {
|
|
265
|
+
it('suggests npm run commands from worker-ish script names and bodies', () => {
|
|
266
|
+
const byName = makeTmp();
|
|
267
|
+
writeJson(byName, 'package.json', { scripts: { start: 'node index.js', 'queue:work': 'node queue.js' } });
|
|
268
|
+
const named = detectProjectCapabilities(byName);
|
|
269
|
+
expect(named.worker.detected).toBe(true);
|
|
270
|
+
expect(named.worker.suggestedCommand).toBe('npm run queue:work');
|
|
271
|
+
|
|
272
|
+
const byBody = makeTmp();
|
|
273
|
+
writeJson(byBody, 'package.json', { scripts: { jobs: 'node bull-board.js' } });
|
|
274
|
+
expect(detectProjectCapabilities(byBody).worker.suggestedCommand).toBe('npm run jobs');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('detects worker deps, Procfile workers, and compose worker services', () => {
|
|
278
|
+
const dir = makeTmp();
|
|
279
|
+
writeFile(dir, 'requirements.txt', 'dramatiq\n');
|
|
280
|
+
writeFile(dir, 'Procfile', 'web: gunicorn app.wsgi\nworker: celery -A app worker\n');
|
|
281
|
+
writeFile(dir, 'docker-compose.yml', 'services:\n web:\n image: app:latest\n ports: ["3000:3000"]\n mail-worker:\n image: app:latest\n');
|
|
282
|
+
const result = detectProjectCapabilities(dir);
|
|
283
|
+
expect(result.worker.detected).toBe(true);
|
|
284
|
+
expect(result.worker.evidence).toEqual(expect.arrayContaining(['dramatiq', 'Procfile', 'docker-compose worker']));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('delegates migration detection to detectMigrationCommand', () => {
|
|
288
|
+
const dir = makeTmp();
|
|
289
|
+
writeJson(dir, 'package.json', { scripts: { 'db:migrate': 'prisma migrate deploy' } });
|
|
290
|
+
const result = detectProjectCapabilities(dir);
|
|
291
|
+
expect(result.migration.detected).toBe(true);
|
|
292
|
+
expect(result.migration.command).toContain('db:migrate');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('detects vector and cron hints across ecosystems', () => {
|
|
296
|
+
const dir = makeTmp();
|
|
297
|
+
writeFile(dir, 'requirements.txt', 'pgvector\napscheduler\n');
|
|
298
|
+
writeFile(dir, 'docker-compose.yml', 'services:\n db:\n image: pgvector/pgvector:pg16\n');
|
|
299
|
+
writeFile(dir, 'vercel.json', JSON.stringify({ crons: [{ path: '/api/cron', schedule: '0 5 * * *' }] }));
|
|
300
|
+
const result = detectProjectCapabilities(dir);
|
|
301
|
+
expect(result.upcomingHints).toEqual({ vector: true, cron: true, mysql: false });
|
|
302
|
+
|
|
303
|
+
const emptyCrons = makeTmp();
|
|
304
|
+
writeFile(emptyCrons, 'vercel.json', JSON.stringify({ crons: [] }));
|
|
305
|
+
expect(detectProjectCapabilities(emptyCrons).upcomingHints.cron).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
|
|
22
|
+
it('documents the --target chooser in help text', () => {
|
|
23
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
24
|
+
const content = fs.readFileSync(cliPath, 'utf8');
|
|
25
|
+
|
|
26
|
+
expect(content).toContain('--target <ecs|lambda>');
|
|
27
|
+
expect(content).toContain('Fargate vs Lambda');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { failCommand, failProjectNotInitialized } from '../src/utils/command.js';
|
|
3
|
+
import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
|
|
4
|
+
|
|
5
|
+
vi.mock('../src/core/telemetry.js', () => ({
|
|
6
|
+
trackEvent: vi.fn(),
|
|
7
|
+
flushTelemetry: vi.fn().mockResolvedValue(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('failCommand', () => {
|
|
11
|
+
let exitSpy;
|
|
12
|
+
let logSpy;
|
|
13
|
+
let errorSpy;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
18
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
19
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
exitSpy.mockRestore();
|
|
24
|
+
logSpy.mockRestore();
|
|
25
|
+
errorSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('prints, tracks, flushes, exits, and returns the standard result', async () => {
|
|
29
|
+
const result = await failCommand({
|
|
30
|
+
message: '\n✖ Bad input.',
|
|
31
|
+
hint: ' Fix it.',
|
|
32
|
+
event: 'add_run',
|
|
33
|
+
telemetry: { capability: 'x', error_code: 'BAD' },
|
|
34
|
+
reason: 'bad-input',
|
|
35
|
+
resultExtra: { capability: 'x' },
|
|
36
|
+
});
|
|
37
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Bad input.'));
|
|
38
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Fix it.'));
|
|
39
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
40
|
+
capability: 'x',
|
|
41
|
+
error_code: 'BAD',
|
|
42
|
+
success: false,
|
|
43
|
+
});
|
|
44
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
45
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
46
|
+
expect(result).toEqual({ ok: false, reason: 'bad-input', capability: 'x' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('supports custom printers, error stream, and tones', async () => {
|
|
50
|
+
const print = vi.fn();
|
|
51
|
+
await failCommand({ print, exitCode: 2 });
|
|
52
|
+
expect(print).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
54
|
+
expect(trackEvent).not.toHaveBeenCalled();
|
|
55
|
+
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
56
|
+
|
|
57
|
+
await failCommand({ message: 'warn', tone: 'yellow', useErrorStream: true, exitCode: null });
|
|
58
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('warn'));
|
|
59
|
+
expect(exitSpy).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('skips telemetry, exit, and reason when omitted', async () => {
|
|
63
|
+
const result = await failCommand({ message: 'soft', exitCode: null });
|
|
64
|
+
expect(trackEvent).not.toHaveBeenCalled();
|
|
65
|
+
expect(flushTelemetry).not.toHaveBeenCalled();
|
|
66
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
67
|
+
expect(result).toEqual({ ok: false });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('stamps errorCode and merges extra into telemetry', async () => {
|
|
71
|
+
await failCommand({
|
|
72
|
+
message: 'failed',
|
|
73
|
+
event: 'db_migrate_run',
|
|
74
|
+
telemetry: { cmd_source: 'explicit' },
|
|
75
|
+
errorCode: 'MIGRATION_TASK_FAILED',
|
|
76
|
+
extra: { exit_code: 3 },
|
|
77
|
+
reason: 'migration-task-failed',
|
|
78
|
+
exitCode: 3,
|
|
79
|
+
});
|
|
80
|
+
expect(trackEvent).toHaveBeenCalledWith('db_migrate_run', {
|
|
81
|
+
cmd_source: 'explicit',
|
|
82
|
+
error_code: 'MIGRATION_TASK_FAILED',
|
|
83
|
+
exit_code: 3,
|
|
84
|
+
success: false,
|
|
85
|
+
});
|
|
86
|
+
expect(exitSpy).toHaveBeenCalledWith(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('failProjectNotInitialized emits the structured not-initialized failure', async () => {
|
|
90
|
+
const result = await failProjectNotInitialized({ event: 'logs_streamed' });
|
|
91
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
92
|
+
expect(trackEvent).toHaveBeenCalledWith('logs_streamed', {
|
|
93
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
94
|
+
success: false,
|
|
95
|
+
});
|
|
96
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
97
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
98
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Could not determine the project'));
|
|
99
|
+
});
|
|
100
|
+
});
|