@gambi97/keel-cli 0.3.3 → 0.3.5

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
@@ -62,7 +62,8 @@ non-empty repository is reported immediately, and only that answer is asked
62
62
  again). It picks up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your
63
63
  environment as defaults, then shows a **complete summary of what it will
64
64
  create and where**. Nothing is touched before you confirm. When it finishes, push to `main` (or merge the
65
- first PR) and the pipeline provisions the infrastructure.
65
+ first PR) and the pipeline provisions the non-production infrastructure; a
66
+ version tag (`vX.Y.Z`) promotes to production.
66
67
 
67
68
  Non-interactive and dry-run:
68
69
 
@@ -150,14 +151,16 @@ flowchart TD
150
151
  CLI -->|creates state bucket| SCW[(Scaleway<br/>Object Storage)]
151
152
  CLI -->|creates repo, secrets,<br/>variables, branch rules| GH[GitHub repo]
152
153
  CLI -->|creates project, envs,<br/>placeholder secrets| INF[Infisical]
153
- GH -->|push to main| ACT[GitHub Actions]
154
- ACT -->|terraform apply| PROD[Scaleway:<br/>Container + SQL + Registry]
154
+ GH -->|merge to main| ACT[GitHub Actions]
155
+ GH -->|tag vX.Y.Z| ACT
156
+ ACT -->|apply non-prod / prod| PROD[Scaleway:<br/>Container + SQL + Registry]
155
157
  INF -.->|app secrets at runtime| PROD
156
158
  ```
157
159
 
158
160
  The CLI does the **bootstrap**, in seconds, on your machine. The first
159
161
  `terraform apply` runs **in CI** on the first push to `main`, so nobody needs
160
- local tooling or production credentials.
162
+ local tooling or production credentials. Non-production environments deploy on
163
+ every merge; production is promoted by pushing a version tag.
161
164
 
162
165
  ## What gets created, and when
163
166
 
@@ -167,7 +170,7 @@ local tooling or production credentials.
167
170
  | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
168
171
  | Your machine | The generated repo: Terraform, workflows, README, initial git commit |
169
172
  | Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
170
- | 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` |
173
+ | GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` promoted by a version tag); branch protection on `main` |
171
174
  | Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), `DATABASE_URL` / `APP_URL` placeholders per environment, and `S3_*` placeholders when Object Storage is enabled |
172
175
 
173
176
  **Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
@@ -190,7 +193,7 @@ my-app/
190
193
  ├── README.md # operating manual for the repo
191
194
  ├── .github/workflows/
192
195
  │ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
193
- │ ├── terraform-apply.yml # main: apply each env in order; production gated
196
+ │ ├── terraform-apply.yml # merge to main: apply non-prod; tag vX.Y.Z: apply prod
194
197
  │ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
195
198
  ├── .keel/manifest.json # committed record: keel version, contract version, options
196
199
  ├── versions.tf · providers.tf · backend.tf
@@ -222,8 +225,8 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
222
225
  short version of the first deploy:
223
226
 
224
227
  1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
225
- databases (and an Object Storage bucket, if enabled). Approve the
226
- `production` gate when prompted.
228
+ databases for the non-production environments (and an Object Storage bucket,
229
+ if enabled). Production waits for a version tag (step 5).
227
230
  2. **Build and push your app image** to the registry endpoint from the apply
228
231
  output (replace `<env>` with your target environment):
229
232
  ```sh
@@ -234,9 +237,12 @@ short version of the first deploy:
234
237
  3. **Set `container_image`** in the tfvars and open a PR: the next apply
235
238
  creates the containers.
236
239
  4. **Replace the placeholder secrets** in Infisical with real values. The app
