grada-run 0.0.1 → 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.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -6
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,496 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ parseTerraformConfig,
7
+ estimateMonthlyCost,
8
+ renderDryRunPreview,
9
+ syncDocCostEstimate,
10
+ buildCostTelemetryProps,
11
+ buildBaselineLine,
12
+ COST_ESTIMATE_MARKER,
13
+ } from '../src/utils/visualizer.js';
14
+ import { ADDON_REGISTRY } from '../src/utils/addons.js';
15
+ import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
16
+ import { confirm } from '@clack/prompts';
17
+
18
+ const { mockNote } = vi.hoisted(() => ({ mockNote: vi.fn() }));
19
+
20
+ vi.mock('@clack/prompts', () => ({
21
+ note: mockNote,
22
+ confirm: vi.fn(),
23
+ isCancel: (value) => typeof value === 'symbol',
24
+ cancel: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('../src/core/telemetry.js', () => {
28
+ const trackEvent = vi.fn();
29
+ const flushTelemetry = vi.fn().mockResolvedValue();
30
+ // Mirrors the real trackFailure delegation so failure-path assertions
31
+ // keep observing trackEvent (the real helper is unit-tested separately).
32
+ const trackFailure = vi.fn(async (event, properties) => {
33
+ trackEvent(event, { ...properties, success: false });
34
+ await flushTelemetry();
35
+ });
36
+ return { trackEvent, flushTelemetry, trackFailure };
37
+ });
38
+
39
+ let tmpDirs = [];
40
+
41
+ function makeTmp() {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'visualizer-test-'));
43
+ tmpDirs.push(dir);
44
+ return dir;
45
+ }
46
+
47
+ function writeTf(dir, files = {}) {
48
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
49
+ for (const [name, content] of Object.entries(files)) {
50
+ fs.writeFileSync(path.join(dir, 'terraform', name), content);
51
+ }
52
+ }
53
+
54
+ const MAIN_TF_MICRO = [
55
+ 'resource "aws_ecs_task_definition" "app" {',
56
+ ' cpu = "256"',
57
+ ' memory = "512"',
58
+ '}',
59
+ '',
60
+ ].join('\n');
61
+
62
+ const MAIN_TF_SMALL = [
63
+ 'resource "aws_ecs_task_definition" "app" {',
64
+ ' cpu = "512"',
65
+ ' memory = "1024"',
66
+ '}',
67
+ '',
68
+ ].join('\n');
69
+
70
+ // picocolors wraps words in ANSI escapes; strip them before asserting text.
71
+ function stripAnsi(text) {
72
+ return String(text).replace(/\[[0-9;]*m/g, '');
73
+ }
74
+
75
+ beforeEach(() => {
76
+ tmpDirs = [];
77
+ vi.clearAllMocks();
78
+ });
79
+
80
+ afterEach(() => {
81
+ for (const dir of tmpDirs) {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ describe('parseTerraformConfig', () => {
87
+ it('extracts rendered cpu and memory from main.tf', () => {
88
+ const dir = makeTmp();
89
+ writeTf(dir, { 'main.tf': MAIN_TF_SMALL });
90
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
91
+ expect(config.cpu).toBe(512);
92
+ expect(config.memory).toBe(1024);
93
+ });
94
+
95
+ it('lets terraform.tfvars override rendered values', () => {
96
+ const dir = makeTmp();
97
+ writeTf(dir, {
98
+ 'main.tf': MAIN_TF_SMALL,
99
+ 'terraform.tfvars': 'container_cpu = 1024\ncontainer_memory = 2048\n',
100
+ });
101
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
102
+ expect(config.cpu).toBe(1024);
103
+ expect(config.memory).toBe(2048);
104
+ });
105
+
106
+ it('falls back to micro defaults when neither source specifies sizes', () => {
107
+ const dir = makeTmp();
108
+ writeTf(dir, { 'main.tf': 'provider "aws" {}\n' });
109
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
110
+ expect(config.cpu).toBe(256);
111
+ expect(config.memory).toBe(512);
112
+ });
113
+
114
+ it('detects secrets.tf existence and registry addons', () => {
115
+ const dir = makeTmp();
116
+ writeTf(dir, { 'main.tf': MAIN_TF_MICRO, 'secrets.tf': '# secrets\n', 's3.tf': '# s3\n', 'dynamodb.tf': '# dynamodb\n' });
117
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
118
+ expect(config.hasSecrets).toBe(true);
119
+ expect(config.addons).toEqual(['storage:s3', 'db:dynamodb']);
120
+ });
121
+
122
+ it('reports hasSecrets false and empty addons when files are absent', () => {
123
+ const dir = makeTmp();
124
+ writeTf(dir, { 'main.tf': MAIN_TF_MICRO });
125
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
126
+ expect(config.hasSecrets).toBe(false);
127
+ expect(config.addons).toEqual([]);
128
+ });
129
+
130
+ it.each([
131
+ ['postgres instance', 'resource "aws_db_instance" "postgres" {\n engine = "postgres"\n}\n', 'postgres'],
132
+ ['mysql instance', 'resource "aws_db_instance" "postgres" {\n engine = "mysql"\n}\n', 'mysql'],
133
+ ['aurora cluster', 'resource "aws_rds_cluster" "postgres" {\n engine = "aurora-postgresql"\n}\n', 'aurora-postgresql'],
134
+ ])('detects dbEngine for %s', (_, databaseTf, expected) => {
135
+ const dir = makeTmp();
136
+ writeTf(dir, { 'main.tf': MAIN_TF_MICRO, 'database.tf': databaseTf });
137
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
138
+ expect(config.hasDb).toBe(true);
139
+ expect(config.dbEngine).toBe(expected);
140
+ });
141
+
142
+ it('defaults dbEngine to postgres without a database', () => {
143
+ const dir = makeTmp();
144
+ writeTf(dir, { 'main.tf': MAIN_TF_MICRO });
145
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
146
+ expect(config.hasDb).toBe(false);
147
+ expect(config.dbEngine).toBe('postgres');
148
+ });
149
+ });
150
+
151
+ describe('estimateMonthlyCost database engines', () => {
152
+ it.each([
153
+ ['postgres', '13.98'],
154
+ ['mysql', '13.98'],
155
+ ])('bills the managed-instance rate for %s', (dbEngine, expected) => {
156
+ const cost = estimateMonthlyCost({ hasDb: true, dbEngine });
157
+ expect(cost.dbMonthly).toBe(expected);
158
+ });
159
+
160
+ it('bills $0/mo idle compute for aurora-postgresql', () => {
161
+ const cost = estimateMonthlyCost({ hasDb: true, dbEngine: 'aurora-postgresql' });
162
+ expect(cost.dbMonthly).toBe('0.00');
163
+ });
164
+ });
165
+
166
+ describe('estimateMonthlyCost', () => {
167
+ it('adds $0.40 for the base app secret', () => {
168
+ const cost = estimateMonthlyCost({ hasSecrets: true, hasDb: false });
169
+ expect(cost.secretsMonthly).toBe('0.40');
170
+ });
171
+
172
+ it('adds $0.80 when the RDS master password secret also exists', () => {
173
+ const cost = estimateMonthlyCost({ hasSecrets: true, hasDb: true });
174
+ expect(cost.secretsMonthly).toBe('0.80');
175
+ });
176
+
177
+ it('charges $0.00 secrets with no secrets file and no database', () => {
178
+ const cost = estimateMonthlyCost({ hasSecrets: false, hasDb: false });
179
+ expect(cost.secretsMonthly).toBe('0.00');
180
+ });
181
+
182
+ it('keeps the object return shape with two-decimal strings', () => {
183
+ const cost = estimateMonthlyCost({});
184
+ for (const key of ['fargateMonthly', 'albMonthly', 'dbMonthly', 'secretsMonthly', 'totalMonthly']) {
185
+ expect(cost[key]).toMatch(/^\d+\.\d{2}$/);
186
+ }
187
+ });
188
+
189
+ it('ignores unknown addon keys instead of throwing', () => {
190
+ expect(() => estimateMonthlyCost({ addons: ['unknown:foo'] })).not.toThrow();
191
+ });
192
+ });
193
+
194
+ describe('renderDryRunPreview', () => {
195
+ it('labels the fixed baseline with the us-east-2 caveat and secrets breakdown', async () => {
196
+ await renderDryRunPreview({ hasSecrets: true, hasDb: true }, true);
197
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
198
+ expect(output).toContain('Fixed Baseline:');
199
+ expect(output).not.toContain('Est. Fixed Baseline:');
200
+ expect(output).toContain('(Fargate: $');
201
+ expect(output).toContain('[Secrets Manager (2 secrets)]');
202
+ expect(output).toContain(', Secrets: $0.80');
203
+ expect(output).toContain('(us-east-2 rates)');
204
+ expect(output).not.toContain('Est. Monthly Cost:');
205
+ });
206
+
207
+ it('omits the Secrets Manager node when there are no secrets', async () => {
208
+ await renderDryRunPreview({ hasSecrets: false, hasDb: false }, true);
209
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
210
+ expect(output).not.toContain('Secrets Manager');
211
+ expect(output).not.toContain('Secrets: $');
212
+ });
213
+
214
+ it('renders addon tree nodes with a compact counted usage line', async () => {
215
+ await renderDryRunPreview({ hasSecrets: true, addons: ['storage:s3', 'db:dynamodb'] }, true);
216
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
217
+ expect(output).toContain('[S3 + CloudFront OAC]');
218
+ expect(output).toContain('[DynamoDB (On-Demand + PITR)]');
219
+ expect(output).toContain('+ Usage-based (2 addons): $0/mo fixed');
220
+ // Verbose per-addon summaries stay out of the box (no repeated lists).
221
+ expect(output).not.toContain('Usage-Based Addons:');
222
+ expect(output).not.toContain(ADDON_REGISTRY['storage:s3'].cost.summary);
223
+ expect(output).not.toContain(ADDON_REGISTRY['db:dynamodb'].cost.summary);
224
+ });
225
+
226
+ it('uses the singular form for a single addon', async () => {
227
+ await renderDryRunPreview({ addons: ['storage:s3'] }, true);
228
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
229
+ expect(output).toContain('+ Usage-based (1 addon):');
230
+ });
231
+
232
+ it.each([
233
+ ['postgres', '🐘 Amazon RDS (PostgreSQL managed instance)', 'RDS: $13.98'],
234
+ ['mysql', '🐬 Amazon RDS (MySQL managed instance)', 'RDS: $13.98'],
235
+ ['aurora-postgresql', '✨ Amazon Aurora PostgreSQL (Serverless v2 · 0–2 ACU scale-to-zero)', 'RDS: $0.00'],
236
+ ])('renders the %s database node and cost part', async (dbEngine, label, part) => {
237
+ await renderDryRunPreview({ hasDb: true, dbEngine }, true);
238
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
239
+ expect(output).toContain(label);
240
+ expect(output).toContain(part);
241
+ });
242
+
243
+ it('fits the maximal box inside the IDE viewport budget', async () => {
244
+ await renderDryRunPreview(
245
+ { hasDb: true, hasWorker: true, hasSecrets: true, addons: ['storage:s3', 'db:dynamodb'] },
246
+ true
247
+ );
248
+ const lines = stripAnsi(mockNote.mock.calls[0][0]).split('\n');
249
+ expect(lines.length).toBeLessThanOrEqual(14);
250
+ for (const line of lines) {
251
+ expect(line.length).toBeLessThanOrEqual(90);
252
+ }
253
+ });
254
+
255
+ it('skips unknown addon keys instead of throwing', async () => {
256
+ await expect(renderDryRunPreview({ addons: ['unknown:foo'] }, true)).resolves.toBe(true);
257
+ });
258
+
259
+ it('suppresses the usage line when only fixed-baseline addons are active', async () => {
260
+ await renderDryRunPreview({ addons: ['db:redis'] }, true);
261
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
262
+ expect(output).toContain('[ElastiCache Valkey 8.0]');
263
+ expect(output).toContain('Addons: $9.49');
264
+ expect(output).not.toContain('+ Usage-based');
265
+ });
266
+
267
+ it('counts only usage-based addons in the usage line', async () => {
268
+ await renderDryRunPreview({ addons: ['db:redis', 'storage:s3'] }, true);
269
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
270
+ expect(output).toContain('+ Usage-based (1 addon):');
271
+ expect(output).toContain('Addons: $9.49');
272
+ });
273
+
274
+ it('collapses three or more addons into a single tree line', async () => {
275
+ await renderDryRunPreview({ addons: ['storage:s3', 'db:dynamodb', 'db:redis'] }, true);
276
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
277
+ expect(output).toContain('[Addons (3): storage:s3, db:dynamodb, db:redis]');
278
+ expect(output).not.toContain('[S3 + CloudFront OAC]');
279
+ expect(output).not.toContain('[ElastiCache Valkey 8.0]');
280
+ });
281
+
282
+ it('fits the full five-addon maximal box inside the viewport budget', async () => {
283
+ await renderDryRunPreview(
284
+ {
285
+ hasDb: true,
286
+ hasWorker: true,
287
+ hasSecrets: true,
288
+ addons: ['storage:s3', 'db:dynamodb', 'db:redis', 'queue:sqs', 'ai:bedrock'],
289
+ },
290
+ true
291
+ );
292
+ const lines = stripAnsi(mockNote.mock.calls[0][0]).split('\n');
293
+ expect(lines.length).toBeLessThanOrEqual(14);
294
+ for (const line of lines) {
295
+ expect(line.length).toBeLessThanOrEqual(90);
296
+ }
297
+ const output = lines.join('\n');
298
+ expect(output).toContain('[Addons (5):');
299
+ expect(output).toContain('+ Usage-based (4 addons):');
300
+ });
301
+ });
302
+
303
+ describe('buildBaselineLine', () => {
304
+ it('keeps the full breakdown when it fits in 90 columns', () => {
305
+ const line = stripAnsi(buildBaselineLine('31.28', ['Fargate: $9.01', 'ALB: $22.27'], 0, 0));
306
+ expect(line).toBe('Fixed Baseline: ~$31.28/mo (Fargate: $9.01, ALB: $22.27)');
307
+ });
308
+
309
+ it('folds Secrets and Addons into +$X other when over budget', () => {
310
+ const line = stripAnsi(buildBaselineLine(
311
+ '50.66',
312
+ ['Fargate: $9.01', 'ALB: $22.27', 'RDS: $13.98', 'Secrets: $0.80', 'Addons: $9.49'],
313
+ 0.8,
314
+ 9.49
315
+ ));
316
+ expect(line.length).toBeLessThanOrEqual(90);
317
+ expect(line).toContain('Fargate: $9.01');
318
+ expect(line).toContain('+$10.29 other');
319
+ expect(line).not.toContain('Secrets: $0.80');
320
+ });
321
+
322
+ it('appends a usage suffix without a comma separator', () => {
323
+ const line = stripAnsi(buildBaselineLine('0.80', ['RDS: $0.00', 'Secrets: $0.80'], 0.8, 0, '+ API GW & Lambda usage'));
324
+ expect(line).toBe('Fixed Baseline: ~$0.80/mo (RDS: $0.00, Secrets: $0.80 + API GW & Lambda usage)');
325
+ });
326
+
327
+ it('renders a bare breakdown as just the suffix', () => {
328
+ const line = stripAnsi(buildBaselineLine('0.00', [], 0, 0, '+ API GW & Lambda usage'));
329
+ expect(line).toBe('Fixed Baseline: ~$0.00/mo (+ API GW & Lambda usage)');
330
+ });
331
+ });
332
+
333
+ describe('fixed-baseline addon costs', () => {
334
+ it('adds monthlyFixed to the total while preserving the return shape', () => {
335
+ const cost = estimateMonthlyCost({ addons: ['db:redis'] });
336
+ for (const key of ['fargateMonthly', 'albMonthly', 'dbMonthly', 'secretsMonthly', 'totalMonthly']) {
337
+ expect(cost[key]).toMatch(/^\d+\.\d{2}$/);
338
+ }
339
+ const base = estimateMonthlyCost({ addons: [] });
340
+ expect(Number(cost.totalMonthly) - Number(base.totalMonthly)).toBeCloseTo(9.49, 2);
341
+ });
342
+
343
+ it('detects the new addon files from the registry', () => {
344
+ const dir = makeTmp();
345
+ writeTf(dir, { 'main.tf': MAIN_TF_MICRO, 'redis.tf': '# redis\n', 'sqs.tf': '# sqs\n', 'bedrock.tf': '# bedrock\n' });
346
+ const config = parseTerraformConfig(path.join(dir, 'terraform'));
347
+ expect(config.addons).toEqual(['db:redis', 'queue:sqs', 'ai:bedrock']);
348
+ });
349
+ });
350
+
351
+ describe('buildCostTelemetryProps', () => {
352
+ it('returns the shared numeric cost/shape payload', () => {
353
+ const props = buildCostTelemetryProps(
354
+ { projectName: 'myapp', cpu: 512, memory: 1024, hasDb: true, hasWorker: true, addons: ['storage:s3'] },
355
+ { totalMonthly: '58.07' }
356
+ );
357
+ expect(props).toEqual({
358
+ projectName: 'myapp',
359
+ estimated_monthly_usd: 58.07,
360
+ compute_target: 'ecs',
361
+ cpu: 512,
362
+ memory: 1024,
363
+ has_db: true,
364
+ db_engine: 'postgres',
365
+ has_worker: true,
366
+ addons: ['storage:s3'],
367
+ addon_count: 1,
368
+ });
369
+ });
370
+
371
+ it('falls back to basename, micro defaults, and empty addons', () => {
372
+ const props = buildCostTelemetryProps({}, { totalMonthly: '31.28' });
373
+ expect(props.projectName).toBe(path.basename(process.cwd()));
374
+ expect(props.cpu).toBe(256);
375
+ expect(props.memory).toBe(512);
376
+ expect(props.addons).toEqual([]);
377
+ expect(props.addon_count).toBe(0);
378
+ });
379
+ });
380
+
381
+ describe('preview cancel telemetry (real decline path)', () => {
382
+ let exitSpy;
383
+
384
+ beforeEach(() => {
385
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
386
+ });
387
+
388
+ afterEach(() => {
389
+ exitSpy.mockRestore();
390
+ });
391
+
392
+ it('emits cancelled_at_preview with cost props when confirm is declined', async () => {
393
+ vi.mocked(confirm).mockResolvedValue(false);
394
+ await renderDryRunPreview({ projectName: 'myapp', hasSecrets: true }, false);
395
+ expect(trackEvent).toHaveBeenCalledWith(
396
+ 'infrastructure_applied',
397
+ expect.objectContaining({
398
+ success: false,
399
+ status: 'cancelled_at_preview',
400
+ projectName: 'myapp',
401
+ estimated_monthly_usd: expect.any(Number),
402
+ })
403
+ );
404
+ expect(flushTelemetry).toHaveBeenCalled();
405
+ expect(exitSpy).toHaveBeenCalledWith(0);
406
+ });
407
+
408
+ it('emits the same event when the prompt is cancelled via symbol', async () => {
409
+ vi.mocked(confirm).mockResolvedValue(Symbol('clack:cancel'));
410
+ await renderDryRunPreview({ projectName: 'myapp' }, false);
411
+ expect(trackEvent).toHaveBeenCalledWith(
412
+ 'infrastructure_applied',
413
+ expect.objectContaining({ success: false, status: 'cancelled_at_preview' })
414
+ );
415
+ expect(exitSpy).toHaveBeenCalledWith(0);
416
+ });
417
+ });
418
+
419
+ describe('templates/README.md marker', () => {
420
+ it('literally contains COST_ESTIMATE_MARKER so template and constant cannot drift', () => {
421
+ const template = fs.readFileSync(
422
+ path.join(process.cwd(), 'templates', 'README.md'),
423
+ 'utf-8'
424
+ );
425
+ expect(template).toContain(COST_ESTIMATE_MARKER);
426
+ });
427
+ });
428
+
429
+ describe('syncDocCostEstimate', () => {
430
+ it('preserves non-default CPU/memory from main.tf in the synced baseline', () => {
431
+ const dir = makeTmp();
432
+ writeTf(dir, { 'main.tf': MAIN_TF_SMALL, 'secrets.tf': '# secrets\n' });
433
+ fs.writeFileSync(path.join(dir, 'README.md'), `# myapp\n\n* **${COST_ESTIMATE_MARKER}** ~$0.00/month\n`);
434
+ const target = syncDocCostEstimate(dir);
435
+ expect(target).toBe(path.join(dir, 'README.md'));
436
+ const expected = estimateMonthlyCost(parseTerraformConfig(path.join(dir, 'terraform')));
437
+ expect(fs.readFileSync(target, 'utf-8')).toContain(`~$${expected.totalMonthly}/month`);
438
+ });
439
+ });
440
+
441
+ const MAIN_TF_LAMBDA = [
442
+ 'resource "aws_lambda_function" "app" {',
443
+ ' function_name = "myapp-fn"',
444
+ ' memory_size = 512',
445
+ '}',
446
+ '',
447
+ ].join('\n');
448
+
449
+ describe('lambda compute target', () => {
450
+ it('detects computeTarget from main.tf content', () => {
451
+ const lambdaDir = makeTmp();
452
+ writeTf(lambdaDir, { 'main.tf': MAIN_TF_LAMBDA });
453
+ expect(parseTerraformConfig(path.join(lambdaDir, 'terraform')).computeTarget).toBe('lambda');
454
+
455
+ const ecsDir = makeTmp();
456
+ writeTf(ecsDir, { 'main.tf': MAIN_TF_MICRO });
457
+ expect(parseTerraformConfig(path.join(ecsDir, 'terraform')).computeTarget).toBe('ecs');
458
+
459
+ expect(parseTerraformConfig(path.join(makeTmp(), 'terraform')).computeTarget).toBe('ecs');
460
+ });
461
+
462
+ it('prices lambda compute and API Gateway at $0 fixed baseline', () => {
463
+ const bare = estimateMonthlyCost({ hasSecrets: true, computeTarget: 'lambda' });
464
+ expect(bare.fargateMonthly).toBe('0.00');
465
+ expect(bare.albMonthly).toBe('0.00');
466
+ expect(bare.totalMonthly).toBe('0.40');
467
+
468
+ const aurora = estimateMonthlyCost({ hasDb: true, dbEngine: 'aurora-postgresql', hasSecrets: true, computeTarget: 'lambda' });
469
+ expect(aurora.dbMonthly).toBe('0.00');
470
+ expect(aurora.totalMonthly).toBe('0.80');
471
+
472
+ // ECS pricing is unchanged by the new parameter default.
473
+ const ecs = estimateMonthlyCost({ hasSecrets: true });
474
+ expect(ecs.fargateMonthly).not.toBe('0.00');
475
+ expect(ecs.albMonthly).not.toBe('0.00');
476
+ });
477
+
478
+ it('renders the serverless topology and secrets-only baseline', async () => {
479
+ await renderDryRunPreview(
480
+ { framework: 'Node.js', hasSecrets: true, hasDb: true, dbEngine: 'aurora-postgresql', computeTarget: 'lambda' },
481
+ true
482
+ );
483
+ const output = stripAnsi(mockNote.mock.calls[0][0]);
484
+ expect(output).toContain('API Gateway HTTP API v2');
485
+ expect(output).toContain('AWS Lambda Web Service');
486
+ expect(output).toContain('512 MB · Scale-to-zero');
487
+ expect(output).not.toContain('ALB');
488
+ expect(output).not.toContain('ECS Web Service');
489
+ expect(output).toContain('Fixed Baseline: ~$0.80/mo (RDS: $0.00, Secrets: $0.80 + API GW & Lambda usage)');
490
+ });
491
+
492
+ it('reports compute_target in cost telemetry', () => {
493
+ const props = buildCostTelemetryProps({ projectName: 'myapp', computeTarget: 'lambda' }, { totalMonthly: '0.40' });
494
+ expect(props.compute_target).toBe('lambda');
495
+ });
496
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ env: {
6
+ FORCE_COLOR: '1',
7
+ },
8
+ },
9
+ });
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- console.log("Grada CLI (https://grada.run) — migrating from deploy-stack. Full release landing shortly.");