@gambi97/keel-cli 0.1.1 → 0.2.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/README.md CHANGED
@@ -30,8 +30,9 @@ to `main`, and the infrastructure is live.
30
30
 
31
31
  - **Near-free to start.** Compute and database scale to zero; an idle project
32
32
  costs cents per month, with no expiring trial.
33
- - **Scales with your product.** Staging and prod from day one; growing is a
34
- one-line tfvars change reviewed in a PR.
33
+ - **Scales with your product.** Pick your environments (production only, or
34
+ add staging and dev) and, if you need it, an application file store; growing
35
+ is a one-line tfvars change reviewed in a PR.
35
36
  - **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
36
37
  - **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
37
38
  secret manager, wired for you.
@@ -151,21 +152,22 @@ local tooling or production credentials.
151
152
 
152
153
  **Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
153
154
 
154
- | Where | What |
155
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
- | Your machine | The generated repo: Terraform, workflows, README, initial git commit |
157
- | Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking) |
158
- | GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; `staging` and `production` environments, the latter gated by manual approval; branch protection on `main` |
159
- | Infisical | A project with `staging` and `prod` environments, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (staging, random password) and a `DATABASE_URL` placeholder per environment |
155
+ | Where | What |
156
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
157
+ | Your machine | The generated repo: Terraform, workflows, README, initial git commit |
158
+ | Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking) |
159
+ | GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main` |
160
+ | Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
160
161
 
161
162
  **Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
162
163
 
163
- | Scaleway resource | Notes |
164
- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
165
- | Registry namespace | Private, one per environment |
166
- | Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
167
- | Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
168
- | IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
164
+ | Scaleway resource | Notes |
165
+ | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
166
+ | Registry namespace | Private, one per environment |
167
+ | Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
168
+ | Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
169
+ | IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
170
+ | Object Storage bucket + credential (opt-in) | Only with `--object-storage`: one bucket per environment plus a dedicated least-privilege credential; coordinates synced to Infisical as `S3_*` |
169
171
 
170
172
  No custom domain is configured: the app gets an auto-generated Scaleway URL.
171
173
  Add one later with a single `scaleway_container_domain` resource.
@@ -176,28 +178,29 @@ Add one later with a single `scaleway_container_domain` resource.
176
178
  my-app/
177
179
  ├── README.md # operating manual for the repo
178
180
  ├── .github/workflows/
179
- │ ├── terraform-plan.yml # PR: fmt + validate + plan (staging & prod)
180
- │ ├── terraform-apply.yml # main: apply staging -> approval -> apply prod
181
+ │ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
182
+ │ ├── terraform-apply.yml # main: apply each env in order; production gated
181
183
  │ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
182
184
  ├── versions.tf · providers.tf · backend.tf
183
185
  ├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
184
186
  ├── variables.tf · main.tf · outputs.tf
185
- ├── staging.tfvars · prod.tfvars # non-sensitive config only, committed
186
- └── modules/app_stack/ # registry + container + database + db credential
187
+ ├── <env>.tfvars # one per environment, non-sensitive config, committed
188
+ └── modules/app_stack/ # registry + container + database (+ object storage) + credentials
187
189
  ```
188
190
 
189
- Environments are separated with **Terraform workspaces**: same code, two
190
- independent states in one bucket, differences confined to the two `.tfvars`
191
- files.
191
+ Environments are separated with **Terraform workspaces**: same code, one
192
+ independent state per environment in one bucket, differences confined to the
193
+ per-environment `<env>.tfvars` files.
192
194
 
193
- | Data | Lives in | Why |
194
- | --------------------------------- | ------------------------ | -------------------------------------------------------------------- |
195
- | Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
196
- | Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
197
- | Basic Auth user/password | Infisical (staging) | App secret, injected into the container, rotatable |
198
- | Database connection string | Infisical (both envs) | Complete, ready-to-use value synced by the pipeline after each apply |
199
- | Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
200
- | Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
195
+ | Data | Lives in | Why |
196
+ | ----------------------------------- | ------------------------ | -------------------------------------------------------------------- |
197
+ | Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
198
+ | Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
199
+ | Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
200
+ | Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
201
+ | Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
202
+ | Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
203
+ | Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
201
204
 
