@gambi97/keel-cli 0.1.1 → 0.2.1

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
@@ -28,10 +28,19 @@ provisioned by Terraform, deployed by GitHub Actions, with secrets in
28
28
  Docker image, keel gives it a place to run. You answer a few questions, push
29
29
  to `main`, and the infrastructure is live.
30
30
 
31
+ **keel is a starting point, not a control plane.** It runs once to lay the
32
+ foundation and hand you a repository you fully own. From then on, day-2 work —
33
+ scaling, new environments, custom domains, rotating secrets — happens in that
34
+ repository: edit a tfvars line, open a PR, merge. keel does not stay in the
35
+ loop, has nothing to update, and never manages your infrastructure after the
36
+ first setup. If you outgrow the generated layout, it is plain Terraform you
37
+ can take anywhere.
38
+
31
39
  - **Near-free to start.** Compute and database scale to zero; an idle project
32
40
  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.
41
+ - **Scales with your product.** Pick your environments (production only, or
42
+ add staging and dev) and, if you need it, an application file store; growing
43
+ is a one-line tfvars change reviewed in a PR.
35
44
  - **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
36
45
  - **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
37
46
  secret manager, wired for you.
@@ -46,10 +55,13 @@ GitHub (see [Prerequisites](#prerequisites) for how to get each one).
46
55
  npx @gambi97/keel-cli
47
56
  ```
48
57
 
49
- The CLI asks for a project name, region, repository name and visibility, picks
50
- up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your environment as defaults,
51
- then shows a **complete summary of what it will create and where**. Nothing is
52
- touched before you confirm. When it finishes, push to `main` (or merge the
58
+ The CLI asks for a project name, region and environments, then walks one
59
+ provider at a time GitHub, Infisical, Scaleway verifying each set of
60
+ credentials with read-only calls the moment you enter it (a bad token or a
61
+ non-empty repository is reported immediately, and only that answer is asked
62
+ again). It picks up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your
63
+ environment as defaults, then shows a **complete summary of what it will
64
+ create and where**. Nothing is touched before you confirm. When it finishes, push to `main` (or merge the
53
65
  first PR) and the pipeline provisions the infrastructure.
54
66
 
55
67
  Non-interactive and dry-run:
@@ -151,21 +163,22 @@ local tooling or production credentials.
151
163
 
152
164
  **Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
153
165
 
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 |
166
+ | Where | What |
167
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
168
+ | Your machine | The generated repo: Terraform, workflows, README, initial git commit |
169
+ | 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` |
171
+ | 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
172
 
161
173
  **Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
162
174
 
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` |
175
+ | Scaleway resource | Notes |
176
+ | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | Registry namespace | Private, one per environment |
178
+ | Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
179
+ | Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
180
+ | IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
181
+ | 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
182
 
170
183
  No custom domain is configured: the app gets an auto-generated Scaleway URL.
171
184
  Add one later with a single `scaleway_container_domain` resource.
@@ -176,28 +189,29 @@ Add one later with a single `scaleway_container_domain` resource.
176
189
  my-app/
177
190
  ├── README.md # operating manual for the repo
178
191
  ├── .github/workflows/
179
- │ ├── terraform-plan.yml # PR: fmt + validate + plan (staging & prod)
180
- │ ├── terraform-apply.yml # main: apply staging -> approval -> apply prod
192
+ │ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
193
+ │ ├── terraform-apply.yml # main: apply each env in order; production gated
181
194
  │ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
182
195
  ├── versions.tf · providers.tf · backend.tf
183
196
  ├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
184
197
  ├── 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
198
+ ├── <env>.tfvars # one per environment, non-sensitive config, committed
199
+ └── modules/app_stack/ # registry + container + database (+ object storage) + credentials
187
200
  ```
188
201
 
189
- Environments are separated with **Terraform workspaces**: same code, two
190
- independent states in one bucket, differences confined to the two `.tfvars`
191
- files.
202
+ Environments are separated with **Terraform workspaces**: same code, one
203
+ independent state per environment in one bucket, differences confined to the
204
+ per-environment `<env>.tfvars` files.
192
205
 
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 |
206
+ | Data | Lives in | Why |
207
+ | ----------------------------------- | ------------------------ | -------------------------------------------------------------------- |
208
+ | Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
209
+ | Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
210
+ | Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
211
+ | Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
212
+ | Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
213
+ | Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
214
+ | Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
201
215
 
202
216
  ## After the bootstrap
203
217
 
@@ -206,19 +220,21 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
206
220
  short version of the first deploy:
207
221
 
208
222
  1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
209
- databases. Approve the `production` gate when prompted.
223
+ databases (and an Object Storage bucket, if enabled). Approve the
224
+ `production` gate when prompted.
210
225
  2. **Build and push your app image** to the registry endpoint from the apply
211
- output:
226
+ output (replace `<env>` with your target environment):
212
227
  ```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
228
+ docker login rg.fr-par.scw.cloud/my-app-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
229
+ docker build -t rg.fr-par.scw.cloud/my-app-<env>/app:latest .
230
+ docker push rg.fr-par.scw.cloud/my-app-<env>/app:latest
216
231
  ```
217
232
  3. **Set `container_image`** in the tfvars and open a PR: the next apply
218
233
  creates the containers.
219
234
  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.
235
+ reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from its
236
+ environment; non-production environments also receive `BASIC_AUTH_ENABLED=true`
237
+ and enforce it.
222
238
 
223
239
  ## Prerequisites
224
240
 
@@ -278,9 +294,18 @@ creating anything**.
278
294
  plan, production applies need manual approval.
279
295
  - Terraform state lives in a private, versioned bucket with S3-native state
280
296
  locking (`use_lockfile`), so concurrent applies cannot corrupt it.
297
+ - The state bucket is **restricted by a bucket policy** to the identity that
298
+ owns the API key you give keel — the same identity CI authenticates with,
299
+ so the pipeline keeps working. Terraform state contains the generated
300
+ credentials (`DATABASE_URL`, `S3_*`), so no other principal in the project —
301
+ including the app's own credentials — can read it. Note that this also
302
+ hides the bucket from other console users; manage or remove the policy with
303
+ that same API key if you need to open it up.
281
304
  - The app connects to its database with a **dedicated least-privilege IAM
282
305
  credential** (read/write on that database, nothing else), not with your
283
- main API key.
306
+ main API key. Object Storage, when enabled, gets its own separate dedicated
307
+ credential that can only use Object Storage (and, thanks to the bucket
308
+ policy above, cannot read the Terraform state).
284
309
  - A weekly drift-detection plan opens an issue when the real infrastructure
285
310
  no longer matches the code.
286
311
 
@@ -296,6 +321,7 @@ included (Scaleway `fr-par` list prices, excl. VAT):
296
321
  | Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
297
322
  | Object Storage (Terraform state) | a few MB | ~€0 |
298
323
  | Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
324
+ | Object Storage (optional) | only if `--object-storage` | ~€0 idle (a few GB of storage when used) |
299
325
  | **Total to start** | | **under ~€1 / month** |
300
326
 
301
327
  Compute is billed per second, only while actually serving: an idle container
@@ -321,6 +347,17 @@ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
321
347
  Yes. The CLI asks for the name and the visibility; default is public (the
322
348
  infra holds no secrets), or choose private interactively or with `--private`.
323
349
 
350
+ **Can I point keel at an existing repository?**
351
+ Only if it has no commits. keel pushes a brand-new history, so a repository
352
+ that already has commits (even just a README from the GitHub UI) would reject
353
+ the push; the CLI checks this up front and asks for another name. The easiest
354
+ path is to let keel create the repository for you.
355
+
356
+ **Can I reuse an existing Infisical project?**
357
+ Yes: pass its project ID (`--infisical-project-id` or interactively). The CLI
358
+ verifies the machine identity can access it before anything is created. Leave
359
+ it empty and keel creates a project named after your app instead.
360
+
324
361
  **Why is the container not created on the first apply?**
325
362
  A Serverless Container needs an image, and none exists yet. Registry and
326
363
  database are created immediately; the container is gated on
@@ -331,9 +368,21 @@ Scaleway Serverless Containers have no built-in auth in front of public
331
368
  endpoints. Credentials live in Infisical, the container gets
332
369
  `BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
333
370
 
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.
371
+ **Which environments do I get, and can I change them?**
372
+ You choose at creation: production only, staging + production (default), or
373
+ dev + staging + production — interactively or with `--environments`
374
+ (e.g. `--environments prod` or `--environments dev,staging,prod`). Production
375
+ is always gated by a manual approval; non-production environments enable Basic
376
+ Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
377
+ (the plan and drift workflows discover environments from the tfvars files at
378
+ the repo root automatically), an Infisical environment with the same slug, and
379
+ a chained job in `terraform-apply.yml`.
380
+
381
+ **Can I store files, not just rows?**
382
+ Yes, opt in with `--object-storage` (or answer yes interactively). Each
383
+ environment gets its own Object Storage bucket and a dedicated credential; the
384
+ pipeline syncs the `S3_*` coordinates to Infisical for the app to read. It is
385
+ off by default — many apps only need the database.
337
386
 
338
387
  ## CLI reference
339
388
 
@@ -348,13 +397,21 @@ one job in each workflow.
348
397
  --infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
349
398
  --infisical-client-id <id> or env INFISICAL_CLIENT_ID
350
399
  --infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
351
- --infisical-project-name <n> Infisical project (default: project name)
400
+ --infisical-project-id <id> Existing Infisical project ID to reuse
401
+ (or env INFISICAL_PROJECT_ID; default: create by name)
402
+ --infisical-project-name <n> Infisical project name (default: project name)
352
403
  --github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
353
404
  --repo-name <name> GitHub repository name (default: project name)
354
405
  --private / --public Repository visibility (default: public)
355
- --no-basic-auth Disable Basic Auth on staging
406
+ --environments <preset> prod | staging+prod | dev+staging+prod
407
+ (or a list like "dev,staging,prod"; default staging+prod)
408
+ --object-storage Provision a per-environment Object Storage bucket
409
+ --no-object-storage Do not provision Object Storage (default)
410
+ --basic-auth Enable Basic Auth on non-production environments (default)
411
+ --no-basic-auth Disable Basic Auth on non-production environments
412
+ --dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
356
413
  --staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
357
- --prod-min-scale <n> Default 0 --prod-max-scale <n> Default 2
414
+ --prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
358
415
  --config <file.json> Load answers from a JSON file
359
416
  --advanced Also ask scaling questions interactively
360
417
  --yes Accept defaults, skip the confirmation prompt
@@ -368,8 +425,10 @@ is taken from the environment or asked interactively:
368
425
  {
369
426
  "projectName": "my-app",
370
427
  "region": "fr-par",
428
+ "environments": ["staging", "prod"],
429
+ "objectStorage": true,
371
430
  "github": { "repoPrivate": true },
372
- "scaling": { "prodMaxScale": 4 }
431
+ "scaling": { "prod": { "maxScale": 4 } }
373
432
  }
374
433
  ```
375
434
 
@@ -4,47 +4,104 @@ import { createRequire } from 'node:module';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { Octokit } from '@octokit/rest';
7
+ import { planStatusCheckContext } from '../contracts.js';
7
8
  // The ESM build of libsodium-wrappers is broken (missing libsodium.mjs), so
8
9
  // load the CommonJS build explicitly.
9
10
  const require = createRequire(import.meta.url);
10
11
  const sodium = require('libsodium-wrappers');
11
12
  export class GitHubError extends Error {
13
+ field;
14
+ constructor(message, field = 'token') {
15
+ super(message);
16
+ this.field = field;
17
+ }
12
18
  }
13
- export async function createContext(answers) {
14
- const octokit = new Octokit({ auth: answers.github.token });
19
+ export async function createContext(github) {
20
+ // Silence Octokit's own request logging: expected non-2xx responses (a 404
21
+ // for a repo that does not exist yet, a 401 for a bad token) are handled
22
+ // here and would otherwise scribble over the prompt spinner.
23
+ const octokit = new Octokit({
24
+ auth: github.token,
25
+ log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
26
+ });
15
27
  let login;
16
28
  let ownerId;
29
+ let scopes;
17
30
  try {
18
- const { data } = await octokit.users.getAuthenticated();
31
+ const { data, headers } = await octokit.users.getAuthenticated();
19
32
  login = data.login;
20
33
  ownerId = data.id;
34
+ scopes = headers['x-oauth-scopes'];
21
35
  }
22
36
  catch {
23
37
  throw new GitHubError('GitHub rejected the token. It needs the "repo" and "workflow" scopes ' +
24
38
  '(classic PAT) or equivalent fine-grained permissions.');
25
39
  }
40
+ // Classic PATs advertise their scopes in a header; fine-grained tokens do
41
+ // not, so an absent/empty header only means "cannot check here".
42
+ if (scopes) {
43
+ const granted = new Set(scopes.split(',').map((s) => s.trim()));
44
+ for (const required of ['repo', 'workflow']) {
45
+ if (!granted.has(required)) {
46
+ throw new GitHubError(`The GitHub token is missing the "${required}" scope (it has: ${scopes}). ` +
47
+ 'Create a classic PAT with the "repo" and "workflow" scopes.');
48
+ }
49
+ }
50
+ }
26
51
  return {
27
52
  octokit,
28
53
  owner: login,
29
54
  ownerId,
30
- repo: answers.github.repoName,
31
- repoPrivate: answers.github.repoPrivate,
55
+ repo: github.repoName,
56
+ repoPrivate: github.repoPrivate,
32
57
  };
33
58
  }
34
- /** Create the repository, or reuse it when it already exists. */
35
- export async function ensureRepo(ctx) {
59
+ /** Read-only look at the target repository: existence, push access, emptiness. */
60
+ export async function inspectRepo(ctx) {
61
+ let url;
36
62
  try {
37
63
  const { data } = await ctx.octokit.repos.get({ owner: ctx.owner, repo: ctx.repo });
38
- if (!data.permissions?.push) {
39
- throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`);
40
- }
41
- return { created: false, url: data.html_url };
64
+ if (!data.permissions?.push)
65
+ return { state: 'no-push', url: data.html_url };
66
+ url = data.html_url;
42
67
  }
43
68
  catch (error) {
44
- if (error instanceof GitHubError)
45
- throw error;
46
- if (error.status !== 404)
47
- throw error;
69
+ if (error.status === 404)
70
+ return { state: 'not-found' };
71
+ throw error;
72
+ }
73
+ try {
74
+ const { data } = await ctx.octokit.repos.listCommits({
75
+ owner: ctx.owner,
76
+ repo: ctx.repo,
77
+ per_page: 1,
78
+ });
79
+ return { state: data.length > 0 ? 'non-empty' : 'empty', url };
80
+ }
81
+ catch (error) {
82
+ // GitHub answers 409 "Git Repository is empty" for a repo with no commits.
83
+ if (error.status === 409)
84
+ return { state: 'empty', url };
85
+ throw error;
86
+ }
87
+ }
88
+ /** Turn a blocking repo state into a GitHubError; pass on 'not-found'/'empty'. */
89
+ export function assertRepoUsable(ctx, state) {
90
+ if (state === 'no-push') {
91
+ throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`, 'repo');
92
+ }
93
+ if (state === 'non-empty') {
94
+ throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} already has commits. keel pushes a brand-new ` +
95
+ 'history, so the push would be rejected: use a new repository name (keel creates ' +
96
+ 'it for you) or an existing repository with no commits.', 'repo');
97
+ }
98
+ }
99
+ /** Create the repository, or reuse it when it already exists and is empty. */
100
+ export async function ensureRepo(ctx) {
101
+ const inspected = await inspectRepo(ctx);
102
+ assertRepoUsable(ctx, inspected.state);
103
+ if (inspected.state !== 'not-found') {
104
+ return { created: false, url: inspected.url };
48
105
  }
49
106
  const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
50
107
  name: ctx.repo,
@@ -100,23 +157,27 @@ export function pushRepo(ctx, token, targetDir) {
100
157
  /** Encrypt and upload every CI secret, then set the plain variables. */
101
158
  export async function configureRepo(ctx, answers, infisicalProjectId) {
102
159
  // The workflows map AWS_* (state backend) from the same SCW_* secrets, so
103
- // each credential is stored once and rotated in one place.
104
- await setSecrets(ctx, {
160
+ // each credential is stored once and rotated in one place. The Record types
161
+ // force these names to stay in lockstep with the contracts the generated
162
+ // workflows are tested against.
163
+ const secrets = {
105
164
  SCW_ACCESS_KEY: answers.scaleway.accessKey,
106
165
  SCW_SECRET_KEY: answers.scaleway.secretKey,
107
166
  SCW_DEFAULT_PROJECT_ID: answers.scaleway.projectId,
108
167
  SCW_DEFAULT_ORGANIZATION_ID: answers.scaleway.organizationId,
109
168
  INFISICAL_CLIENT_ID: answers.infisical.clientId,
110
169
  INFISICAL_CLIENT_SECRET: answers.infisical.clientSecret,
111
- });
112
- await setVariables(ctx, {
170
+ };
171
+ await setSecrets(ctx, secrets);
172
+ const variables = {
113
173
  TF_STATE_BUCKET: answers.stateBucket,
114
174
  SCW_REGION: answers.region,
115
175
  INFISICAL_PROJECT_ID: infisicalProjectId,
116
176
  INFISICAL_HOST: answers.infisical.host,
117
- });
118
- await configureEnvironments(ctx);
119
- await protectMainBranch(ctx);
177
+ };
178
+ await setVariables(ctx, variables);
179
+ await configureEnvironments(ctx, answers);
180
+ await protectMainBranch(ctx, answers);
120
181
  }
121
182
  async function setSecrets(ctx, secrets) {
122
183
  await sodium.ready;
@@ -157,29 +218,28 @@ async function setVariables(ctx, variables) {
157
218
  }
158
219
  }
159
220
  }
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
- });
221
+ /** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
222
+ async function configureEnvironments(ctx, answers) {
223
+ for (const env of answers.environments) {
224
+ await ctx.octokit.repos.createOrUpdateEnvironment({
225
+ owner: ctx.owner,
226
+ repo: ctx.repo,
227
+ environment_name: env.githubEnvironment,
228
+ ...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
229
+ });
230
+ }
173
231
  }
174
232
  /** Require a green plan on PRs and forbid force-pushes/deletion of main. */
175
- async function protectMainBranch(ctx) {
233
+ async function protectMainBranch(ctx, answers) {
176
234
  await ctx.octokit.repos.updateBranchProtection({
177
235
  owner: ctx.owner,
178
236
  repo: ctx.repo,
179
237
  branch: 'main',
180
238
  required_status_checks: {
181
239
  strict: false,
182
- contexts: ['plan (staging)', 'plan (prod)'],
240
+ // Must match the job names produced by the plan workflow; the shared
241
+ // format lives in contracts.ts and is tested against the template.
242
+ contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
183
243
  },
184
244
  enforce_admins: false,
185
245
  required_pull_request_reviews: null,
@@ -1,5 +1,11 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { S3_SECRET_KEYS } from '../contracts.js';
2
3
  export class InfisicalError extends Error {
4
+ field;
5
+ constructor(message, field = 'credentials') {
6
+ super(message);
7
+ this.field = field;
8
+ }
3
9
  }
4
10
  async function api(host, path, options = {}) {
5
11
  const response = await fetch(`${host}${path}`, {
@@ -20,8 +26,8 @@ async function api(host, path, options = {}) {
20
26
  }
21
27
  return { status: response.status, data };
22
28
  }
23
- export async function login(answers) {
24
- const { host, clientId, clientSecret } = answers.infisical;
29
+ export async function login(infisical) {
30
+ const { host, clientId, clientSecret } = infisical;
25
31
  const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
26
32
  if (status !== 200 || !data.accessToken) {
27
33
  throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ` +
@@ -29,11 +35,18 @@ export async function login(answers) {
29
35
  }
30
36
  return data.accessToken;
31
37
  }
32
- async function findProject(host, token, name) {
38
+ async function listProjects(host, token) {
33
39
  const { status, data } = await api(host, '/api/v1/workspace', { token });
34
40
  if (status !== 200)
35
- return undefined;
36
- return data.workspaces?.find((w) => w.name === name);
41
+ return [];
42
+ return data.workspaces ?? [];
43
+ }
44
+ async function findProject(host, token, name) {
45
+ return (await listProjects(host, token)).find((w) => w.name === name);
46
+ }
47
+ /** Look an existing project up by ID among those the identity can access. */
48
+ export async function findProjectById(host, token, id) {
49
+ return (await listProjects(host, token)).find((w) => w.id === id);
37
50
  }
38
51
  async function createProject(host, token, name) {
39
52
  const { status, data } = await api(host, '/api/v2/workspace', { method: 'POST', token, body: { projectName: name } });
@@ -43,10 +56,9 @@ async function createProject(host, token, name) {
43
56
  }
44
57
  return data.project;
45
58
  }
46
- async function ensureEnvironment(host, token, project, slug) {
59
+ async function ensureEnvironment(host, token, project, slug, name) {
47
60
  if (project.environments?.some((e) => e.slug === slug))
48
61
  return;
49
- const name = slug === 'prod' ? 'Production' : 'Staging';
50
62
  const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
51
63
  // 400 usually means the environment already exists: fine for idempotency.
52
64
  if (status !== 200 && status !== 400) {
@@ -72,24 +84,61 @@ async function seedSecret(host, token, projectId, environment, name, value) {
72
84
  throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
73
85
  }
74
86
  }
75
- /** Create/reuse the project, ensure staging+prod, seed placeholder secrets. */
87
+ function inaccessibleProject(projectId) {
88
+ return new InfisicalError(`Infisical project "${projectId}" was not found or the machine identity has no access to it. ` +
89
+ 'Check the project ID and that the identity is a member of the project.', 'project');
90
+ }
91
+ /**
92
+ * Read-only validation: Universal Auth login works and, when a project ID was
93
+ * given, that project is accessible. Returns the project name when found.
94
+ */
95
+ export async function validateInfisical(infisical) {
96
+ const token = await login(infisical);
97
+ if (infisical.projectId) {
98
+ const project = await findProjectById(infisical.host, token, infisical.projectId);
99
+ if (!project)
100
+ throw inaccessibleProject(infisical.projectId);
101
+ return { projectName: project.name };
102
+ }
103
+ return {};
104
+ }
105
+ /** Create/reuse the project, ensure every environment, seed placeholder secrets. */
76
106
  export async function bootstrapInfisical(answers) {
77
- const { host, projectName } = answers.infisical;
78
- const token = await login(answers);
107
+ const { host, projectName, projectId } = answers.infisical;
108
+ const token = await login(answers.infisical);
79
109
  let createdProject = false;
80
- let project = await findProject(host, token, projectName);
81
- if (!project) {
82
- project = await createProject(host, token, projectName);
83
- createdProject = true;
110
+ let project;
111
+ if (projectId) {
112
+ // An explicit ID is never created implicitly: fail loudly if unreachable.
113
+ project = await findProjectById(host, token, projectId);
114
+ if (!project)
115
+ throw inaccessibleProject(projectId);
84
116
  }
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'));
117
+ else {
118
+ // Find-by-name keeps re-runs idempotent even without a recorded ID.
119
+ project = await findProject(host, token, projectName);
120
+ if (!project) {
121
+ project = await createProject(host, token, projectName);
122
+ createdProject = true;
123
+ }
90
124
  }
125
+ for (const env of answers.environments) {
126
+ await ensureEnvironment(host, token, project, env.slug, env.displayName);
127
+ }
128
+ // Real values arrive from the pipeline after the first apply; seed placeholders
129
+ // now so the secret paths exist and the container has something to read.
91
130
  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);
131
+ for (const env of answers.environments) {
132
+ if (env.basicAuth) {
133
+ await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_USER', env.slug);
134
+ await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
135
+ }
136
+ await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
137
+ if (answers.objectStorage) {
138
+ for (const key of S3_SECRET_KEYS) {
139
+ await seedSecret(host, token, project.id, env.slug, key, placeholder);
140
+ }
141
+ }
142
+ }
94
143
  return { projectId: project.id, createdProject };
95
144
  }