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,243 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import color from 'picocolors';
6
+ import { intro, outro, spinner } from '@clack/prompts';
7
+ import { trackSuccess, trackFailure } from '../core/telemetry.js';
8
+ import { failCommand, failProjectNotInitialized } from '../utils/command.js';
9
+ import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
10
+ import { resolveRegion, resolveProjectName, resolveCwd, readFileSafe } from '../utils/resolvers.js';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ const DRIFT_TEMPLATE_PATH = path.join(__dirname, '../../templates/github/drift.yml');
16
+
17
+ export const DRIFT_WORKFLOW_FILE = path.join('.github', 'workflows', 'drift.yml');
18
+ export const MAX_SUMMARY_LINES = 60;
19
+
20
+ export function parseDriftArgs(argv = []) {
21
+ const args = normalizeArgv(argv);
22
+ if (args[0] === 'drift') args.shift();
23
+ const { options, rest } = parseFlags(args, {
24
+ string: ['region', 'project-name'],
25
+ boolean: ['setup', 'force', 'headless'],
26
+ });
27
+ const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
28
+ // `drift init` is an alias for `drift --setup`.
29
+ if (positionals.includes('init') || positionals.includes('setup')) options.setup = true;
30
+ const others = positionals.filter((positional) => positional !== 'init' && positional !== 'setup');
31
+ if (others.length > 0) options.unexpectedPositionals = others;
32
+ return options;
33
+ }
34
+
35
+ // The OIDC role ARN the deploy workflow assumes — drift CI reuses it so no
36
+ // extra secret is needed. Null when the anchor cannot be found.
37
+ export function extractRoleArn(deployYmlContent) {
38
+ const match = /role-to-assume:\s*([^\s#]+)/.exec(String(deployYmlContent ?? ''));
39
+ return match ? match[1] : null;
40
+ }
41
+
42
+ // Renders `templates/github/drift.yml` into the project. Shared by
43
+ // `drift --setup` and `--setup-ci-drift`; never exits, never tracks.
44
+ export function scaffoldDriftWorkflow(cwd = process.cwd(), { region, roleArn, force = false } = {}) {
45
+ const template = fsSync.readFileSync(DRIFT_TEMPLATE_PATH, 'utf8');
46
+ const rendered = template
47
+ .replaceAll('{{REGION}}', region)
48
+ .replaceAll('{{ROLE_ARN}}', roleArn);
49
+ const targetPath = path.join(cwd, DRIFT_WORKFLOW_FILE);
50
+ if (fsSync.existsSync(targetPath) && force !== true && force !== 'true') {
51
+ return { ok: false, reason: 'exists', path: targetPath };
52
+ }
53
+ fsSync.mkdirSync(path.dirname(targetPath), { recursive: true });
54
+ fsSync.writeFileSync(targetPath, rendered, 'utf8');
55
+ return { ok: true, path: targetPath };
56
+ }
57
+
58
+ // Condenses `terraform plan` output to the resource-change lines plus the
59
+ // `Plan:` summary, capped so Issue bodies and terminals stay readable.
60
+ export function extractPlanSummary(output) {
61
+ const lines = String(output ?? '').split('\n');
62
+ const picked = lines.filter((line) => /^\s*[~+-] /.test(line) || /^# \S/.test(line) || /^Plan:/.test(line));
63
+ const trimmed = picked.slice(0, MAX_SUMMARY_LINES);
64
+ if (picked.length > trimmed.length) {
65
+ trimmed.push(`... (${picked.length - trimmed.length} more lines)`);
66
+ }
67
+ return trimmed.join('\n');
68
+ }
69
+
70
+ function tailLines(output, count = 15) {
71
+ return String(output ?? '').split('\n').slice(-count).join('\n').trim();
72
+ }
73
+
74
+ export async function runDrift(input = {}) {
75
+ const options = normalizeOptions(input);
76
+ let cwd;
77
+ let region;
78
+ let projectName;
79
+ try {
80
+ cwd = resolveCwd(options);
81
+ region = resolveRegion(options, cwd);
82
+ projectName = resolveProjectName(options, cwd);
83
+ } catch {
84
+ return failProjectNotInitialized({ event: 'drift_run' });
85
+ }
86
+ const setup = options.setup === true || options.setup === 'true';
87
+ const force = options.force === true || options.force === 'true';
88
+
89
+ intro(color.bgCyan(color.black(' grada drift šŸ” ')));
90
+
91
+ if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
92
+ return failCommand({
93
+ message: `\nāœ– Unexpected argument "${options.unexpectedPositionals[0]}". Run "grada drift" to check, or "grada drift --setup" to scaffold scheduled checks.\n`,
94
+ event: 'drift_run',
95
+ telemetry: { projectName },
96
+ errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
97
+ reason: 'unexpected-positional-args',
98
+ resultExtra: { region },
99
+ });
100
+ }
101
+
102
+ if (setup) {
103
+ const deployYml = readFileSafe(path.join(cwd, '.github', 'workflows', 'deploy.yml'));
104
+ if (!deployYml) {
105
+ return failCommand({
106
+ message: `\nāœ– Workflow not found at ${color.cyan('.github/workflows/deploy.yml')}. Run ${color.green('npx grada-run')} first.\n`,
107
+ event: 'drift_run',
108
+ telemetry: { projectName },
109
+ errorCode: 'WORKFLOW_NOT_FOUND',
110
+ reason: 'workflow-not-found',
111
+ resultExtra: { region },
112
+ });
113
+ }
114
+ const roleArn = extractRoleArn(deployYml);
115
+ if (!roleArn) {
116
+ return failCommand({
117
+ message: '\nāœ– Could not find the OIDC role ARN (role-to-assume) in .github/workflows/deploy.yml.\n',
118
+ event: 'drift_run',
119
+ telemetry: { projectName },
120
+ errorCode: 'ROLE_ARN_NOT_FOUND',
121
+ reason: 'role-arn-not-found',
122
+ resultExtra: { region },
123
+ });
124
+ }
125
+ const scaffolded = scaffoldDriftWorkflow(cwd, { region, roleArn, force });
126
+ if (!scaffolded.ok) {
127
+ return failCommand({
128
+ message: `\n⚠ ${DRIFT_WORKFLOW_FILE} already exists. Pass --force to overwrite.\n`,
129
+ tone: 'yellow',
130
+ event: 'drift_run',
131
+ telemetry: { projectName, error_code: 'DRIFT_WORKFLOW_EXISTS' },
132
+ reason: 'drift-workflow-exists',
133
+ resultExtra: { projectName, region },
134
+ exitCode: null,
135
+ });
136
+ }
137
+ console.log(color.green(`\nāœ… Created ${DRIFT_WORKFLOW_FILE} (daily 06:00 UTC drift check).`));
138
+ console.log(color.dim(' Drift opens (or updates) a GitHub Issue labeled iac-drift; set SLACK_WEBHOOK_URL for Slack alerts.\n'));
139
+ await trackSuccess('drift_run', { projectName, action: 'setup' });
140
+ outro(color.green('Done.'));
141
+ return { ok: true, action: 'setup', projectName, region, file: DRIFT_WORKFLOW_FILE };
142
+ }
143
+
144
+ const tfDir = path.join(cwd, 'terraform');
145
+ if (!fsSync.existsSync(path.join(tfDir, 'main.tf'))) {
146
+ return failCommand({
147
+ message: '\nāœ– No terraform/main.tf found. Run "grada" first before checking drift.\n',
148
+ event: 'drift_run',
149
+ telemetry: { projectName, error_code: 'TERRAFORM_NOT_INITIALIZED' },
150
+ reason: 'terraform-not-initialized',
151
+ resultExtra: { region },
152
+ });
153
+ }
154
+
155
+ const runSync = options.spawnSyncImpl || spawnSync;
156
+ const s = spinner();
157
+ s.start('Running terraform plan...');
158
+
159
+ try {
160
+ const initResult = runSync('terraform', ['init', '-input=false'], { cwd: tfDir, encoding: 'utf8' });
161
+ if (initResult?.error?.code === 'ENOENT') {
162
+ s.stop(color.red('Terraform not found.'));
163
+ return failCommand({
164
+ message: '\nāœ– Terraform is not installed.',
165
+ hint: ' Please run "npx grada-run doctor" to check your environment.\n',
166
+ event: 'drift_run',
167
+ telemetry: { projectName },
168
+ errorCode: 'TERRAFORM_NOT_INSTALLED',
169
+ reason: 'terraform-not-installed',
170
+ resultExtra: { region },
171
+ });
172
+ }
173
+ if (initResult?.status !== 0) {
174
+ s.stop(color.red('Terraform init failed.'));
175
+ return failCommand({
176
+ message: `\nāœ– terraform init failed:\n${tailLines(initResult?.stderr || initResult?.stdout)}\n`,
177
+ event: 'drift_run',
178
+ telemetry: { projectName },
179
+ errorCode: 'TERRAFORM_INIT_FAILED',
180
+ reason: 'terraform-init-failed',
181
+ resultExtra: { region },
182
+ });
183
+ }
184
+
185
+ const planResult = runSync('terraform', ['plan', '-detailed-exitcode', '-no-color'], {
186
+ cwd: tfDir,
187
+ encoding: 'utf8',
188
+ maxBuffer: 32 * 1024 * 1024,
189
+ });
190
+ const status = planResult?.status;
191
+ if (status === 0) {
192
+ s.stop(color.green('No drift.'));
193
+ console.log(color.green('\nāœ… No infrastructure drift detected.\n'));
194
+ await trackSuccess('drift_run', { projectName, action: 'check', drift: false });
195
+ outro(color.green('Done.'));
196
+ return { ok: true, action: 'check', drift: false, region };
197
+ }
198
+ if (status === 2) {
199
+ s.stop(color.yellow('Drift detected.'));
200
+ const summary = extractPlanSummary(planResult?.stdout);
201
+ return failCommand({
202
+ print: () => {
203
+ console.log(color.yellow('\n⚠ Infrastructure drift detected!'));
204
+ if (summary) console.log(color.dim(`\n${summary}\n`));
205
+ console.log(` Reconcile with ${color.green('npx grada-run apply')}, or adopt the live resource with ${color.green('terraform import')}.\n`);
206
+ },
207
+ event: 'drift_run',
208
+ telemetry: { projectName, action: 'check', drift: true },
209
+ errorCode: 'DRIFT_DETECTED',
210
+ reason: 'drift-detected',
211
+ resultExtra: { region },
212
+ exitCode: 2,
213
+ });
214
+ }
215
+ s.stop(color.red('Terraform plan failed.'));
216
+ return failCommand({
217
+ message: `\nāœ– terraform plan failed:\n${tailLines(planResult?.stderr || planResult?.stdout)}\n`,
218
+ event: 'drift_run',
219
+ telemetry: { projectName, action: 'check' },
220
+ errorCode: 'TERRAFORM_PLAN_FAILED',
221
+ reason: 'terraform-plan-failed',
222
+ resultExtra: { region },
223
+ });
224
+ } catch (error) {
225
+ await trackFailure('drift_run', {
226
+ projectName,
227
+ action: 'check',
228
+ error_code: error?.name || 'UNKNOWN',
229
+ error_message: error?.message,
230
+ });
231
+ try { s.stop(color.red('āŒ Drift check failed.')); } catch { /* spinner already stopped */ }
232
+ return failCommand({
233
+ message: `āœ– ${error?.message || error}`,
234
+ hint: 'Check your AWS credentials and region, then try again.',
235
+ reason: 'error',
236
+ resultExtra: { region },
237
+ });
238
+ }
239
+ }
240
+
241
+ export const driftCommand = runDrift;
242
+
243
+ export default runDrift;
@@ -0,0 +1,127 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { intro, outro, confirm, spinner, cancel } from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
6
+
7
+ // Matches the `# <brand> generated infrastructure...` header line in
8
+ // either brand variant (Lambda targets append a suffix in parentheses).
9
+ const GENERATED_HEADER_REGEX = /# (?:grada|deploy-stack) generated infrastructure[^\n]*\n?/g;
10
+
11
+ // Matches the one-line `# <brand> addon: ...` header in either variant.
12
+ const ADDON_HEADER_REGEX = /^# (?:grada|deploy-stack) addon:[^\n]*\n?/gm;
13
+
14
+ // Matches the `# Generated by \`<brand> add ...` sentence through its
15
+ // `... overwrites this file.` tail (it wraps across lines), in either
16
+ // brand variant. Billing docs below the header are left untouched.
17
+ const GENERATED_BY_REGEX = /^# Generated by `?(?:grada|deploy-stack) add\b[^\n]*\n(?:^#[^\n]*\n)*?^#[^\n]*overwrites this file\.[^\n]*\n?/gm;
18
+
19
+ // Matches a `default_tags` block pinning `ManagedBy` to either brand.
20
+ const MANAGED_BY_TAGS_REGEX = /[ \t]*default_tags\s*\{\s*tags\s*=\s*\{\s*ManagedBy\s*=\s*"(?:grada|deploy-stack)"\s*\}\s*\}\n?/g;
21
+
22
+ // Matches the `.bak` gitignore stanza in either brand variant.
23
+ const BACKUPS_GITIGNORE_REGEX = /\n# (?:grada|deploy-stack) backups\n\*\.bak\.\*\n/g;
24
+
25
+ // Strips tool metadata from Terraform source: the generated header,
26
+ // addon headers, and ManagedBy tag blocks. Pure and unit-tested.
27
+ export function stripTerraformMetadata(content) {
28
+ return String(content ?? '')
29
+ .replace(GENERATED_HEADER_REGEX, '')
30
+ .replace(ADDON_HEADER_REGEX, '')
31
+ .replace(GENERATED_BY_REGEX, '')
32
+ .replace(MANAGED_BY_TAGS_REGEX, '');
33
+ }
34
+
35
+ // Strips the `.bak` stanza from gitignore content. Pure and unit-tested.
36
+ export function stripGitignoreMetadata(content) {
37
+ return String(content ?? '').replace(BACKUPS_GITIGNORE_REGEX, '\n');
38
+ }
39
+
40
+ function collectTerraformFiles(dir, out = []) {
41
+ if (!fs.existsSync(dir)) return out;
42
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const fullPath = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) collectTerraformFiles(fullPath, out);
45
+ else if (entry.isFile() && entry.name.endsWith('.tf')) out.push(fullPath);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export async function ejectStack() {
51
+ intro(color.bgRed(color.white(' grada eject āļø ')));
52
+
53
+ console.log(color.yellow('This will permanently decouple your infrastructure from the grada CLI.'));
54
+ console.log(color.gray('It removes all ManagedBy tags and tool-specific metadata from your local files.'));
55
+ console.log(color.gray('Your infrastructure will remain fully operational as raw, standalone Terraform.'));
56
+
57
+ const shouldEject = await confirm({
58
+ message: 'Are you sure you want to eject? (This cannot be undone)',
59
+ initialValue: false,
60
+ });
61
+
62
+ if (!shouldEject || typeof shouldEject === 'symbol') {
63
+ cancel('Eject cancelled. You are still managed by grada.');
64
+ process.exit(0);
65
+ }
66
+
67
+ const s = spinner();
68
+ s.start('Ejecting grada metadata...');
69
+
70
+ const targetDir = process.cwd();
71
+ const tfDir = path.join(targetDir, 'terraform');
72
+ const gitignorePath = path.join(targetDir, '.gitignore');
73
+
74
+ // 1. Clean every Terraform file (main.tf headers and tag blocks,
75
+ // addon headers in either brand variant).
76
+ for (const tfPath of collectTerraformFiles(tfDir)) {
77
+ const content = fs.readFileSync(tfPath, 'utf8');
78
+ const cleaned = stripTerraformMetadata(content);
79
+ if (cleaned !== content) {
80
+ fs.writeFileSync(tfPath, cleaned.trim() + '\n');
81
+ }
82
+ }
83
+
84
+ // 2. Clean up .gitignore
85
+ if (fs.existsSync(gitignorePath)) {
86
+ let gitignore = fs.readFileSync(gitignorePath, 'utf8');
87
+ // Remove the exact block we injected in backup.js
88
+ gitignore = stripGitignoreMetadata(gitignore);
89
+ fs.writeFileSync(gitignorePath, gitignore.trim() + '\n');
90
+ }
91
+
92
+ // 3. Purge all local `.bak` files to leave a pristine directory
93
+ const cleanBackups = (dir) => {
94
+ if (!fs.existsSync(dir)) return;
95
+ const files = fs.readdirSync(dir);
96
+ for (const file of files) {
97
+ const fullPath = path.join(dir, file);
98
+ if (file.includes('.bak.')) {
99
+ fs.rmSync(fullPath, { recursive: true, force: true });
100
+ } else if (fs.statSync(fullPath).isDirectory() && file !== 'node_modules' && file !== '.git') {
101
+ cleanBackups(fullPath); // Recursively check subdirectories like .github/workflows
102
+ }
103
+ }
104
+ };
105
+
106
+ cleanBackups(targetDir);
107
+
108
+ s.stop('Ejection complete.');
109
+
110
+ const actualProjectName = path.basename(process.cwd());
111
+ trackEvent('project_ejected', {
112
+ projectName: actualProjectName
113
+ });
114
+ await flushTelemetry();
115
+
116
+ outro(`
117
+ ${color.green('āœ… Successfully ejected from grada!')}
118
+
119
+ Your Terraform and CI/CD files are now 100% vanilla.
120
+
121
+ ${color.blue('Next Step:')}
122
+ Run ${color.cyan('terraform apply')} inside your terraform/ folder.
123
+ AWS will sync your state and automatically remove the 'ManagedBy' tags from your live cloud resources.
124
+
125
+ ${color.magenta('Godspeed! You are now the sole owner of your infrastructure.')}
126
+ `);
127
+ }
@@ -0,0 +1,222 @@
1
+ import { ECSClient, ListTasksCommand, DescribeTasksCommand } from '@aws-sdk/client-ecs';
2
+ import { spawn } from 'child_process';
3
+ import color from 'picocolors';
4
+ import { intro, outro, spinner } from '@clack/prompts';
5
+ import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
6
+ import { hasAwsCli, AWS_CLI_INSTALL_URL, handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
7
+ import { failCommand, failProjectNotInitialized } from '../utils/command.js';
8
+ import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
9
+ import { resolveRegion, resolveProjectName, resolveCluster, resolveService, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
10
+ import {
11
+ hasSessionManagerPlugin,
12
+ resolveContainer,
13
+ printAwsCliGuidance,
14
+ printSessionManagerGuidance,
15
+ printNoTasksGuidance,
16
+ } from '../utils/ecs.js';
17
+
18
+ // Re-exported from the shared ECS module for backward compatibility.
19
+ export {
20
+ SESSION_MANAGER_PLUGIN_URL,
21
+ hasSessionManagerPlugin,
22
+ resolveContainer,
23
+ } from '../utils/ecs.js';
24
+
25
+ export const DEFAULT_SHELL = '/bin/sh';
26
+
27
+ export function resolveShellCommand(options = {}) {
28
+ if (typeof options.command === 'string' && options.command.trim()) return options.command.trim();
29
+ if (typeof options.shell === 'string' && options.shell.trim()) return options.shell.trim();
30
+ return DEFAULT_SHELL;
31
+ }
32
+
33
+ export function parseExecArgs(argv = []) {
34
+ const args = normalizeArgv(argv);
35
+ if (args[0] === 'exec') args.shift();
36
+ const { options, rest } = parseFlags(args, {
37
+ string: ['cluster', 'service', 'container', 'command', 'region'],
38
+ });
39
+ const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
40
+ if (positionals.length > 0 && !options.service) options.service = positionals[0];
41
+ return options;
42
+ }
43
+
44
+ export function buildExecuteCommandArgs({ cluster, taskArn, container, command = DEFAULT_SHELL, region }) {
45
+ const args = [
46
+ 'ecs', 'execute-command',
47
+ '--cluster', cluster,
48
+ '--task', taskArn,
49
+ '--container', container,
50
+ '--interactive',
51
+ '--command', command,
52
+ ];
53
+ if (region) args.push('--region', region);
54
+ return args;
55
+ }
56
+
57
+ function pickContainerName(task, fallback) {
58
+ const containers = task?.containers || [];
59
+ if (containers.length === 0) return fallback;
60
+ const exact = containers.find((c) => c.name === fallback);
61
+ if (exact) return exact.name;
62
+ const running = containers.find((c) => c.lastStatus === 'RUNNING');
63
+ if (running?.name) return running.name;
64
+ if (containers[0]?.name) return containers[0].name;
65
+ return fallback;
66
+ }
67
+
68
+ export async function findRunningTask(ecsClient, { cluster, service }) {
69
+ const listResp = await ecsClient.send(
70
+ new ListTasksCommand({ cluster, serviceName: service, desiredStatus: 'RUNNING', maxResults: 10 })
71
+ );
72
+ const taskArns = listResp.taskArns || [];
73
+ if (taskArns.length === 0) return null;
74
+ const descResp = await ecsClient.send(
75
+ new DescribeTasksCommand({ cluster, tasks: taskArns.slice(0, 1) })
76
+ );
77
+ const tasks = descResp.tasks || [];
78
+ if (tasks.length === 0) return { taskArn: taskArns[0], containerName: null };
79
+ return { taskArn: tasks[0].taskArn || taskArns[0], containerName: tasks[0].containers?.[0]?.name || null, task: tasks[0] };
80
+ }
81
+
82
+ export async function runExec(input = {}) {
83
+ const options = normalizeOptions(input);
84
+ let cwd;
85
+ let region;
86
+ let projectName;
87
+ let cluster;
88
+ let service;
89
+ let expectedContainer;
90
+ try {
91
+ cwd = resolveCwd(options);
92
+ region = resolveRegion(options, cwd);
93
+ projectName = resolveProjectName(options, cwd);
94
+ cluster = resolveCluster(options, cwd);
95
+ service = resolveService(options, cwd);
96
+ expectedContainer = resolveContainer(options, cwd);
97
+ } catch {
98
+ return failProjectNotInitialized({ event: 'exec_run' });
99
+ }
100
+ const shellCommand = resolveShellCommand(options);
101
+
102
+ const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
103
+
104
+ const spawnImpl = options.spawnImpl || spawn;
105
+ const awsCliPresent = options.hasAwsCli ?? hasAwsCli({ spawnSyncImpl: options.spawnSyncImpl });
106
+ const ssmPluginPresent = options.hasSsmPlugin ?? hasSessionManagerPlugin({ spawnSyncImpl: options.spawnSyncImpl });
107
+
108
+ intro(color.bgCyan(color.black(' grada exec 🐚 ')));
109
+
110
+ if (readTerraformComputeTarget(cwd) === 'lambda') {
111
+ return failCommand({
112
+ print: () => {
113
+ console.log(color.red(`\nāœ– Exec opens a shell in a running ECS container, but "${projectName}" is a Lambda project.`));
114
+ console.log(` Lambda functions have no shell to attach to — inspect recent output with ${color.green('npx grada-run logs')} instead.\n`);
115
+ },
116
+ event: 'exec_run',
117
+ telemetry: { projectName, error_code: 'LAMBDA_TARGET_UNSUPPORTED' },
118
+ reason: 'lambda-target-unsupported',
119
+ resultExtra: { cluster, service, region },
120
+ });
121
+ }
122
+
123
+ if (!awsCliPresent) {
124
+ return failCommand({
125
+ print: printAwsCliGuidance,
126
+ event: 'exec_run',
127
+ telemetry: { projectName, error_code: 'AWS_CLI_MISSING' },
128
+ reason: 'aws-cli-missing',
129
+ resultExtra: { cluster, service, region },
130
+ });
131
+ }
132
+
133
+ if (!ssmPluginPresent) {
134
+ return failCommand({
135
+ print: printSessionManagerGuidance,
136
+ event: 'exec_run',
137
+ telemetry: { projectName, error_code: 'SSM_PLUGIN_MISSING' },
138
+ reason: 'ssm-plugin-missing',
139
+ resultExtra: { cluster, service, region },
140
+ });
141
+ }
142
+
143
+ const s = spinner();
144
+ s.start('Finding a running container...');
145
+
146
+ try {
147
+ const found = await findRunningTask(ecsClient, { cluster, service });
148
+
149
+ if (!found) {
150
+ s.stop(color.yellow('No running tasks.'));
151
+ return failCommand({
152
+ print: () => printNoTasksGuidance(service, cluster),
153
+ event: 'exec_run',
154
+ telemetry: { projectName, error_code: 'NO_RUNNING_TASKS' },
155
+ reason: 'no-running-tasks',
156
+ resultExtra: { cluster, service, region },
157
+ });
158
+ }
159
+
160
+ const container = found.containerName || expectedContainer;
161
+ const taskArn = found.taskArn;
162
+ // Prefer the exact expected container name when the task actually runs it.
163
+ const resolvedContainer = found.task ? pickContainerName(found.task, expectedContainer) : container;
164
+
165
+ s.stop(color.green('Container found. Connecting...'));
166
+ console.log(` ${color.dim('Cluster:')} ${color.cyan(cluster)}`);
167
+ console.log(` ${color.dim('Task:')} ${color.dim(taskArn)}`);
168
+ console.log(` ${color.dim('Container:')} ${color.yellow(resolvedContainer)}`);
169
+ console.log(color.dim(` Opening ${shellCommand} — type 'exit' to leave.\n`));
170
+
171
+ const cliArgs = buildExecuteCommandArgs({
172
+ cluster,
173
+ taskArn,
174
+ container: resolvedContainer,
175
+ command: shellCommand,
176
+ region,
177
+ });
178
+
179
+ await trackSuccess('exec_run', { projectName });
180
+
181
+ await new Promise((resolve) => {
182
+ const child = spawnImpl('aws', cliArgs, { stdio: 'inherit' });
183
+ child.on('error', (err) => {
184
+ console.log(color.red(`\nāœ– Failed to start AWS CLI: ${err?.message || err}`));
185
+ console.log(color.dim(`Install help: ${AWS_CLI_INSTALL_URL}`));
186
+ resolve({ code: 1 });
187
+ });
188
+ child.on('close', (code) => resolve({ code: code ?? 0 }));
189
+ }).then(async ({ code }) => {
190
+ if (code === 0) {
191
+ outro(color.green('Shell session ended. šŸ‘‹'));
192
+ } else {
193
+ console.log(color.yellow(`\nShell exited with code ${code}.`));
194
+ console.log(color.dim(`If the connection failed, ensure ECS Exec is enabled (re-run ${color.green('npx grada-run apply')}) and the Session Manager plugin is installed.`));
195
+ outro(color.yellow('Exec finished.'));
196
+ }
197
+ });
198
+
199
+ return { ok: true, cluster, service, taskArn, container: resolvedContainer, region };
200
+ } catch (error) {
201
+ await trackFailure('exec_run', {
202
+ projectName,
203
+ error_code: error?.name || 'UNKNOWN',
204
+ error_message: error?.message,
205
+ });
206
+ if (handleAuthErrorBranch(error, s, options)) {
207
+ return { ok: false, reason: 'error', cluster, service, region };
208
+ }
209
+ s.stop(color.red('āŒ Exec failed.'));
210
+ return failCommand({
211
+ message: `āœ– ${error?.message || error}`,
212
+ hint: 'Check your AWS credentials and region, then try again.',
213
+ reason: 'error',
214
+ resultExtra: { cluster, service, region },
215
+ });
216
+ }
217
+ }
218
+
219
+ // Convenience alias mirroring the CLI verb.
220
+ export const execCommand = runExec;
221
+
222
+ export default runExec;