202
205
  ## After the bootstrap
203
206
 
@@ -206,19 +209,21 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
206
209
  short version of the first deploy:
207
210
 
208
211
  1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
209
- databases. Approve the `production` gate when prompted.
212
+ databases (and an Object Storage bucket, if enabled). Approve the
213
+ `production` gate when prompted.
210
214
  2. **Build and push your app image** to the registry endpoint from the apply
211
- output:
215
+ output (replace `<env>` with your target environment):
212
216
  ```sh
213
- docker login rg.fr-par.scw.cloud/my-app-staging -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
214
- docker build -t rg.fr-par.scw.cloud/my-app-staging/app:latest .
215
- docker push rg.fr-par.scw.cloud/my-app-staging/app:latest
217
+ docker login rg.fr-par.scw.cloud/my-app-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
218
+ docker build -t rg.fr-par.scw.cloud/my-app-<env>/app:latest .
219
+ docker push rg.fr-par.scw.cloud/my-app-<env>/app:latest
216
220
  ```
217
221
  3. **Set `container_image`** in the tfvars and open a PR: the next apply
218
222
  creates the containers.
219
223
  4. **Replace the placeholder secrets** in Infisical with real values. The app
220
- reads `DATABASE_URL` and `BASIC_AUTH_*` from its environment; on staging
221
- it also receives `BASIC_AUTH_ENABLED=true` and enforces it.
224
+ reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from its
225
+ environment; non-production environments also receive `BASIC_AUTH_ENABLED=true`
226
+ and enforce it.
222
227
 
223
228
  ## Prerequisites
224
229
 
@@ -280,7 +285,8 @@ creating anything**.
280
285
  locking (`use_lockfile`), so concurrent applies cannot corrupt it.
281
286
  - The app connects to its database with a **dedicated least-privilege IAM
282
287
  credential** (read/write on that database, nothing else), not with your
283
- main API key.
288
+ main API key. Object Storage, when enabled, gets its own separate dedicated
289
+ credential the same way.
284
290
  - A weekly drift-detection plan opens an issue when the real infrastructure
285
291
  no longer matches the code.
286
292
 
@@ -296,6 +302,7 @@ included (Scaleway `fr-par` list prices, excl. VAT):
296
302
  | Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
297
303
  | Object Storage (Terraform state) | a few MB | ~€0 |
298
304
  | Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
305
+ | Object Storage (optional) | only if `--object-storage` | ~€0 idle (a few GB of storage when used) |
299
306
  | **Total to start** | | **under ~€1 / month** |
300
307
 
301
308
  Compute is billed per second, only while actually serving: an idle container
@@ -331,9 +338,20 @@ Scaleway Serverless Containers have no built-in auth in front of public
331
338
  endpoints. Credentials live in Infisical, the container gets