237
- reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from its
238
- environment; non-production environments also receive `BASIC_AUTH_ENABLED=true`
239
- and enforce it.
240
+ reads `DATABASE_URL`, `APP_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from
241
+ its environment; non-production environments also receive
242
+ `BASIC_AUTH_ENABLED=true` and enforce it.
243
+ 5. **Release to production**: push a version tag — `git tag v1.0.0 && git push
244
+ --tags` — and the production apply runs against that commit. A merge to
245
+ `main` never deploys production.
240
246
 
241
247
  ## Prerequisites
242
248
 
@@ -293,7 +299,9 @@ creating anything**.
293
299
  - Actions secrets are encrypted client-side (libsodium sealed box) before
294
300
  upload.
295
301
  - `main` is protected: force pushes and deletion blocked, PRs need a green
296
- plan, production applies need manual approval.
302
+ plan. Production never deploys from a merge — it is gated behind a version
303
+ tag, a free promotion control that works on any GitHub plan (required-reviewer
304
+ rules need a paid plan on private repos) and lives auditably in the repo.
297
305
  - Terraform state lives in a private, versioned bucket with S3-native state
298
306
  locking (`use_lockfile`), so concurrent applies cannot corrupt it.
299
307
  - The state bucket is **restricted by a bucket policy** to the identity that
@@ -373,12 +381,13 @@ endpoints. Credentials live in Infisical, the container gets
373
381
  **Which environments do I get, and can I change them?**
374
382
  You choose at creation: production only, staging + production (default), or
375
383
  dev + staging + production — interactively or with `--environments`
376
- (e.g. `--environments prod` or `--environments dev,staging,prod`). Production
377
- is always gated by a manual approval; non-production environments enable Basic
378
- Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
379
- (the plan and drift workflows discover environments from the tfvars files at
380
- the repo root automatically), an Infisical environment with the same slug, and
381
- a chained job in `terraform-apply.yml`.
384
+ (e.g. `--environments prod` or `--environments dev,staging,prod`). Non-production
385
+ environments deploy on merge to `main` and enable Basic Auth by default;
386
+ production is promoted by pushing a version tag (`vX.Y.Z`), never by a merge. To
387
+ add a non-production environment later: add a `<env>.tfvars` (the plan and drift
388
+ workflows discover environments from the tfvars files at the repo root
389
+ automatically), an Infisical environment with the same slug, and a `main`-gated
390
+ job in `terraform-apply.yml`.
382
391
 
383
392
  **Can I extend the generated repo with my own Terraform?**
384
393
  Yes, and additions never edit generated files: drop a new `.tf` file at the
package/dist/config.js CHANGED
@@ -46,6 +46,17 @@ export const ENV_PRESETS = {
46
46
  'dev+staging+prod': ['dev', 'staging', 'prod'],
47
47
  };
48
48
  export const DEFAULT_ENV_PRESET = 'staging+prod';
49
+ /**
50
+ * Lock a resume's configuration to what the repo was generated with. Scaling
51
+ * is intentionally not carried: it only shapes the tfvars, which are already
52
+ * generated on any run that has a manifest, so post-generate steps never read it.
53
+ */
54
+ export function hydrateConfigFromManifest(partial, manifest) {
55
+ partial.region = manifest.region;
56
+ partial.environments = manifest.environments;
57
+ partial.objectStorage = manifest.options.objectStorage;
58
+ partial.basicAuth = manifest.options.basicAuth;
59
+ }
49
60
  export class ConfigError extends Error {
50
61
  }
51
62
  const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
package/dist/generate.js CHANGED
@@ -29,6 +29,28 @@ function renderManifest(answers) {
29
29
  };
30
30
  return `${JSON.stringify(manifest, null, 2)}\n`;
31
31
  }
32
+ /**
33
+ * Read the committed manifest from a target directory, or undefined when there
34
+ * is none (a fresh run). Used by the resume paths to lock configuration to
35
+ * what the repo was generated with instead of asking for it again.
36
+ */
37
+ export function readManifest(targetDir) {
38
+ try {
39
+ const raw = readFileSync(join(targetDir, MANIFEST_FILE), 'utf8');
40
+ const m = JSON.parse(raw);
41
+ if (typeof m.projectName === 'string' &&
42
+ typeof m.region === 'string' &&
43
+ Array.isArray(m.environments) &&
44
+ typeof m.options?.objectStorage === 'boolean' &&
45
+ typeof m.options?.basicAuth === 'boolean') {
46
+ return m;
47
+ }
48
+ }
49
+ catch {
50
+ // No manifest or unreadable/invalid: treat as a fresh run.
51
+ }
52
+ return undefined;
53
+ }
32
54
  /** Files whose real name would confuse npm packaging are stored renamed. */
33
55
  const RENAMES = {
34
56
  _gitignore: '.gitignore',
@@ -77,16 +99,32 @@ function isSpecial(rel) {
77
99
  * in deploy order, each `needs:` the previous so applies run sequentially.
78
100
  */
79
101
  function renderApplyWorkflow(source, answers, globalTokens) {
80
- const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), globalTokens, '_partials/apply-header.yml');
102
+ // Non-production environments deploy on merge to main (chained in order);
103
+ // production is promoted by a version tag. The triggers and the per-job
104
+ // guards are assembled from which environments exist, so a prod-only repo
105
+ // has no branch trigger and a prod-less selection has no tag trigger.
106
+ const nonProd = answers.environments.filter((env) => !env.production);
107
+ const prod = answers.environments.find((env) => env.production);
108
+ const triggers = [];
109
+ if (nonProd.length > 0)
110
+ triggers.push(' branches: [main]');
111
+ if (prod)
112
+ triggers.push(' tags: ["v*.*.*"]');
113
+ const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), { ...globalTokens, __APPLY_TRIGGERS__: triggers.join('\n') }, '_partials/apply-header.yml');
81
114
  const jobTemplate = readFileSync(join(source, '_partials/apply-job.yml'), 'utf8');
82
- const jobs = answers.environments.map((env, i) => {
83
- const previous = answers.environments[i - 1];
84
- return renderContent(jobTemplate, {
85
- __ENV_SLUG__: env.slug,
86
- __GH_ENVIRONMENT__: env.githubEnvironment,
87
- __NEEDS_LINE__: previous ? ` needs: apply-${previous.slug}\n` : '',
88
- }, '_partials/apply-job.yml');
115
+ const renderJob = (env, applyIf, needs) => renderContent(jobTemplate, {
116
+ __ENV_SLUG__: env.slug,
117
+ __GH_ENVIRONMENT__: env.githubEnvironment,
118
+ __APPLY_IF__: applyIf,
119
+ __NEEDS_LINE__: needs,
120
+ }, '_partials/apply-job.yml');
121
+ const jobs = nonProd.map((env, i) => {
122
+ const previous = nonProd[i - 1];
123
+ return renderJob(env, "github.ref == 'refs/heads/main'", previous ? ` needs: apply-${previous.slug}\n` : '');
89
124
  });
125
+ if (prod) {
126
+ jobs.push(renderJob(prod, "startsWith(github.ref, 'refs/tags/')", ''));
127
+ }
90
128
  return header + jobs.join('\n');
91
129
  }
92
130
  function renderContent(content, tokens, file) {
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { parseArgs } from 'node:util';
5
- import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
5
+ import { ConfigError, finalizeAnswers, fromEnv, hydrateConfigFromManifest, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
6
6
  import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js';
7
- import { generateProject, GenerateError } from './generate.js';
7
+ import { generateProject, GenerateError, readManifest } from './generate.js';
8
8
  import { toolVersion } from './meta.js';
9
9
  import { confirmSummary, fillMissing } from './prompts.js';
10
10
  import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
@@ -333,6 +333,16 @@ async function main() {
333
333
  collected = await fillMissing(partial, { advanced: flags.advanced, dryRun: flags.dryRun });
334
334
  }
335
335
  else if (!flags.dryRun) {
336
+ // Resuming non-interactively: lock configuration to the committed manifest
337
+ // so flags/defaults can't silently diverge from the generated repo (e.g. a
338
+ // resume without --object-storage must not flip it off). Same source of
339
+ // truth as the interactive path.
340
+ const resumeDir = partial.targetDir?.trim() || partial.projectName;
341
+ const manifest = resumeDir ? readManifest(resumeDir) : undefined;
342
+ if (manifest && manifest.projectName === partial.projectName) {
343
+ hydrateConfigFromManifest(partial, manifest);
344
+ log.info(`Resuming "${manifest.projectName}" — configuration locked to its .keel manifest.`);
345
+ }
336
346
  const missing = missingRequired(partial);
337
347
  if (missing.length > 0) {
338
348
  cancel(`Non-interactive run is missing required values:\n - ${missing.join('\n - ')}`);
package/dist/prompts.js CHANGED
@@ -2,7 +2,8 @@ import * as p from '@clack/prompts';
2
2
  import { authenticate, createContext, GitHubError, inspectRepo, assertRepoUsable, listOwnedRepos, } from './bootstrap/github.js';
3
3
  import { InfisicalError, validateInfisical } from './bootstrap/infisical.js';
4
4
  import { ScalewayError, validateScalewayCredentials } from './bootstrap/scaleway.js';
5
- import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
5
+ import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, hydrateConfigFromManifest, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
6
+ import { readManifest } from './generate.js';
6
7
  import { isDone, loadState } from './state.js';
7
8
  import { log } from './ui.js';
8
9
  function bail() {
@@ -45,6 +46,17 @@ const text = (message, placeholder) => ask(p.text({
45
46
  export async function fillMissing(partial, options) {
46
47
  const out = structuredClone(partial);
47
48
  await askProjectName(out);
49
+ // Resuming an existing project: its configuration is frozen (the repo is
50
+ // already generated with it), so lock it from the committed manifest instead
51
+ // of asking again. Credentials are never persisted, so the provider blocks
52
+ // still run — set SCW_*/INFISICAL_*/GITHUB_TOKEN in the env to skip typing.
53
+ const resumeDir = out.targetDir?.trim() || out.projectName;
54
+ const manifest = readManifest(resumeDir);
55
+ const resuming = manifest !== undefined && manifest.projectName === out.projectName;
56
+ if (manifest && resuming) {
57
+ hydrateConfigFromManifest(out, manifest);
58
+ log.info(`Resuming "${out.projectName}" — region, environments and options are locked to its .keel manifest.`);
59
+ }
48
60
  // Each block opens with a "┌ <Provider>" section corner and closes with a
49
61
  // "└ <Provider> connected — …" line, so every question flows inside its own
50
62
  // section and the next one opens only when the previous is verified.
@@ -55,7 +67,10 @@ export async function fillMissing(partial, options) {
55
67
  }
56
68
  // Configuration comes last: with the accounts verified, these are the only
57
69
  // real choices left. In a dry run it is all that is asked after the name.
58
- await askConfiguration(out, options);
70
+ // On resume it is skipped entirely — the manifest already fixed it.
71
+ if (!resuming) {
72
+ await askConfiguration(out, options);
73
+ }
59
74
  return out;
60
75
  }
61
76
  async function askProjectName(out) {
package/dist/ui.js CHANGED
@@ -79,9 +79,10 @@ export function renderNextSteps(answers, repoUrl, containerUrlHint) {
79
79
  ` registry created by the first pipeline run (${containerUrlHint}).`,
80
80
  ` 2. Replace the placeholder secrets in Infisical (${answers.infisical.host})`,
81
81
  ` project "${answers.infisical.projectName}" with real values.`,
82
- ` 3. Push to main (or merge a PR) to trigger the first terraform apply:`,
82
+ ` 3. Push to main (or merge a PR) to deploy the non-production environments:`,
83
83
  ` ${repoUrl}/actions`,
84
84
  ` 4. Set container_image in ${answers.environments.map((e) => `${e.slug}.tfvars`).join(' / ')} once an image exists.`,
85
+ ` 5. Release to production with a version tag: git tag v1.0.0 && git push --tags`,
85
86
  '',
86
87
  `Repository: ${repoUrl}`,
87
88
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.",
5
5
  "keywords": [
6
6
  "keel",
@@ -40,12 +40,18 @@ Credentials live in GitHub Actions encrypted secrets (CI) and in Infisical
40
40
 
41
41
  ```
