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.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +29 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +75 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +105 -6
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +127 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +126 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +65 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import color from 'picocolors';
|
|
6
|
+
import { COST_ESTIMATE_MARKER, LEGACY_COST_ESTIMATE_MARKER } from './visualizer.js';
|
|
7
|
+
import { findResourceBlock, replaceBlock } from './hcl.js';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
// Fallback deployment-doc filename used only when README.md and
|
|
13
|
+
// DEPLOYMENT.md both exist and neither was generated by us. The legacy
|
|
14
|
+
// name stays a writable candidate so pre-rebrand docs keep updating.
|
|
15
|
+
export const MANAGED_DOC_FALLBACK = 'GRADA.md';
|
|
16
|
+
export const LEGACY_MANAGED_DOC_FALLBACK = 'DEPLOY-STACK.md';
|
|
17
|
+
|
|
18
|
+
// A doc is ours when it carries our cost-estimate marker (current or legacy).
|
|
19
|
+
export function isManagedDoc(content) {
|
|
20
|
+
const text = String(content ?? '');
|
|
21
|
+
return text.includes(COST_ESTIMATE_MARKER) || text.includes(LEGACY_COST_ESTIMATE_MARKER);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildReadmeNotice(docFile) {
|
|
25
|
+
return `\n## 🚀 Deployment\nAWS Fargate infrastructure is managed via [grada](./${docFile}).\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Picks the file that should receive the rendered deployment doc without ever
|
|
29
|
+
// overwriting user-owned content. Returns null when every candidate exists
|
|
30
|
+
// and none is managed (caller then skips doc generation with a warning).
|
|
31
|
+
export function resolveDocDest(targetDir) {
|
|
32
|
+
const inspect = (name) => {
|
|
33
|
+
const filePath = path.join(targetDir, name);
|
|
34
|
+
if (!fsSync.existsSync(filePath)) return { exists: false, managed: false };
|
|
35
|
+
return { exists: true, managed: isManagedDoc(fsSync.readFileSync(filePath, 'utf-8')) };
|
|
36
|
+
};
|
|
37
|
+
const readme = inspect('README.md');
|
|
38
|
+
if (!readme.exists || readme.managed) return 'README.md';
|
|
39
|
+
const deploy = inspect('DEPLOYMENT.md');
|
|
40
|
+
if (!deploy.exists || deploy.managed) return 'DEPLOYMENT.md';
|
|
41
|
+
const fallback = inspect(MANAGED_DOC_FALLBACK);
|
|
42
|
+
if (!fallback.exists || fallback.managed) return MANAGED_DOC_FALLBACK;
|
|
43
|
+
const legacy = inspect(LEGACY_MANAGED_DOC_FALLBACK);
|
|
44
|
+
if (!legacy.exists || legacy.managed) return LEGACY_MANAGED_DOC_FALLBACK;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const LAMBDA_ADAPTER_IMAGE = 'public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1';
|
|
49
|
+
|
|
50
|
+
// Injects the AWS Lambda Web Adapter layer into a rendered Dockerfile so
|
|
51
|
+
// standard HTTP servers handle API Gateway events with zero code changes.
|
|
52
|
+
// Inserted right after the LAST `FROM` (the final stage, still running as
|
|
53
|
+
// root) so the copy into /opt/extensions never hits a later `USER` switch.
|
|
54
|
+
// `ENV PORT` lines already present in the template are not duplicated.
|
|
55
|
+
export function injectLambdaAdapter(dockerContent, port) {
|
|
56
|
+
const content = String(dockerContent ?? '');
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
let lastFromIdx = -1;
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
if (/^\s*FROM\s+/i.test(lines[i])) lastFromIdx = i;
|
|
61
|
+
}
|
|
62
|
+
if (lastFromIdx === -1) return content;
|
|
63
|
+
const hasPort = /^\s*ENV\s+PORT[=\s]/im.test(content);
|
|
64
|
+
const hasLwaPort = /^\s*ENV\s+AWS_LWA_PORT[=\s]/im.test(content);
|
|
65
|
+
const injected = [
|
|
66
|
+
'',
|
|
67
|
+
'# AWS Lambda Web Adapter (grada --target lambda)',
|
|
68
|
+
`COPY --from=${LAMBDA_ADAPTER_IMAGE} /lambda-adapter /opt/extensions/lambda-adapter`,
|
|
69
|
+
...(hasLwaPort ? [] : [`ENV AWS_LWA_PORT=${port}`]),
|
|
70
|
+
...(hasPort ? [] : [`ENV PORT=${port}`]),
|
|
71
|
+
];
|
|
72
|
+
lines.splice(lastFromIdx + 1, 0, ...injected);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Converts a Lambda-target database.tf from AWS-managed master passwords to
|
|
77
|
+
// an explicit Terraform-managed password: VPC-attached functions cannot reach
|
|
78
|
+
// Secrets Manager without a paid VPC endpoint, so the password flows into the
|
|
79
|
+
// function's DB_PASSWORD environment variable instead. Removes the now-invalid
|
|
80
|
+
// master-secret IAM policy (indexing an empty `master_user_secret` tuple fails
|
|
81
|
+
// planning) and appends the `random_password` resource. No-op for ECS content
|
|
82
|
+
// or content that was already converted.
|
|
83
|
+
export function convertDatabaseTfForLambda(databaseTfContent) {
|
|
84
|
+
let content = String(databaseTfContent ?? '');
|
|
85
|
+
if (!content.includes('manage_master_user_password = true')) return content;
|
|
86
|
+
// aws_rds_cluster (Aurora) names the argument `master_password` while
|
|
87
|
+
// aws_db_instance (RDS Postgres/MySQL) names it `password`.
|
|
88
|
+
const passwordAttr = content.includes('resource "aws_rds_cluster"') ? 'master_password' : 'password';
|
|
89
|
+
content = content.replace(
|
|
90
|
+
' # AWS automatically creates and manages the secret in Secrets Manager!\n manage_master_user_password = true',
|
|
91
|
+
` # Lambda targets use an explicit Terraform-managed password (see\n # random_password.db_password below) so the value can flow into the\n # function's DB_PASSWORD environment variable without a Secrets Manager\n # VPC endpoint.\n ${passwordAttr} = random_password.db_password.result`
|
|
92
|
+
);
|
|
93
|
+
content = content.replace('# 4. IAM Permission for RDS Master Password Secret\n', '');
|
|
94
|
+
const resource = findResourceBlock(content, 'aws_iam_role_policy', 'rds_secret_access');
|
|
95
|
+
if (resource) {
|
|
96
|
+
const lineStart = content.lastIndexOf('\n', resource.headerIdx - 1) + 1;
|
|
97
|
+
content = replaceBlock(content, {
|
|
98
|
+
startIdx: lineStart,
|
|
99
|
+
openIdx: resource.openIdx,
|
|
100
|
+
closeIdx: resource.closeIdx,
|
|
101
|
+
}, '').trimEnd();
|
|
102
|
+
content += '\n';
|
|
103
|
+
}
|
|
104
|
+
// RDS passwords disallow / " @ and spaces; the override excludes them.
|
|
105
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
106
|
+
content += `
|
|
107
|
+
resource "random_password" "db_password" {
|
|
108
|
+
length = 24
|
|
109
|
+
special = true
|
|
110
|
+
override_special = "!#$%&*()-_=+[]{}<>:?"
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
return content.replace(/\n{3,}/g, '\n\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Adds the `random` provider to a rendered backend.tf (Lambda + database
|
|
117
|
+
// projects only, for `random_password.db_password`). No-op when present.
|
|
118
|
+
export function addRandomProvider(backendTfContent) {
|
|
119
|
+
const content = String(backendTfContent ?? '');
|
|
120
|
+
if (/source\s*=\s*"hashicorp\/random"/.test(content)) return content;
|
|
121
|
+
const tlsBlock = ' tls = {\n source = "hashicorp/tls"\n version = "~> 4.0"\n }';
|
|
122
|
+
if (!content.includes(tlsBlock)) return content;
|
|
123
|
+
return content.replace(
|
|
124
|
+
tlsBlock,
|
|
125
|
+
`${tlsBlock}\n random = {\n source = "hashicorp/random"\n version = "~> 3.0"\n }`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function generateTemplates(targetDir, config) {
|
|
130
|
+
const isLambda = config.TARGET === 'lambda';
|
|
131
|
+
|
|
132
|
+
// 1. Create directories
|
|
133
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
134
|
+
await fs.mkdir(path.join(targetDir, 'terraform'), { recursive: true });
|
|
135
|
+
await fs.mkdir(path.join(targetDir, '.github', 'workflows'), { recursive: true });
|
|
136
|
+
|
|
137
|
+
// 2. Define paths (never overwrite user-owned docs — see resolveDocDest)
|
|
138
|
+
const templatesDir = path.join(__dirname, '../../templates');
|
|
139
|
+
const readmeDest = resolveDocDest(targetDir);
|
|
140
|
+
const filesToProcess = [
|
|
141
|
+
{ src: isLambda ? 'terraform/main-lambda.tf' : 'terraform/main.tf', dest: 'terraform/main.tf' },
|
|
142
|
+
{ src: 'terraform/network.tf', dest: 'terraform/network.tf' },
|
|
143
|
+
{ src: 'terraform/secrets.tf', dest: 'terraform/secrets.tf' },
|
|
144
|
+
{ src: 'terraform/oidc.tf', dest: 'terraform/oidc.tf' },
|
|
145
|
+
{ src: 'terraform/backend.tf', dest: 'terraform/backend.tf' },
|
|
146
|
+
{ src: isLambda ? 'terraform/cloudfront-lambda.tf' : 'terraform/cloudfront.tf', dest: 'terraform/cloudfront.tf' },
|
|
147
|
+
{ src: `docker/${config.finalFramework}.Dockerfile`, dest: 'Dockerfile' },
|
|
148
|
+
{ src: isLambda ? 'github/deploy-lambda.yml' : 'github/deploy.yml', dest: '.github/workflows/deploy.yml' },
|
|
149
|
+
];
|
|
150
|
+
if (readmeDest) {
|
|
151
|
+
filesToProcess.push({ src: 'README.md', dest: readmeDest });
|
|
152
|
+
} else {
|
|
153
|
+
console.log(color.yellow('⚠️ README.md, DEPLOYMENT.md, GRADA.md, and DEPLOY-STACK.md all exist and none was generated by grada. Skipping deployment-doc generation to protect your files.'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2.1 Point user-owned READMEs at the generated doc (append once only).
|
|
157
|
+
const readmePath = path.join(targetDir, 'README.md');
|
|
158
|
+
if (readmeDest && readmeDest !== 'README.md' && fsSync.existsSync(readmePath)) {
|
|
159
|
+
const notice = buildReadmeNotice(readmeDest);
|
|
160
|
+
const current = fsSync.readFileSync(readmePath, 'utf-8');
|
|
161
|
+
if (!current.includes(notice.trim())) {
|
|
162
|
+
fsSync.appendFileSync(readmePath, notice);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2.1 Add Ephemeral PR workflows only if opted in (teardown.yml is
|
|
167
|
+
// Terraform-only, so both targets share it)
|
|
168
|
+
if (config.ENABLE_PR_PREVIEWS) {
|
|
169
|
+
filesToProcess.push({ src: isLambda ? 'github/preview-lambda.yml' : 'github/preview.yml', dest: '.github/workflows/preview.yml' });
|
|
170
|
+
filesToProcess.push({ src: 'github/teardown.yml', dest: '.github/workflows/teardown.yml' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 3. Configure Compute Commands & Environment Variables
|
|
174
|
+
let secretsArray = [];
|
|
175
|
+
|
|
176
|
+
// 3.1. Format the ECS Task Command (Heroku Procfile Support)
|
|
177
|
+
// If a Procfile exists and has a 'web' process, override the Docker CMD
|
|
178
|
+
if (config.PROCFILE && config.PROCFILE.web) {
|
|
179
|
+
// e.g., ["gunicorn", "myproject.wsgi"] -> JSON string for Terraform
|
|
180
|
+
config.TASK_COMMAND = `command = ${JSON.stringify(config.PROCFILE.web)}`;
|
|
181
|
+
} else {
|
|
182
|
+
// Fallback to the default Dockerfile CMD
|
|
183
|
+
config.TASK_COMMAND = '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Lambda equivalent: a Procfile `web:` command overrides the image CMD
|
|
187
|
+
// via `image_config`. Omitted when empty (Dockerfile CMD wins).
|
|
188
|
+
if (isLambda && Array.isArray(config.PROCFILE?.web) && config.PROCFILE.web.length > 0) {
|
|
189
|
+
const webCommand = config.PROCFILE.web.map((part) => String(part)).join(' ');
|
|
190
|
+
config.LAMBDA_IMAGE_CONFIG = ` image_config {\n command = ${JSON.stringify(['sh', '-c', webCommand])}\n }\n`;
|
|
191
|
+
} else {
|
|
192
|
+
config.LAMBDA_IMAGE_CONFIG = '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A caller-provided WORKER_COMMAND (interactive init worker prompt,
|
|
196
|
+
// possibly customized from the Procfile default) wins verbatim;
|
|
197
|
+
// otherwise the Procfile worker process drives generation as before.
|
|
198
|
+
// Lambda targets never provision the ECS worker service.
|
|
199
|
+
if (!isLambda && typeof config.WORKER_COMMAND === 'string' && config.WORKER_COMMAND.trim() !== '') {
|
|
200
|
+
filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
|
|
201
|
+
} else if (!isLambda && config.PROCFILE && config.PROCFILE.worker) {
|
|
202
|
+
config.WORKER_COMMAND = `command = ${JSON.stringify(config.PROCFILE.worker)}`;
|
|
203
|
+
// Dynamically add worker.tf to the generation list
|
|
204
|
+
filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
|
|
205
|
+
} else {
|
|
206
|
+
config.WORKER_COMMAND = '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3.2. Inject Managed Database Variables
|
|
210
|
+
if (config.NEEDS_DATABASE) {
|
|
211
|
+
const dbEngine = config.DB_ENGINE === 'mysql' || config.DB_ENGINE === 'aurora-postgresql'
|
|
212
|
+
? config.DB_ENGINE
|
|
213
|
+
: 'postgres';
|
|
214
|
+
const dbTemplate = dbEngine === 'postgres'
|
|
215
|
+
? 'terraform/database.tf'
|
|
216
|
+
: `terraform/database-${dbEngine}.tf`;
|
|
217
|
+
filesToProcess.push({ src: dbTemplate, dest: 'terraform/database.tf' });
|
|
218
|
+
|
|
219
|
+
const isCluster = dbEngine === 'aurora-postgresql';
|
|
220
|
+
const dbRef = isCluster ? 'aws_rds_cluster.postgres' : 'aws_db_instance.postgres';
|
|
221
|
+
const dbHostAttr = isCluster ? 'endpoint' : 'address';
|
|
222
|
+
const dbNameAttr = isCluster ? 'database_name' : 'db_name';
|
|
223
|
+
const dbPort = dbEngine === 'mysql' ? '3306' : '5432';
|
|
224
|
+
|
|
225
|
+
if (isLambda) {
|
|
226
|
+
// Lambda `environment.variables` map shape. The password flows as
|
|
227
|
+
// a plain variable (see convertDatabaseTfForLambda) because
|
|
228
|
+
// VPC-attached functions cannot reach Secrets Manager without a
|
|
229
|
+
// paid VPC endpoint.
|
|
230
|
+
config.DB_ENV_VARS = ` DB_HOST = ${dbRef}.${dbHostAttr}\n`
|
|
231
|
+
+ ` DB_PORT = "${dbPort}"\n`
|
|
232
|
+
+ ` DB_NAME = ${dbRef}.${dbNameAttr}\n`
|
|
233
|
+
+ ` DB_USER = "dbadmin"\n`
|
|
234
|
+
+ ` DB_PASSWORD = random_password.db_password.result\n`
|
|
235
|
+
+ (dbEngine === 'postgres' ? '' : ` DB_ENGINE = "${dbEngine}"\n`);
|
|
236
|
+
} else {
|
|
237
|
+
config.DB_ENV_VARS = `
|
|
238
|
+
{ "name": "DB_HOST", "value": ${dbRef}.${dbHostAttr} },
|
|
239
|
+
{ "name": "DB_PORT", "value": "${dbPort}" },
|
|
240
|
+
{ "name": "DB_NAME", "value": ${dbRef}.${dbNameAttr} }${dbEngine === 'postgres' ? '' : `,\n { "name": "DB_ENGINE", "value": "${dbEngine}" }`}`;
|
|
241
|
+
|
|
242
|
+
secretsArray.push(`{ "name": "DB_USER", "valueFrom": "\${${dbRef}.master_user_secret[0].secret_arn}:username::" }`);
|
|
243
|
+
secretsArray.push(`{ "name": "DB_PASSWORD", "valueFrom": "\${${dbRef}.master_user_secret[0].secret_arn}:password::" }`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// VPC-attached functions reach RDS/ElastiCache over the VPC subnets;
|
|
247
|
+
// non-database functions stay outside the VPC with direct internet.
|
|
248
|
+
config.LAMBDA_VPC_CONFIG = isLambda
|
|
249
|
+
? ` vpc_config {\n subnet_ids = aws_subnet.public[*].id\n security_group_ids = [aws_security_group.ecs_tasks.id]\n }\n`
|
|
250
|
+
: '';
|
|
251
|
+
} else {
|
|
252
|
+
config.DB_ENV_VARS = '';
|
|
253
|
+
config.LAMBDA_VPC_CONFIG = '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3.3. Docker Compose Overrides & Sidecars
|
|
257
|
+
let composeWebEnvVars = [];
|
|
258
|
+
let extraContainersHCL = "";
|
|
259
|
+
|
|
260
|
+
if (config.DOCKER_COMPOSE && config.DOCKER_COMPOSE.length > 0) {
|
|
261
|
+
// Identify the main web service (the one with the exposed port)
|
|
262
|
+
const webService = config.DOCKER_COMPOSE.find(s => s.port) || config.DOCKER_COMPOSE[0];
|
|
263
|
+
|
|
264
|
+
// Extract Web Env Vars
|
|
265
|
+
if (webService.environment) {
|
|
266
|
+
for (const [key, val] of Object.entries(webService.environment)) {
|
|
267
|
+
if (isLambda) {
|
|
268
|
+
composeWebEnvVars.push(` ${key} = "${String(val).replace(/"/g, '\\"')}"`);
|
|
269
|
+
} else {
|
|
270
|
+
composeWebEnvVars.push(`{ "name": "${key}", "value": "${val}" }`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Extract Web Command Override (only if Procfile hasn't already set it)
|
|
276
|
+
if (webService.command && !config.TASK_COMMAND && !isLambda) {
|
|
277
|
+
config.TASK_COMMAND = `command = ${JSON.stringify(typeof webService.command === 'string' ? webService.command.split(' ') : webService.command)}`;
|
|
278
|
+
}
|
|
279
|
+
if (isLambda && webService.command && config.LAMBDA_IMAGE_CONFIG === '') {
|
|
280
|
+
const composeCommand = typeof webService.command === 'string' ? webService.command : webService.command.join(' ');
|
|
281
|
+
config.LAMBDA_IMAGE_CONFIG = ` image_config {\n command = ${JSON.stringify(['sh', '-c', composeCommand])}\n }\n`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Process Sidecar Containers (e.g., Redis, Memcached)
|
|
285
|
+
const sidecars = config.DOCKER_COMPOSE.filter(s => s.name !== webService.name);
|
|
286
|
+
if (isLambda && sidecars.length > 0) {
|
|
287
|
+
console.log(color.yellow(`⚠️ Skipping ${sidecars.length} Docker Compose sidecar(s) (${sidecars.map((s) => s.name).join(', ')}): Lambda targets run a single container per function.`));
|
|
288
|
+
}
|
|
289
|
+
if (!isLambda && sidecars.length > 0) {
|
|
290
|
+
extraContainersHCL = sidecars.map(service => {
|
|
291
|
+
const sidecarEnv = Object.entries(service.environment || {})
|
|
292
|
+
.map(([k, v]) => `{ "name": "${k}", "value": "${String(v).replace(/"/g, '\\"')}" }`)
|
|
293
|
+
.join(',\n ');
|
|
294
|
+
|
|
295
|
+
const sidecarCmd = service.command ? `\n command = ${JSON.stringify(typeof service.command === 'string' ? service.command.split(' ') : service.command)}` : '';
|
|
296
|
+
|
|
297
|
+
return `,
|
|
298
|
+
{
|
|
299
|
+
name = "${service.name}"
|
|
300
|
+
image = "${service.image || 'alpine:latest'}"
|
|
301
|
+
essential = true
|
|
302
|
+
environment = [
|
|
303
|
+
${sidecarEnv}
|
|
304
|
+
]${sidecarCmd}
|
|
305
|
+
logConfiguration = {
|
|
306
|
+
logDriver = "awslogs"
|
|
307
|
+
options = {
|
|
308
|
+
"awslogs-group" = aws_cloudwatch_log_group.app_logs.name
|
|
309
|
+
"awslogs-region" = "${config.REGION}"
|
|
310
|
+
"awslogs-stream-prefix" = "ecs-${service.name}"
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}`;
|
|
314
|
+
}).join('');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
config.COMPOSE_WEB_ENV_VARS = composeWebEnvVars.length === 0
|
|
319
|
+
? ''
|
|
320
|
+
: isLambda
|
|
321
|
+
? `${composeWebEnvVars.join('\n')}\n`
|
|
322
|
+
: `${composeWebEnvVars.join(',\n ')},`;
|
|
323
|
+
config.EXTRA_CONTAINERS = isLambda ? '' : extraContainersHCL;
|
|
324
|
+
|
|
325
|
+
// 4. Configure AWS Secrets Manager Integration
|
|
326
|
+
// Build the initial HCL map for AWS Secrets Manager
|
|
327
|
+
let initialSecretMap = `{\n EXAMPLE_API_KEY = "replace_me_in_aws_console"`;
|
|
328
|
+
|
|
329
|
+
// 4.1. Inject Rails Master Key if applicable
|
|
330
|
+
if (config.finalFramework === 'rails') {
|
|
331
|
+
secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${local.secret_arn}:RAILS_MASTER_KEY::" }`);
|
|
332
|
+
|
|
333
|
+
let initialMasterKey = "1234567890abcdef1234567890abcdef"; // Dummy fallback for CI/CD
|
|
334
|
+
const masterKeyPath = path.join(targetDir, 'config', 'master.key');
|
|
335
|
+
|
|
336
|
+
if (fsSync.existsSync(masterKeyPath)) {
|
|
337
|
+
initialMasterKey = fsSync.readFileSync(masterKeyPath, 'utf-8').trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Inject the string literal directly, avoiding Terraform variables entirely
|
|
341
|
+
initialSecretMap += `,\n RAILS_MASTER_KEY = "${initialMasterKey}"`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
initialSecretMap += `\n }`;
|
|
345
|
+
|
|
346
|
+
// Lambda functions have no ECS `secrets` block: credentials flow via
|
|
347
|
+
// environment variables (DB_*) or runtime Secrets Manager reads
|
|
348
|
+
// (APP_SECRETS_ARN), so the task-level secret mappings stay empty.
|
|
349
|
+
if (isLambda && config.finalFramework === 'rails') {
|
|
350
|
+
console.log(color.yellow('⚠️ RAILS_MASTER_KEY is not auto-injected on Lambda targets: set it as a function environment variable after provisioning.'));
|
|
351
|
+
}
|
|
352
|
+
config.TASK_SECRETS = isLambda ? '' : secretsArray.join(',\n ');
|
|
353
|
+
config.INITIAL_SECRET_MAP = initialSecretMap;
|
|
354
|
+
|
|
355
|
+
config.SAFE_ALB_NAME = config.PROJECT_NAME.length > 27
|
|
356
|
+
? config.PROJECT_NAME.substring(0, 27).replace(/-$/, '') // Remove trailing hyphens
|
|
357
|
+
: config.PROJECT_NAME;
|
|
358
|
+
|
|
359
|
+
// 4.5. Translate Vercel Rules to AWS ALB Listener Rules
|
|
360
|
+
let vercelTerraformRules = '';
|
|
361
|
+
|
|
362
|
+
if (config.VERCEL_RULES && config.VERCEL_RULES.redirects) {
|
|
363
|
+
config.VERCEL_RULES.redirects.forEach((rule, index) => {
|
|
364
|
+
// Vercel uses 'permanent: true' for 301, false for 302
|
|
365
|
+
const statusCode = rule.permanent === false ? "HTTP_302" : "HTTP_301";
|
|
366
|
+
|
|
367
|
+
// Map Vercel's source path to AWS ALB Path Pattern
|
|
368
|
+
let sourcePath = rule.source;
|
|
369
|
+
// Vercel sometimes uses regex syntax like '/blog/(.*)'. ALB uses '/blog/*'
|
|
370
|
+
sourcePath = sourcePath.replace(/\(\.\*\)/g, '*');
|
|
371
|
+
|
|
372
|
+
vercelTerraformRules += `
|
|
373
|
+
# Auto-generated from vercel.json redirect
|
|
374
|
+
resource "aws_lb_listener_rule" "vercel_redirect_${index}" {
|
|
375
|
+
listener_arn = aws_lb_listener.http.arn
|
|
376
|
+
priority = ${100 + index} # Start at 100 to avoid conflicts
|
|
377
|
+
|
|
378
|
+
action {
|
|
379
|
+
type = "redirect"
|
|
380
|
+
redirect {
|
|
381
|
+
status_code = "${statusCode}"
|
|
382
|
+
path = "${rule.destination}"
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
condition {
|
|
387
|
+
path_pattern {
|
|
388
|
+
values = ["${sourcePath}"]
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
`;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Assign the compiled HCL to the config object so the template engine can inject it
|
|
397
|
+
// (Lambda targets have no ALB listener rules, so the routing stays empty).
|
|
398
|
+
config.VERCEL_EDGE_ROUTING = isLambda ? '' : vercelTerraformRules;
|
|
399
|
+
|
|
400
|
+
// 5. Process standard files
|
|
401
|
+
for (const file of filesToProcess) {
|
|
402
|
+
let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
|
|
403
|
+
|
|
404
|
+
// Inject variables
|
|
405
|
+
for (const [key, value] of Object.entries(config)) {
|
|
406
|
+
content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (isLambda && file.dest === 'Dockerfile') {
|
|
410
|
+
content = injectLambdaAdapter(content, config.PORT);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (isLambda && file.dest === 'terraform/database.tf') {
|
|
414
|
+
content = convertDatabaseTfForLambda(content);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (isLambda && config.NEEDS_DATABASE && file.dest === 'terraform/backend.tf') {
|
|
418
|
+
content = addRandomProvider(content);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (file.dest === 'terraform/worker.tf' && !config.NEEDS_DATABASE) {
|
|
422
|
+
// {{DB_ENV_VARS}} expanded to empty, leaving a dangling comma +
|
|
423
|
+
// blank line after NODE_ENV (main.tf shares the value shape, so
|
|
424
|
+
// the cleanup stays worker-specific).
|
|
425
|
+
content = content.replace(
|
|
426
|
+
/(\{ "name": "NODE_ENV", "value": "production" \}),\s*\n\s*\n(\s*\])/,
|
|
427
|
+
'$1\n$2'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await fs.writeFile(path.join(targetDir, file.dest), content);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 6. Create empty secrets file (preserve previously pushed keys on re-init)
|
|
435
|
+
const secretKeysPath = path.join(targetDir, 'terraform', 'secret_keys.json');
|
|
436
|
+
if (!fsSync.existsSync(secretKeysPath)) {
|
|
437
|
+
await fs.writeFile(secretKeysPath, "[]");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 7. Handle .gitignore dynamically based on framework
|
|
441
|
+
const targetGitignore = path.join(targetDir, '.gitignore');
|
|
442
|
+
|
|
443
|
+
if (!fsSync.existsSync(targetGitignore)) {
|
|
444
|
+
await fs.writeFile(targetGitignore, getGitignoreContent(config.finalFramework));
|
|
445
|
+
} else {
|
|
446
|
+
const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
|
|
447
|
+
if (!existingGitignore.includes('terraform/.terraform/')) {
|
|
448
|
+
const appendIgnore = '\n# Added by grada (Terraform)\nterraform/.terraform/\nterraform/*.tfstate\nterraform/*.tfstate.backup\nterraform/.terraform.lock.hcl\n.env\n';
|
|
449
|
+
await fs.appendFile(targetGitignore, appendIgnore);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 8. Create .dockerignore to keep images lean and secure
|
|
454
|
+
const dockerignorePath = path.join(targetDir, '.dockerignore');
|
|
455
|
+
const appendDockerIgnore = '\n# Infrastructure (grada)\nterraform/\n**/.terraform/\n**/.terraform.*\n**/*.tfstate*\n.env\n';
|
|
456
|
+
|
|
457
|
+
if (!fsSync.existsSync(dockerignorePath)) {
|
|
458
|
+
const dockerignoreContent = `
|
|
459
|
+
.git/
|
|
460
|
+
.github/
|
|
461
|
+
README.md
|
|
462
|
+
node_modules/
|
|
463
|
+
npm-debug.log
|
|
464
|
+
__pycache__/
|
|
465
|
+
*.pyc
|
|
466
|
+
.DS_Store
|
|
467
|
+
`.trim() + appendDockerIgnore;
|
|
468
|
+
await fs.writeFile(dockerignorePath, dockerignoreContent);
|
|
469
|
+
} else {
|
|
470
|
+
const existingDockerignore = await fs.readFile(dockerignorePath, 'utf-8');
|
|
471
|
+
if (!existingDockerignore.includes('terraform/')) {
|
|
472
|
+
await fs.appendFile(dockerignorePath, appendDockerIgnore);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function getGitignoreContent(framework) {
|
|
477
|
+
const baseIgnore = `
|
|
478
|
+
# Infrastructure (grada)
|
|
479
|
+
terraform/.terraform/
|
|
480
|
+
terraform/*.tfstate
|
|
481
|
+
terraform/*.tfstate.backup
|
|
482
|
+
terraform/.terraform.lock.hcl
|
|
483
|
+
# NOTE: terraform/secret_keys.json must stay committed (key names only, no values) — Terraform/CI reads it.
|
|
484
|
+
terraform/*.auto.tfvars
|
|
485
|
+
.env
|
|
486
|
+
|
|
487
|
+
# OS
|
|
488
|
+
.DS_Store
|
|
489
|
+
Thumbs.db
|
|
490
|
+
`;
|
|
491
|
+
|
|
492
|
+
const presets = {
|
|
493
|
+
node: '\n# Node.js\nnode_modules/\nnpm-debug.log\nyarn-error.log\n',
|
|
494
|
+
nestjs: '\n# NestJS\nnode_modules/\ndist/\nnpm-debug.log\n',
|
|
495
|
+
nextjs: '\n# Next.js\nnode_modules/\n.next/\nout/\nbuild/\nnext-env.d.ts\n',
|
|
496
|
+
nuxt: '\n# Nuxt 3\nnode_modules/\n.nuxt/\n.output/\ndist/\n',
|
|
497
|
+
python: '\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\nvenv/\nenv/\n.venv/\n.pytest_cache/\n',
|
|
498
|
+
django: '\n# Django\n*.log\n*.pot\n*.pyc\n__pycache__/\ndb.sqlite3\nmedia/\n',
|
|
499
|
+
go: '\n# Go\n/bin/\n/pkg/\n/obj/\n*.exe\n*.exe~*\n*.dll\n*.so\n*.dylib\n',
|
|
500
|
+
rails: '\n# Rails\n/*.log\n/tmp/\n/log/\n/public/system/\n/public/assets/\n/vendor/bundle/\n',
|
|
501
|
+
static: '\n# Static Sites\nnode_modules/\ndist/\nbuild/\nout/\n.output/\n.cache/\npublic/\n'
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const frameworkIgnore = presets[framework] || '';
|
|
505
|
+
return (baseIgnore + frameworkIgnore).trim();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 9. Framework-specific cleanup
|
|
509
|
+
// Disable default workflows that crash in isolated CI environments
|
|
510
|
+
if (config.finalFramework === 'rails') {
|
|
511
|
+
const railsCiPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
|
|
512
|
+
const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
|
|
513
|
+
|
|
514
|
+
if (config.DISABLE_DEFAULT_CI) {
|
|
515
|
+
if (fsSync.existsSync(railsCiPath)) {
|
|
516
|
+
fsSync.renameSync(railsCiPath, `${railsCiPath}.bak`);
|
|
517
|
+
console.log(' ✅ Safely disabled default Rails ci.yml (renamed to .bak).');
|
|
518
|
+
}
|
|
519
|
+
if (fsSync.existsSync(dependabotPath)) {
|
|
520
|
+
fsSync.renameSync(dependabotPath, `${dependabotPath}.bak`);
|
|
521
|
+
console.log(' ✅ Safely disabled default dependabot.yml (renamed to .bak).');
|
|
522
|
+
}
|
|
523
|
+
} else if (fsSync.existsSync(railsCiPath) || fsSync.existsSync(dependabotPath)) {
|
|
524
|
+
console.log(' ⚠️ You chose to keep your existing GitHub Actions. Please note that default tests may fail until you configure a database service in your CI runner.');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|