332
339
  `BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
333
340
 
334
- **Can I add more environments?**
335
- Yes: add a workspace, a `<env>.tfvars`, an Infisical environment, and mirror
336
- one job in each workflow.
341
+ **Which environments do I get, and can I change them?**
342
+ You choose at creation: production only, staging + production (default), or
343
+ dev + staging + production — interactively or with `--environments`
344
+ (e.g. `--environments prod` or `--environments dev,staging,prod`). Production
345
+ is always gated by a manual approval; non-production environments enable Basic
346
+ Auth by default. To add one to an existing repo later: add a `<env>.tfvars`,
347
+ an Infisical environment, a matrix entry in the plan/drift workflows, and a
348
+ chained job in `terraform-apply.yml`.
349
+
350
+ **Can I store files, not just rows?**
351
+ Yes, opt in with `--object-storage` (or answer yes interactively). Each
352
+ environment gets its own Object Storage bucket and a dedicated credential; the
353
+ pipeline syncs the `S3_*` coordinates to Infisical for the app to read. It is
354
+ off by default — many apps only need the database.
337
355
 
338
356
  ## CLI reference
339
357
 
@@ -352,7 +370,12 @@ one job in each workflow.
352
370
  --github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
353
371
  --repo-name <name> GitHub repository name (default: project name)
354
372
  --private / --public Repository visibility (default: public)
355
- --no-basic-auth Disable Basic Auth on staging
373
+ --environments <preset> prod | staging+prod | dev+staging+prod
374
+ (or a list like "dev,staging,prod"; default staging+prod)
375
+ --object-storage Provision a per-environment Object Storage bucket
376
+ --no-object-storage Do not provision Object Storage (default)
377
+ --no-basic-auth Disable Basic Auth on non-production environments
378
+ --dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
356
379
  --staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
357
380
  --prod-min-scale <n> Default 0 --prod-max-scale <n> Default 2
358
381
  --config <file.json> Load answers from a JSON file
@@ -368,8 +391,10 @@ is taken from the environment or asked interactively:
368
391
  {
369
392
  "projectName": "my-app",
370
393
  "region": "fr-par",
394
+ "environments": ["staging", "prod"],
395
+ "objectStorage": true,
371
396
  "github": { "repoPrivate": true },
372
- "scaling": { "prodMaxScale": 4 }
397
+ "scaling": { "prod": { "maxScale": 4 } }
373
398
  }
374
399
  ```
375
400
 
@@ -115,8 +115,8 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
115
115
  INFISICAL_PROJECT_ID: infisicalProjectId,
116
116
  INFISICAL_HOST: answers.infisical.host,
117
117
  });
118
- await configureEnvironments(ctx);
119
- await protectMainBranch(ctx);
118
+ await configureEnvironments(ctx, answers);
119
+ await protectMainBranch(ctx, answers);
120
120
  }
121
121
  async function setSecrets(ctx, secrets) {
122
122
  await sodium.ready;
@@ -157,29 +157,27 @@ async function setVariables(ctx, variables) {
157
157
  }
158
158
  }
159
159
  }
