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.
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 -7
  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,243 @@
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 { load as loadYaml } from 'js-yaml';
6
+ import {
7
+ runDrift,
8
+ parseDriftArgs,
9
+ extractRoleArn,
10
+ extractPlanSummary,
11
+ scaffoldDriftWorkflow,
12
+ } from '../src/commands/drift.js';
13
+ import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
14
+
15
+ vi.mock('@clack/prompts', () => ({
16
+ intro: vi.fn(),
17
+ outro: vi.fn(),
18
+ spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
19
+ }));
20
+
21
+ vi.mock('../src/core/telemetry.js', () => {
22
+ const trackEvent = vi.fn();
23
+ const flushTelemetry = vi.fn().mockResolvedValue();
24
+ const trackSuccess = vi.fn(async (event, properties) => {
25
+ trackEvent(event, { ...properties, success: true });
26
+ await flushTelemetry();
27
+ });
28
+ const trackFailure = vi.fn(async (event, properties) => {
29
+ trackEvent(event, { ...properties, success: false });
30
+ await flushTelemetry();
31
+ });
32
+ return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
33
+ });
34
+
35
+ let tmpDirs = [];
36
+ let exitSpy;
37
+ let logSpy;
38
+
39
+ function makeTmp() {
40
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-test-'));
41
+ tmpDirs.push(dir);
42
+ return dir;
43
+ }
44
+
45
+ function writeDeployYml(dir, roleArn = 'arn:aws:iam::123456789012:role/myapp-github-actions-role') {
46
+ const workflows = path.join(dir, '.github', 'workflows');
47
+ fs.mkdirSync(workflows, { recursive: true });
48
+ fs.writeFileSync(
49
+ path.join(workflows, 'deploy.yml'),
50
+ `name: Deploy\njobs:\n deploy:\n steps:\n - uses: anton-codes-iac/deploy-stack-action@v1\n with:\n role-to-assume: ${roleArn}\n`
51
+ );
52
+ }
53
+
54
+ function writeMainTf(dir, region = 'us-east-2') {
55
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
56
+ fs.writeFileSync(
57
+ path.join(dir, 'terraform', 'main.tf'),
58
+ `provider "aws" {\n region = "${region}"\n}\nlocals {\n app_name = "myapp\${local.env_suffix}"\n}\n`
59
+ );
60
+ }
61
+
62
+ beforeEach(() => {
63
+ tmpDirs = [];
64
+ vi.clearAllMocks();
65
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
66
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
67
+ });
68
+
69
+ afterEach(() => {
70
+ exitSpy.mockRestore();
71
+ logSpy.mockRestore();
72
+ for (const dir of tmpDirs) {
73
+ fs.rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ describe('parseDriftArgs', () => {
78
+ it('parses flags and treats drift init as --setup', () => {
79
+ expect(parseDriftArgs(['drift', '--setup', '--region', 'eu-west-1'])).toMatchObject({ setup: true, region: 'eu-west-1' });
80
+ expect(parseDriftArgs(['drift', 'init']).setup).toBe(true);
81
+ expect(parseDriftArgs(['drift']).setup).toBeUndefined();
82
+ });
83
+
84
+ it('collects unexpected positionals', () => {
85
+ expect(parseDriftArgs(['drift', 'bogus']).unexpectedPositionals).toEqual(['bogus']);
86
+ });
87
+ });
88
+
89
+ describe('extractRoleArn / extractPlanSummary', () => {
90
+ it('finds the OIDC role ARN in deploy.yml', () => {
91
+ expect(extractRoleArn(' role-to-assume: arn:aws:iam::1:role/x # comment')).toBe('arn:aws:iam::1:role/x');
92
+ expect(extractRoleArn('no anchor here')).toBeNull();
93
+ });
94
+
95
+ it('condenses plan output to changes plus the summary', () => {
96
+ const output = [
97
+ 'random log line',
98
+ '# aws_lb.main will be updated',
99
+ ' ~ dns_name = "a" -> "b"',
100
+ ' + new = true',
101
+ ' - old = true',
102
+ 'Plan: 0 to add, 1 to change, 0 to destroy.',
103
+ ].join('\n');
104
+ const summary = extractPlanSummary(output);
105
+ expect(summary).not.toContain('random log line');
106
+ expect(summary).toContain('# aws_lb.main will be updated');
107
+ expect(summary).toContain('Plan: 0 to add, 1 to change, 0 to destroy.');
108
+ });
109
+
110
+ it('caps long summaries', () => {
111
+ const output = Array.from({ length: 100 }, (_, i) => ` + line${i} = true`).join('\n');
112
+ const summary = extractPlanSummary(output);
113
+ expect(summary.split('\n')).toHaveLength(61);
114
+ expect(summary).toContain('more lines');
115
+ });
116
+ });
117
+
118
+ describe('scaffoldDriftWorkflow', () => {
119
+ it('renders region and role ARN into valid workflow YAML', () => {
120
+ const dir = makeTmp();
121
+ const result = scaffoldDriftWorkflow(dir, { region: 'eu-west-1', roleArn: 'arn:aws:iam::1:role/x' });
122
+ expect(result.ok).toBe(true);
123
+ const content = fs.readFileSync(path.join(dir, '.github', 'workflows', 'drift.yml'), 'utf8');
124
+ expect(content).toContain('AWS_REGION: eu-west-1');
125
+ expect(content).toContain('role-to-assume: arn:aws:iam::1:role/x');
126
+ expect(content).not.toContain('{{REGION}}');
127
+ expect(content).not.toContain('{{ROLE_ARN}}');
128
+ const parsed = loadYaml(content);
129
+ expect(parsed.on.schedule).toEqual([{ cron: '0 6 * * *' }]);
130
+ expect(parsed.on).toHaveProperty('workflow_dispatch');
131
+ expect(parsed.permissions).toMatchObject({ 'id-token': 'write', contents: 'read', issues: 'write' });
132
+ const steps = parsed.jobs.drift.steps;
133
+ expect(steps.some((step) => step.uses === 'actions/github-script@v7')).toBe(true);
134
+ expect(steps.some((step) => step.with?.terraform_wrapper === false)).toBe(true);
135
+ });
136
+
137
+ it('refuses to overwrite without --force', () => {
138
+ const dir = makeTmp();
139
+ scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/x' });
140
+ expect(scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/y' }).reason).toBe('exists');
141
+ const forced = scaffoldDriftWorkflow(dir, { region: 'us-east-2', roleArn: 'arn:aws:iam::1:role/y', force: true });
142
+ expect(forced.ok).toBe(true);
143
+ expect(fs.readFileSync(path.join(dir, '.github', 'workflows', 'drift.yml'), 'utf8')).toContain('role/y');
144
+ });
145
+ });
146
+
147
+ describe('runDrift --setup', () => {
148
+ it('scaffolds from the deploy workflow role ARN', async () => {
149
+ const dir = makeTmp();
150
+ writeDeployYml(dir);
151
+ writeMainTf(dir);
152
+ const result = await runDrift({ cwd: dir, setup: true });
153
+ expect(result.ok).toBe(true);
154
+ expect(result.file).toBe(path.join('.github', 'workflows', 'drift.yml'));
155
+ expect(fs.existsSync(path.join(dir, '.github', 'workflows', 'drift.yml'))).toBe(true);
156
+ expect(trackEvent).toHaveBeenCalledWith('drift_run', expect.objectContaining({ action: 'setup', success: true }));
157
+ expect(exitSpy).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('soft-fails when the workflow exists without --force', async () => {
161
+ const dir = makeTmp();
162
+ writeDeployYml(dir);
163
+ writeMainTf(dir);
164
+ await runDrift({ cwd: dir, setup: true });
165
+ const second = await runDrift({ cwd: dir, setup: true });
166
+ expect(second).toMatchObject({ ok: false, reason: 'drift-workflow-exists' });
167
+ expect(exitSpy).not.toHaveBeenCalled();
168
+ const forced = await runDrift({ cwd: dir, setup: true, force: true });
169
+ expect(forced.ok).toBe(true);
170
+ });
171
+
172
+ it('fails without a deploy workflow or role ARN', async () => {
173
+ const missing = makeTmp();
174
+ writeMainTf(missing);
175
+ const noWorkflow = await runDrift({ cwd: missing, setup: true });
176
+ expect(noWorkflow.reason).toBe('workflow-not-found');
177
+
178
+ const noArn = makeTmp();
179
+ writeMainTf(noArn);
180
+ const workflows = path.join(noArn, '.github', 'workflows');
181
+ fs.mkdirSync(workflows, { recursive: true });
182
+ fs.writeFileSync(path.join(workflows, 'deploy.yml'), 'name: Deploy\n');
183
+ const result = await runDrift({ cwd: noArn, setup: true });
184
+ expect(result.reason).toBe('role-arn-not-found');
185
+ });
186
+ });
187
+
188
+ describe('runDrift check mode', () => {
189
+ function checkOptions(dir, planResult, initResult = { status: 0, stdout: '', stderr: '' }) {
190
+ return {
191
+ cwd: dir,
192
+ spawnSyncImpl: vi.fn((bin, args) => {
193
+ if (args[0] === 'init') return initResult;
194
+ return planResult;
195
+ }),
196
+ };
197
+ }
198
+
199
+ it('reports clean on exit code 0', async () => {
200
+ const dir = makeTmp();
201
+ writeMainTf(dir);
202
+ const result = await runDrift(checkOptions(dir, { status: 0, stdout: 'No changes.', stderr: '' }));
203
+ expect(result).toMatchObject({ ok: true, drift: false });
204
+ expect(trackEvent).toHaveBeenCalledWith('drift_run', expect.objectContaining({ action: 'check', drift: false, success: true }));
205
+ expect(exitSpy).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('reports DRIFT_DETECTED with exit code 2', async () => {
209
+ const dir = makeTmp();
210
+ writeMainTf(dir);
211
+ const result = await runDrift(checkOptions(dir, {
212
+ status: 2,
213
+ stdout: '# aws_lb.main will be updated\n ~ x = 1\nPlan: 0 to add, 1 to change, 0 to destroy.',
214
+ stderr: '',
215
+ }));
216
+ expect(result).toMatchObject({ ok: false, reason: 'drift-detected' });
217
+ expect(exitSpy).toHaveBeenCalledWith(2);
218
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
219
+ expect(output).toContain('Infrastructure drift detected');
220
+ expect(output).toContain('Plan: 0 to add, 1 to change, 0 to destroy.');
221
+ });
222
+
223
+ it('fails on exit code 1 and on init failures', async () => {
224
+ const dir = makeTmp();
225
+ writeMainTf(dir);
226
+ const failed = await runDrift(checkOptions(dir, { status: 1, stdout: '', stderr: 'Error: boom' }));
227
+ expect(failed.reason).toBe('terraform-plan-failed');
228
+
229
+ const initFailed = await runDrift(checkOptions(dir, { status: 0, stdout: '', stderr: '' }, { status: 1, stdout: '', stderr: 'init no' }));
230
+ expect(initFailed.reason).toBe('terraform-init-failed');
231
+ });
232
+
233
+ it('guides when terraform is missing or the project is uninitialized', async () => {
234
+ const dir = makeTmp();
235
+ writeMainTf(dir);
236
+ const enoent = await runDrift({ cwd: dir, spawnSyncImpl: () => ({ error: { code: 'ENOENT' } }) });
237
+ expect(enoent.reason).toBe('terraform-not-installed');
238
+
239
+ const bare = makeTmp();
240
+ const uninit = await runDrift({ cwd: bare });
241
+ expect(uninit.reason).toBe('terraform-not-initialized');
242
+ });
243
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import {
3
+ SESSION_MANAGER_PLUGIN_URL,
4
+ resolveContainer,
5
+ hasSessionManagerPlugin,
6
+ fetchActiveService,
7
+ pickRuntimeContainer,
8
+ printAwsCliGuidance,
9
+ printSessionManagerGuidance,
10
+ printNoTasksGuidance,
11
+ } from '../src/utils/ecs.js';
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ delete process.env.ECS_CONTAINER;
16
+ });
17
+
18
+ function capturedOutput(fn) {
19
+ const lines = [];
20
+ vi.spyOn(console, 'log').mockImplementation((...args) => {
21
+ lines.push(args.join(' '));
22
+ });
23
+ fn();
24
+ return lines.join('\n');
25
+ }
26
+
27
+ describe('ecs shared module', () => {
28
+ it('exposes the Session Manager plugin install URL', () => {
29
+ expect(SESSION_MANAGER_PLUGIN_URL).toContain('session-manager-working-with-install-plugin.html');
30
+ });
31
+
32
+ it('resolveContainer prefers explicit flags, then ECS_CONTAINER, then project default', () => {
33
+ expect(resolveContainer({ projectName: 'myapp' }, '/tmp')).toBe('myapp-container');
34
+ expect(resolveContainer({ projectName: 'myapp', container: ' web ' }, '/tmp')).toBe('web');
35
+ expect(resolveContainer({ projectName: 'myapp', containerName: 'api' }, '/tmp')).toBe('api');
36
+ process.env.ECS_CONTAINER = 'from-env';
37
+ expect(resolveContainer({ projectName: 'myapp' }, '/tmp')).toBe('from-env');
38
+ });
39
+
40
+ it('hasSessionManagerPlugin reports ENOENT and spawn failures as missing', () => {
41
+ const enoent = new Error('not found');
42
+ enoent.code = 'ENOENT';
43
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => { throw new Error('boom'); } })).toBe(false);
44
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({ error: enoent }) })).toBe(false);
45
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({}) })).toBe(true);
46
+ });
47
+
48
+ it('printAwsCliGuidance defaults to exec wording', () => {
49
+ const out = capturedOutput(() => printAwsCliGuidance());
50
+ expect(out).toContain('exec');
51
+ expect(out).toContain('to open a shell in your container.');
52
+ expect(out).toContain(SESSION_MANAGER_PLUGIN_URL);
53
+ });
54
+
55
+ it('printAwsCliGuidance accepts per-command wording', () => {
56
+ const out = capturedOutput(() => printAwsCliGuidance({
57
+ commandName: 'db connect',
58
+ purpose: 'to open a secure tunnel to your database',
59
+ }));
60
+ expect(out).toContain('db connect');
61
+ expect(out).toContain('to open a secure tunnel to your database.');
62
+ });
63
+
64
+ it('printSessionManagerGuidance defaults to exec wording', () => {
65
+ const out = capturedOutput(() => printSessionManagerGuidance());
66
+ expect(out).toContain('exec');
67
+ expect(out).toContain('to securely tunnel into your container.');
68
+ });
69
+
70
+ it('printSessionManagerGuidance accepts per-command wording', () => {
71
+ const out = capturedOutput(() => printSessionManagerGuidance({
72
+ commandName: 'db connect',
73
+ purpose: 'to securely tunnel to your database',
74
+ }));
75
+ expect(out).toContain('db connect');
76
+ expect(out).toContain('to securely tunnel to your database.');
77
+ });
78
+
79
+ it('printNoTasksGuidance names the service, cluster, and reason', () => {
80
+ const out = capturedOutput(() => printNoTasksGuidance('svc', 'clu'));
81
+ expect(out).toContain('svc');
82
+ expect(out).toContain('clu');
83
+ expect(out).toContain('to open an interactive shell.');
84
+ const dbOut = capturedOutput(() => printNoTasksGuidance('svc', 'clu', 'to act as a jump host for the tunnel'));
85
+ expect(dbOut).toContain('to act as a jump host for the tunnel.');
86
+ });
87
+
88
+ it('fetchActiveService returns the service only when ACTIVE', async () => {
89
+ const active = { serviceName: 's', status: 'ACTIVE' };
90
+ const seen = [];
91
+ const client = {
92
+ send: vi.fn((cmd) => {
93
+ seen.push(cmd.input);
94
+ return Promise.resolve({ services: [active] });
95
+ }),
96
+ };
97
+ await expect(fetchActiveService(client, 'c', 's')).resolves.toBe(active);
98
+ expect(seen[0]).toEqual({ cluster: 'c', services: ['s'] });
99
+
100
+ const inactive = { send: vi.fn(() => Promise.resolve({ services: [{ status: 'INACTIVE' }] })) };
101
+ await expect(fetchActiveService(inactive, 'c', 's')).resolves.toBeNull();
102
+ const missing = { send: vi.fn(() => Promise.resolve({ services: [] })) };
103
+ await expect(fetchActiveService(missing, 'c', 's')).resolves.toBeNull();
104
+ });
105
+
106
+ it('pickRuntimeContainer prefers the expected name, then RUNNING, then first', () => {
107
+ const task = {
108
+ containers: [
109
+ { name: 'redis', lastStatus: 'RUNNING', runtimeId: 'rt-redis' },
110
+ { name: 'myapp-container', lastStatus: 'RUNNING', runtimeId: 'rt-app' },
111
+ ],
112
+ };
113
+ expect(pickRuntimeContainer(task, 'myapp-container').runtimeId).toBe('rt-app');
114
+ expect(pickRuntimeContainer(task, 'missing').runtimeId).toBe('rt-redis');
115
+ expect(pickRuntimeContainer({ containers: [{ name: 'only' }] }, 'missing').name).toBe('only');
116
+ expect(pickRuntimeContainer({ containers: [] }, 'missing')).toBeNull();
117
+ });
118
+
119
+ it('pickRuntimeContainer also reads task-definition containerDefinitions', () => {
120
+ const taskDef = { containerDefinitions: [{ name: 'a' }, { name: 'b' }] };
121
+ expect(pickRuntimeContainer(taskDef, 'b').name).toBe('b');
122
+ expect(pickRuntimeContainer(taskDef, 'missing').name).toBe('a');
123
+ expect(pickRuntimeContainer({}, 'missing')).toBeNull();
124
+ });
125
+
126
+ it.each([null, undefined, 'string', 42, true, { port: 'string' }])('resolveContainer(%s) behaves like {}', (bad) => {
127
+ expect(resolveContainer(bad)).toBe(resolveContainer({}));
128
+ expect(resolveContainer(bad, 42)).toBe(resolveContainer({}));
129
+ });
130
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { stripTerraformMetadata, stripGitignoreMetadata } from '../src/commands/eject.js';
3
+
4
+ describe('eject metadata stripping', () => {
5
+ it('strips grada headers and tag blocks', () => {
6
+ const content = '# grada generated infrastructure\n'
7
+ + 'provider "aws" {\n'
8
+ + ' region = "us-east-2"\n\n'
9
+ + ' default_tags {\n'
10
+ + ' tags = {\n'
11
+ + ' ManagedBy = "grada"\n'
12
+ + ' }\n'
13
+ + ' }\n'
14
+ + '}\n';
15
+ const cleaned = stripTerraformMetadata(content);
16
+ expect(cleaned).not.toContain('grada generated infrastructure');
17
+ expect(cleaned).not.toContain('ManagedBy');
18
+ expect(cleaned).toContain('provider "aws"');
19
+ expect(cleaned).toContain('region = "us-east-2"');
20
+ });
21
+
22
+ it('strips legacy deploy-stack headers, tag blocks, and lambda suffixes', () => {
23
+ const content = '# deploy-stack generated infrastructure (Lambda target)\n'
24
+ + 'provider "aws" {\n'
25
+ + ' default_tags {\n'
26
+ + ' tags = {\n'
27
+ + ' ManagedBy = "deploy-stack"\n'
28
+ + ' }\n'
29
+ + ' }\n'
30
+ + '}\n';
31
+ const cleaned = stripTerraformMetadata(content);
32
+ expect(cleaned).not.toContain('deploy-stack');
33
+ expect(cleaned).not.toContain('Lambda target');
34
+ expect(cleaned).toContain('provider "aws"');
35
+ });
36
+
37
+ it.each([
38
+ ['grada', '# grada addon: storage:s3\n# Generated by `grada add storage:s3`. Do not edit by hand unless\n'],
39
+ ['legacy', '# deploy-stack addon: storage:s3\n# Generated by `deploy-stack add storage:s3`. Do not edit by hand unless\n'],
40
+ ])('strips %s addon headers but keeps billing docs', (_label, header) => {
41
+ const content = `${header}# you know what you are doing — re-running with --force overwrites this file.\n`
42
+ + '#\n# Billing: usage-billed.\n\nresource "aws_s3_bucket" "x" {}\n';
43
+ const cleaned = stripTerraformMetadata(content);
44
+ expect(cleaned).not.toContain('addon: storage:s3');
45
+ expect(cleaned).not.toContain('Generated by');
46
+ expect(cleaned).not.toContain('overwrites this file');
47
+ expect(cleaned).toContain('# Billing: usage-billed.');
48
+ expect(cleaned).toContain('resource "aws_s3_bucket" "x" {}');
49
+ });
50
+
51
+ it('strips both gitignore backup stanzas', () => {
52
+ const content = 'node_modules/\n\n# grada backups\n*.bak.*\n\n# deploy-stack backups\n*.bak.*\n';
53
+ const cleaned = stripGitignoreMetadata(content);
54
+ expect(cleaned).not.toContain('backups');
55
+ expect(cleaned).not.toContain('*.bak.*');
56
+ expect(cleaned).toContain('node_modules/');
57
+ });
58
+
59
+ it('leaves domain markers and user content untouched', () => {
60
+ const content = '# grada:domain-mode=route53\n# grada:zone-id=Z123\n'
61
+ + '# A user comment about grada add workflows.\n';
62
+ expect(stripTerraformMetadata(content)).toBe(content);
63
+ expect(stripGitignoreMetadata('dist/\n')).toBe('dist/\n');
64
+ });
65
+ });