42
42
  PR to main -> terraform fmt + validate + plan (every environment), shown as PR checks
43
- merge/push main -> apply each environment in order; any gated environment (production) waits for manual approval
43
+ merge/push main -> apply non-production environments in order (dev, staging)
44
+ tag vX.Y.Z -> apply production (the commit the tag points at)
44
45
  ```
45
46
 
46
47
  - `main` is protected: PRs need a green plan, force pushes are blocked.
47
- - Environments apply in order, each `needs:` the previous one; a gated
48
- environment (production) waits for approval on its GitHub Environment.
48
+ - Non-production environments apply on every merge to `main`, in order, each
49
+ `needs:` the previous one a continuously-deployed staging.
50
+ - **Production is promoted by a version tag**, not by a merge: push a tag like
51
+ `v1.2.0` and the production apply runs against that commit. The tag is the
52
+ gate — it works on any GitHub plan (unlike required-reviewer rules, which
53
+ need a paid plan on private repos), lives in the repo, and rolling back is
54
+ re-tagging an earlier commit. Cut one with `git tag v1.2.0 && git push --tags`.
49
55
  - After each apply, the pipeline syncs the secrets Terraform produced to
50
56
  Infisical for that environment: a ready-to-use Postgres `DATABASE_URL`
51
57
  (whose credential is a dedicated IAM application that can only read/write
@@ -60,9 +66,10 @@ merge/push main -> apply each environment in order; any gated environment (pro
60
66
  ## First deploy
61
67
 
62
68
  1. **Merge or push to `main`.** The first pipeline run creates registry and
63
- databases (and the Object Storage bucket, if enabled). The Serverless
64
- Container is intentionally skipped until an image exists (`container_image`
65
- is empty), so the first apply is green.
69
+ databases for the non-production environments (and the Object Storage
70
+ bucket, if enabled). The Serverless Container is intentionally skipped until
71
+ an image exists (`container_image` is empty), so the first apply is green.
72
+ Production waits for a version tag (step 5).
66
73
 
67
74
  2. **Build and push the app image** (any Dockerfile, listening on port 8080
68
75
  by default), replacing `<env>` with your target environment (e.g. staging):
@@ -90,7 +97,19 @@ merge/push main -> apply each environment in order; any gated environment (pro
90
97
 
91
98
  Every secret in the Infisical environment is injected into the container
92
99
  as a secret environment variable, so adding an app secret is: add it in
93
- Infisical, re-run the apply (any merge to `main`).
100
+ Infisical, re-run the apply (a merge to `main` for non-prod, a new tag for
101
+ production).
102
+
103
+ 5. **Release to production**: once staging looks good, cut a version tag on the
104
+ commit you want live and push it:
105
+
106
+ ```sh
107
+ git tag v1.0.0
108
+ git push --tags
109
+ ```
110
+
111
+ The production apply runs against that commit. Nothing reaches production
112
+ without a tag, so a merge to `main` can never deploy it by accident.
94
113
 
95
114
  ## Basic Auth on non-production environments
96
115
 
@@ -115,13 +134,13 @@ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
115
134
  secrets (repo Settings > Secrets and variables > Actions).
116
135
  - **Add a custom domain**: add a `scaleway_container_domain` resource in
117
136
  `modules/app_stack` pointing at the container, plus your DNS record.
118
- - **Add an environment**: add a `<env>.tfvars` at the repo root (the plan and
119
- drift workflows discover environments from the tfvars files automatically),
120
- create an Infisical environment with the same slug, and add a job in
121
- `.github/workflows/terraform-apply.yml` (mirror an existing one and set its
122
- `needs:` to chain after the previous environment). Note: the new `plan
123
- (<env>)` check becomes required on `main` only after you add it in the
124
- branch-protection settings.
137
+ - **Add a non-production environment**: add a `<env>.tfvars` at the repo root
138
+ (the plan and drift workflows discover environments from the tfvars files
139
+ automatically), create an Infisical environment with the same slug, and add a
140
+ job in `.github/workflows/terraform-apply.yml` mirror an existing non-prod
141
+ job (`if: github.ref == 'refs/heads/main'`) and set its `needs:` to chain
142
+ after the previous one. Note: the new `plan (<env>)` check becomes required on
143
+ `main` only after you add it in the branch-protection settings.
125
144
  - **Pin provider versions**: after any local `terraform init`, commit the
126
145
  generated `.terraform.lock.hcl` so CI resolves the exact same provider
127
146
  builds on every run.
@@ -1,13 +1,19 @@
1
1
  name: Terraform apply
2
2
 
3
+ # Non-production environments deploy on every merge to main; production is
4
+ # promoted by pushing a version tag (vX.Y.Z), so a merge never touches prod.
5
+ # Tag-based promotion is the free, git-native gate (no paid environment rule):
6
+ # the tag lives in the repo, is auditable, and rolling back is re-tagging.
3
7
  on:
4
8
  push:
5
- branches: [main]
9
+ __APPLY_TRIGGERS__
6
10
 
7
11
  permissions:
8
12
  contents: read
9
13
 
10
14
  concurrency:
15
+ # Serialize all applies against the shared state bucket; a tag deploy and a
16
+ # main deploy never run at the same time.
11
17
  group: terraform-apply
12
18
  cancel-in-progress: false
13
19
 
@@ -1,8 +1,9 @@
1
1
  apply-__ENV_SLUG__:
2
2
  name: apply (__ENV_SLUG__)
3
3
  runs-on: ubuntu-latest
4
- __NEEDS_LINE__ # The GitHub environment may carry a required-reviewer rule, gating this job.
5
- environment: __GH_ENVIRONMENT__
4
+ # Non-prod runs on merge to main; prod runs only on a version tag.
5
+ if: __APPLY_IF__
6
+ __NEEDS_LINE__ environment: __GH_ENVIRONMENT__
6
7
  steps:
7
8
  - uses: actions/checkout@v4
8
9