160
- /** staging deploys automatically; production requires a manual approval. */
161
- async function configureEnvironments(ctx) {
162
- await ctx.octokit.repos.createOrUpdateEnvironment({
163
- owner: ctx.owner,
164
- repo: ctx.repo,
165
- environment_name: 'staging',
166
- });
167
- await ctx.octokit.repos.createOrUpdateEnvironment({
168
- owner: ctx.owner,
169
- repo: ctx.repo,
170
- environment_name: 'production',
171
- reviewers: [{ type: 'User', id: ctx.ownerId }],
172
- });
160
+ /** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
161
+ async function configureEnvironments(ctx, answers) {
162
+ for (const env of answers.environments) {
163
+ await ctx.octokit.repos.createOrUpdateEnvironment({
164
+ owner: ctx.owner,
165
+ repo: ctx.repo,
166
+ environment_name: env.githubEnvironment,
167
+ ...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
168
+ });
169
+ }
173
170
  }
174
171
  /** Require a green plan on PRs and forbid force-pushes/deletion of main. */
175
- async function protectMainBranch(ctx) {
172
+ async function protectMainBranch(ctx, answers) {
176
173
  await ctx.octokit.repos.updateBranchProtection({
177
174
  owner: ctx.owner,
178
175
  repo: ctx.repo,
179
176
  branch: 'main',
180
177
  required_status_checks: {
181
178
  strict: false,
182
- contexts: ['plan (staging)', 'plan (prod)'],
179
+ // Matches the job names produced by the plan workflow: "plan (<slug>)".
180
+ contexts: answers.environments.map((env) => `plan (${env.slug})`),
183
181
  },
184
182
  enforce_admins: false,
185
183
  required_pull_request_reviews: null,
@@ -43,10 +43,9 @@ async function createProject(host, token, name) {
43
43
  }
44
44
  return data.project;
45
45
  }
46
- async function ensureEnvironment(host, token, project, slug) {
46
+ async function ensureEnvironment(host, token, project, slug, name) {
47
47
  if (project.environments?.some((e) => e.slug === slug))
48
48
  return;
49
- const name = slug === 'prod' ? 'Production' : 'Staging';
50
49
  const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
51
50
  // 400 usually means the environment already exists: fine for idempotency.
52
51
  if (status !== 200 && status !== 400) {
@@ -72,7 +71,14 @@ async function seedSecret(host, token, projectId, environment, name, value) {
72
71
  throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
73
72
  }
74
73
  }
75
- /** Create/reuse the project, ensure staging+prod, seed placeholder secrets. */
74
+ const S3_PLACEHOLDER_KEYS = [
75
+ 'S3_BUCKET',
76
+ 'S3_ENDPOINT',
77
+ 'S3_REGION',
78
+ 'S3_ACCESS_KEY',
79
+ 'S3_SECRET_KEY',
80
+ ];
81
+ /** Create/reuse the project, ensure every environment, seed placeholder secrets. */
76
82
  export async function bootstrapInfisical(answers) {
77
83
  const { host, projectName } = answers.infisical;
78
84
  const token = await login(answers);
@@ -82,14 +88,23 @@ export async function bootstrapInfisical(answers) {
82
88
  project = await createProject(host, token, projectName);
83
89
  createdProject = true;
84
90
  }
85
- await ensureEnvironment(host, token, project, 'staging');
86
- await ensureEnvironment(host, token, project, 'prod');
87
- if (answers.basicAuthStaging) {
88
- await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_USER', 'staging');
89
- await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
91
+ for (const env of answers.environments) {
92
+ await ensureEnvironment(host, token, project, env.slug, env.displayName);
90
93
  }
94
+ // Real values arrive from the pipeline after the first apply; seed placeholders
95
+ // now so the secret paths exist and the container has something to read.
91
96
  const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
92
- await seedSecret(host, token, project.id, 'staging', 'DATABASE_URL', placeholder);
93
- await seedSecret(host, token, project.id, 'prod', 'DATABASE_URL', placeholder);
97
+ for (const env of answers.environments) {
98
+ if (env.basicAuth) {
99
+ await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_USER', env.slug);
100
+ await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
101
+ }
102
+ await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
103
+ if (answers.objectStorage) {
104
+ for (const key of S3_PLACEHOLDER_KEYS) {
105
+ await seedSecret(host, token, project.id, env.slug, key, placeholder);
106
+ }
107
+ }
108
+ }
94
109
  return { projectId: project.id, createdProject };
95
110
  }
package/dist/config.js CHANGED
@@ -1,12 +1,49 @@
1
1
  export const REGIONS = ['fr-par', 'nl-ams', 'pl-waw'];
2
2
  export const DEFAULT_INFISICAL_HOST = 'https://app.infisical.com';
3
3
  export const DEFAULT_REGION = 'fr-par';
4
- export const DEFAULT_SCALING = {
5
- stagingMinScale: 0,
6
- stagingMaxScale: 1,
7
- prodMinScale: 0,
8
- prodMaxScale: 2,
4
+ /**
5
+ * The environments keel knows how to provision. The order here is also the
6
+ * deploy order (dev first, prod last) and drives the apply-workflow chain.
7
+ */
8
+ export const KNOWN_ENV_SLUGS = ['dev', 'staging', 'prod'];
9
+ const ENV_DEFAULTS = {
10
+ dev: {
11
+ displayName: 'Development',
12
+ githubEnvironment: 'dev',
13
+ production: false,
14
+ gated: false,
15
+ minScale: 0,
16
+ maxScale: 1,
17
+ },
18
+ staging: {
19
+ displayName: 'Staging',
20
+ githubEnvironment: 'staging',
21
+ production: false,
22
+ gated: false,
23
+ minScale: 0,
24
+ maxScale: 1,
25
+ },
26
+ prod: {
27
+ displayName: 'Production',
28
+ githubEnvironment: 'production',
29
+ production: true,
30
+ gated: true,
31
+ minScale: 0,
32
+ maxScale: 2,
33
+ },
9
34
  };
35
+ /** Default min/max scale for an environment, for prompts and summaries. */
36
+ export function envDefaultScale(slug) {
37
+ const def = ENV_DEFAULTS[slug];
38
+ return { minScale: def.minScale, maxScale: def.maxScale };
39
+ }
40
+ /** Named presets exposed on the CLI; the value is the list of env slugs. */
41
+ export const ENV_PRESETS = {
42
+ prod: ['prod'],
43
+ 'staging+prod': ['staging', 'prod'],
44
+ 'dev+staging+prod': ['dev', 'staging', 'prod'],
45
+ };
46
+ export const DEFAULT_ENV_PRESET = 'staging+prod';
10
47
  export class ConfigError extends Error {
11
48
  }
12
49
  const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
@@ -32,6 +69,35 @@ export function validateScale(value, label) {
32
69
  }
33
70
  return n;
34
71
  }
72
+ /**
73
+ * Parse a `--environments` value into an ordered, de-duplicated slug list.
74
+ * Accepts preset names ("staging+prod") or free lists ("dev,staging,prod").
75
+ */
76
+ export function parseEnvironments(raw) {
77
+ const trimmed = raw.trim();
78
+ if (ENV_PRESETS[trimmed])
79
+ return ENV_PRESETS[trimmed];
80
+ const slugs = trimmed
81
+ .split(/[\s,+]+/)
82
+ .map((s) => s.trim().toLowerCase())
83
+ .filter(Boolean);
84
+ return normalizeEnvSlugs(slugs);
85
+ }
86
+ /** Validate slugs against the known set and return them in deploy order. */
87
+ export function normalizeEnvSlugs(slugs) {
88
+ for (const slug of slugs) {
89
+ if (!KNOWN_ENV_SLUGS.includes(slug)) {
90
+ throw new ConfigError(`Unknown environment "${slug}". Supported environments: ${KNOWN_ENV_SLUGS.join(', ')} ` +
91
+ `(or a preset: ${Object.keys(ENV_PRESETS).join(', ')}).`);
92
+ }
93
+ }
94
+ const chosen = new Set(slugs);
95
+ const ordered = KNOWN_ENV_SLUGS.filter((s) => chosen.has(s));
96
+ if (ordered.length === 0) {
97
+ throw new ConfigError('At least one environment is required.');
98
+ }
99
+ return ordered;
100
+ }
35
101
  export function validateUrl(value, label) {
36
102
  let parsed;
37
103
  try {
@@ -79,26 +145,68 @@ export function fromEnv(env) {
79
145
  export function mergeAnswers(...sources) {
80
146
  const out = { scaleway: {}, infisical: {}, github: {}, scaling: {} };
81
147
  for (const src of sources) {
82
- for (const key of ['projectName', 'region', 'targetDir', 'basicAuthStaging']) {
148
+ for (const key of [
149
+ 'projectName',
150
+ 'region',
151
+ 'targetDir',
152
+ 'basicAuth',
153
+ 'objectStorage',
154
+ ]) {
83
155
  const value = src[key];
84
156
  if (value !== undefined) {
85
157
  out[key] = value;
86
158
  }
87
159
  }
88
- for (const group of ['scaleway', 'infisical', 'github', 'scaling']) {
160
+ if (src.environments !== undefined) {
161
+ out.environments = src.environments;
162
+ }
163
+ for (const group of ['scaleway', 'infisical', 'github']) {
89
164
  for (const [k, v] of Object.entries(src[group] ?? {})) {
90
165
  if (v !== undefined) {
91
166
  out[group][k] = v;
92
167
  }
93
168
  }
94
169
  }
170
+ // Scaling is a per-slug record: merge each slug's fields, deepest wins.
171
+ for (const [slug, override] of Object.entries(src.scaling ?? {})) {
172
+ if (!override)
173
+ continue;
174
+ const current = out.scaling[slug] ?? {};
175
+ if (override.minScale !== undefined)
176
+ current.minScale = override.minScale;
177
+ if (override.maxScale !== undefined)
178
+ current.maxScale = override.maxScale;
179
+ out.scaling[slug] = current;
180
+ }
95
181
  }
96
182
  return out;
97
183
  }
184
+ /** Build the resolved environment list from selected slugs + overrides. */
185
+ export function resolveEnvironments(slugs, basicAuth, overrides) {
186
+ return slugs.map((slug) => {
187
+ const def = ENV_DEFAULTS[slug];
188
+ const ov = overrides[slug] ?? {};
189
+ return {
190
+ slug,
191
+ displayName: def.displayName,
192
+ githubEnvironment: def.githubEnvironment,
193
+ production: def.production,
194
+ gated: def.gated,
195
+ // Basic Auth is a non-production safety net; production is never gated by it.
196
+ basicAuth: def.production ? false : basicAuth,
197
+ minScale: validateScale(ov.minScale ?? def.minScale, `${slug} min scale`),
198
+ maxScale: validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`),
199
+ };
200
+ });
201
+ }
98
202
  /** Validate a fully-collected set of answers and freeze it into a Config. */
99
203
  export function finalizeAnswers(partial) {
100
204
  const projectName = validateProjectName(requireString(partial.projectName, 'project name'));
101
205
  const region = validateRegion(partial.region ?? DEFAULT_REGION);
206
+ const slugs = partial.environments && partial.environments.length > 0
207
+ ? normalizeEnvSlugs(partial.environments)
208
+ : ENV_PRESETS[DEFAULT_ENV_PRESET];
209
+ const basicAuth = partial.basicAuth ?? true;
102
210
  return {
103
211
  projectName,
104
212
  region,
@@ -121,13 +229,9 @@ export function finalizeAnswers(partial) {
121
229
  repoName: validateProjectName(partial.github.repoName?.trim() || projectName),
122
230
  repoPrivate: partial.github.repoPrivate ?? false,
123
231
  },
124
- basicAuthStaging: partial.basicAuthStaging ?? true,
125
- scaling: {
126
- stagingMinScale: validateScale(partial.scaling.stagingMinScale ?? DEFAULT_SCALING.stagingMinScale, 'staging min scale'),
127
- stagingMaxScale: validateScale(partial.scaling.stagingMaxScale ?? DEFAULT_SCALING.stagingMaxScale, 'staging max scale'),
128
- prodMinScale: validateScale(partial.scaling.prodMinScale ?? DEFAULT_SCALING.prodMinScale, 'prod min scale'),
129
- prodMaxScale: validateScale(partial.scaling.prodMaxScale ?? DEFAULT_SCALING.prodMaxScale, 'prod max scale'),
130
- },
232
+ basicAuth,
233
+ objectStorage: partial.objectStorage ?? false,
234
+ environments: resolveEnvironments(slugs, basicAuth, partial.scaling),
131
235
  };
132
236
  }
133
237
  /** List which required values are still missing, for non-interactive runs. */
package/dist/generate.js CHANGED
@@ -12,17 +12,53 @@ const RENAMES = {
12
12
  export function templatesDir() {
13
13
  return fileURLToPath(new URL('../templates', import.meta.url));
14
14
  }
15
+ /** Global tokens shared by every non per-environment template. */
15
16
  export function tokenMap(answers) {
17
+ const slugs = answers.environments.map((e) => e.slug);
16
18
  return {
17
19
  __PROJECT_NAME__: answers.projectName,
18
20
  __REGION__: answers.region,
19
21
  __TF_STATE_BUCKET__: answers.stateBucket,
20
- __STAGING_MIN_SCALE__: String(answers.scaling.stagingMinScale),
21
- __STAGING_MAX_SCALE__: String(answers.scaling.stagingMaxScale),
22
- __PROD_MIN_SCALE__: String(answers.scaling.prodMinScale),
23
- __PROD_MAX_SCALE__: String(answers.scaling.prodMaxScale),
22
+ // YAML inline list for the plan/drift job matrix, e.g. [staging, prod].
23
+ __ENV_MATRIX__: `[${slugs.join(', ')}]`,
24
+ // HCL list for the `environment` variable validation in variables.tf.
25
+ __ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
24
26
  };
25
27
  }
28
+ /** Tokens for one rendered `<env>.tfvars` file. */
29
+ function envTfvarsTokens(answers, env) {
30
+ return {
31
+ __PROJECT_NAME__: answers.projectName,
32
+ __REGION__: answers.region,
33
+ __ENVIRONMENT__: env.slug,
34
+ __ENABLE_BASIC_AUTH__: String(env.basicAuth),
35
+ __ENABLE_OBJECT_STORAGE__: String(answers.objectStorage),
36
+ __MIN_SCALE__: String(env.minScale),
37
+ __MAX_SCALE__: String(env.maxScale),
38
+ };
39
+ }
40
+ /** Templates rendered specially (per environment) instead of copied 1:1. */
41
+ const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
42
+ function isSpecial(rel) {
43
+ return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');
44
+ }
45
+ /**
46
+ * Assemble the apply workflow: the shared header, then one job per environment
47
+ * in deploy order, each `needs:` the previous so applies run sequentially.
48
+ */
49
+ function renderApplyWorkflow(source, answers, globalTokens) {
50
+ const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), globalTokens, '_partials/apply-header.yml');
51
+ const jobTemplate = readFileSync(join(source, '_partials/apply-job.yml'), 'utf8');
52
+ const jobs = answers.environments.map((env, i) => {
53
+ const previous = answers.environments[i - 1];
54
+ return renderContent(jobTemplate, {
55
+ __ENV_SLUG__: env.slug,
56
+ __GH_ENVIRONMENT__: env.githubEnvironment,
57
+ __NEEDS_LINE__: previous ? ` needs: apply-${previous.slug}\n` : '',
58
+ }, '_partials/apply-job.yml');
59
+ });
60
+ return header + jobs.join('\n');
61
+ }
26
62
  function renderContent(content, tokens, file) {
27
63
  let out = content;
28
64
  for (const [token, value] of Object.entries(tokens)) {
@@ -59,6 +95,8 @@ export function generateProject(answers, options = {}) {
59
95
  mkdirSync(target, { recursive: true });
60
96
  const written = [];
61
97
  for (const rel of walk(source)) {
98
+ if (isSpecial(rel))
99
+ continue;
62
100
  const parts = rel.split('/');
63
101
  const fileName = parts[parts.length - 1] ?? rel;
64
102
  const destRel = [...parts.slice(0, -1), RENAMES[fileName] ?? fileName].join('/');
@@ -71,6 +109,18 @@ export function generateProject(answers, options = {}) {
71
109
  }
72
110
  written.push(destRel);
73
111
  }
112
+ // Per-environment tfvars: one <slug>.tfvars from the shared env.tfvars template.
113
+ const envTemplate = readFileSync(join(source, 'env.tfvars'), 'utf8');
114
+ for (const env of answers.environments) {
115
+ const destRel = `${env.slug}.tfvars`;
116
+ writeFileSync(join(target, destRel), renderContent(envTemplate, envTfvarsTokens(answers, env), 'env.tfvars'));
117
+ written.push(destRel);
118
+ }
119
+ // Apply workflow: a header plus one deploy job per environment, chained so
120
+ // each environment applies only after the previous one succeeded.
121
+ const applyDestRel = '.github/workflows/terraform-apply.yml';
122
+ writeFileSync(join(target, applyDestRel), renderApplyWorkflow(source, answers, tokens));
123
+ written.push(applyDestRel);
74
124
  // backend.hcl (git-ignored) so local terraform runs work out of the box.
75
125
  cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
76
126
  if (options.git !== false) {