@gambi97/keel-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matteo Gambera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,401 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="keel" width="520">
3
+ </p>
4
+
5
+ <p align="center">
6
+ One command from zero to a production-shaped serverless infrastructure on Scaleway<br>(Terraform + GitHub Actions + Infisical).
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://github.com/Gambi97/keel/actions/workflows/ci.yml"><img src="https://github.com/Gambi97/keel/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
11
+ <img src="https://img.shields.io/badge/node-%3E%3D18.17-339933?logo=node.js&logoColor=white" alt="node compatibility">
12
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="#quickstart">Quickstart</a> ·
17
+ <a href="#why-keel">Why keel</a> ·
18
+ <a href="#what-gets-created-and-when">What gets created</a> ·
19
+ <a href="#what-it-costs-to-start">Costs</a> ·
20
+ <a href="#faq">FAQ</a>
21
+ </p>
22
+
23
+ A ship's keel is the first beam laid down, the backbone everything else is
24
+ built onto. `keel` lays that foundation for whatever you are building: a
25
+ complete serverless infrastructure on [Scaleway](https://www.scaleway.com),
26
+ provisioned by Terraform, deployed by GitHub Actions, with secrets in
27
+ [Infisical](https://infisical.com). It does not generate an app: you bring a
28
+ Docker image, keel gives it a place to run. You answer a few questions, push
29
+ to `main`, and the infrastructure is live.
30
+
31
+ - **Near-free to start.** Compute and database scale to zero; an idle project
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.
35
+ - **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
36
+ - **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
37
+ secret manager, wired for you.
38
+ - **No lock-in of your code.** Your app is a plain Docker image on a port.
39
+
40
+ ## Quickstart
41
+
42
+ You need Node >= 18.17, git, and credentials for Scaleway, Infisical and
43
+ GitHub (see [Prerequisites](#prerequisites) for how to get each one).
44
+
45
+ ```sh
46
+ npx @gambi97/keel-cli
47
+ ```
48
+
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
53
+ first PR) and the pipeline provisions the infrastructure.
54
+
55
+ Non-interactive and dry-run:
56
+
57
+ ```sh
58
+ # scripts / CI: every question has a flag
59
+ npx @gambi97/keel-cli --yes --name my-app --region fr-par --private
60
+
61
+ # preview: generates the repo locally, touches no account
62
+ npx @gambi97/keel-cli --dry-run --yes --name my-app
63
+ ```
64
+
65
+ See the full [CLI reference](#cli-reference) for all flags, or pass
66
+ `--config answers.json`.
67
+
68
+ ## Why keel
69
+
70
+ Getting anything real online means gluing together a cloud account, a
71
+ container runtime, a database, a state store, a CI/CD pipeline and a secret
72
+ manager, then threading credentials through all of them without leaking
73
+ anything. It is a day of undifferentiated setup before the first line of
74
+ product.
75
+
76
+ `keel` collapses that into one command, guided by three opinions:
77
+
78
+ **1. Start near zero, grow without re-architecting.**
79
+ Both compute (Serverless Containers) and database (Serverless SQL) bill per
80
+ use and scale to zero. The same repo that costs cents while you validate the
81
+ idea carries real traffic later by bumping two numbers in a tfvars file.
82
+ See [the cost breakdown](#what-it-costs-to-start).
83
+
84
+ **2. The repository is the single source of truth, and it holds zero secrets.**
85
+ All infrastructure is Terraform reviewed in PRs; nothing is click-configured.
86
+ Terraform runs in CI, never on a laptop. Credentials live in GitHub's
87
+ encrypted store (CI) and Infisical (application), so rotating a secret never
88
+ requires a commit.
89
+
90
+ **3. Infrastructure only, no framework.**
91
+ keel generates no application skeleton. Your app is any Docker image,
92
+ listening on a port, reading its config from environment variables. Nothing
93
+ to rewrite if you outgrow the stack.
94
+
95
+ ### Why Scaleway, and not AWS or the others
96
+
97
+ AWS can absolutely do this, but the equivalent setup on AWS means assembling
98
+ Lambda + API Gateway + Aurora + ECR + S3 + a thick layer of IAM, each with its
99
+ own knobs. keel optimizes for a different thing: **the shortest path from
100
+ nothing to running, cheap, scalable infrastructure.**
101
+
102
+ - **Truly scale-to-zero, on both tiers.** Scaleway Serverless Containers
103
+ _and_ Serverless SQL drop to zero when idle. On AWS, a serverless database
104
+ typically keeps minimum billable capacity running; for a project that is
105
+ quiet most of the time, that difference is the whole point.
106
+ - **No free tier clock.** Pricing starts near zero and stays per-second;
107
+ nothing expires after 12 months.
108
+ - **A handful of resources instead of a sprawl.** Less to assemble, less IAM
109
+ surface to misconfigure.
110
+ - **Standard containers.** A plain Docker image, portable by default.
111
+ - **EU-based**, with straightforward pricing and data residency.
112
+
113
+ The trade-off is honest: Scaleway has a smaller catalog than the
114
+ hyperscalers. If you need a specific managed AWS service, this is not for
115
+ you. If you want a serverless product online today for near nothing, it fits.
116
+
117
+ ### Why Infisical for secrets
118
+
119
+ Secrets are the easiest thing to get wrong: a `DATABASE_URL` committed "just
120
+ for now", credentials copy-pasted across consoles until nobody knows the
121
+ source of truth.
122
+
123
+ - **One home for application secrets**, separate from code and CI
124
+ credentials, with clean staging/prod separation.
125
+ - **Rotate without a deploy.** Change the value, the next apply picks it up.
126
+ - **Read from both sides.** Terraform reads at plan/apply, the container
127
+ reads at runtime, same place.
128
+ - **Open source and self-hostable.** Point keel at your own instance.
129
+
130
+ CI credentials (the Scaleway and Infisical keys themselves) stay in GitHub
131
+ Actions encrypted secrets, because that is what CI needs to boot.
132
+
133
+ ## How it works
134
+
135
+ ```mermaid
136
+ flowchart TD
137
+ You([You: npx @gambi97/keel-cli]) -->|APIs, after you confirm| CLI{{CLI bootstrap}}
138
+ CLI -->|creates state bucket| SCW[(Scaleway<br/>Object Storage)]
139
+ CLI -->|creates repo, secrets,<br/>variables, branch rules| GH[GitHub repo]
140
+ CLI -->|creates project, envs,<br/>placeholder secrets| INF[Infisical]
141
+ GH -->|push to main| ACT[GitHub Actions]
142
+ ACT -->|terraform apply| PROD[Scaleway:<br/>Container + SQL + Registry]
143
+ INF -.->|app secrets at runtime| PROD
144
+ ```
145
+
146
+ The CLI does the **bootstrap**, in seconds, on your machine. The first
147
+ `terraform apply` runs **in CI** on the first push to `main`, so nobody needs
148
+ local tooling or production credentials.
149
+
150
+ ## What gets created, and when
151
+
152
+ **Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
153
+
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 |
160
+
161
+ **Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
162
+
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` |
169
+
170
+ No custom domain is configured: the app gets an auto-generated Scaleway URL.
171
+ Add one later with a single `scaleway_container_domain` resource.
172
+
173
+ ## The generated repository
174
+
175
+ ```
176
+ my-app/
177
+ ├── README.md # operating manual for the repo
178
+ ├── .github/workflows/
179
+ │ ├── terraform-plan.yml # PR: fmt + validate + plan (staging & prod)
180
+ │ ├── terraform-apply.yml # main: apply staging -> approval -> apply prod
181
+ │ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
182
+ ├── versions.tf · providers.tf · backend.tf
183
+ ├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
184
+ ├── 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
+ ```
188
+
189
+ Environments are separated with **Terraform workspaces**: same code, two
190
+ independent states in one bucket, differences confined to the two `.tfvars`
191
+ files.
192
+
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 |
201
+
202
+ ## After the bootstrap
203
+
204
+ The generated repository ships its own README: the full operating manual for
205
+ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
206
+ short version of the first deploy:
207
+
208
+ 1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
209
+ databases. Approve the `production` gate when prompted.
210
+ 2. **Build and push your app image** to the registry endpoint from the apply
211
+ output:
212
+ ```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
216
+ ```
217
+ 3. **Set `container_image`** in the tfvars and open a PR: the next apply
218
+ creates the containers.
219
+ 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.
222
+
223
+ ## Prerequisites
224
+
225
+ Three accounts and their credentials. Nothing is installed locally: the CLI
226
+ talks to each service through its API and **validates every credential before
227
+ creating anything**.
228
+
229
+ <details>
230
+ <summary><b>Scaleway</b> (the cloud provider)</summary>
231
+ <br>
232
+
233
+ 1. Create an account and a project at [console.scaleway.com](https://console.scaleway.com).
234
+ 2. Generate an API key (IAM > API keys > Generate): you need the **access
235
+ key** and the **secret key**.
236
+ 3. Note your **project ID** and **organization ID** (Project Dashboard).
237
+ 4. The key needs to create Object Storage buckets and manage Serverless
238
+ Containers, Serverless SQL and the Registry (`ObjectStorageFullAccess`,
239
+ `ContainersFullAccess`, `ServerlessSQLDatabaseFullAccess`,
240
+ `ContainerRegistryFullAccess`, plus `IAMManager` so Terraform can create
241
+ the app's dedicated least-privilege database credential).
242
+
243
+ </details>
244
+
245
+ <details>
246
+ <summary><b>Infisical</b> (the secret manager)</summary>
247
+ <br>
248
+
249
+ 1. Create an account at [app.infisical.com](https://app.infisical.com) (or
250
+ use a self-hosted instance).
251
+ 2. Create a **Machine Identity** with **Universal Auth**: you need its
252
+ **client ID** and **client secret**.
253
+ 3. Give the identity permission to create and manage projects.
254
+
255
+ </details>
256
+
257
+ <details>
258
+ <summary><b>GitHub</b> (code hosting and CI)</summary>
259
+ <br>
260
+
261
+ 1. Create a token at [github.com/settings/tokens](https://github.com/settings/tokens)
262
+ with the `repo` and `workflow` scopes (classic), or a fine-grained token
263
+ allowed to create repos and manage Actions secrets, variables,
264
+ environments and branch protection.
265
+
266
+ </details>
267
+
268
+ ## Security model
269
+
270
+ - **No secret ever lands in the repository**: not in Terraform, not in
271
+ tfvars, not in workflows. `backend.hcl` and local state are git-ignored.
272
+ - The CLI never logs credentials and redacts them in the summary. The GitHub
273
+ token is handed to `git push` through an ephemeral askpass helper, so it
274
+ never appears in remote URLs, `.git/config` or the process list.
275
+ - Actions secrets are encrypted client-side (libsodium sealed box) before
276
+ upload.
277
+ - `main` is protected: force pushes and deletion blocked, PRs need a green
278
+ plan, production applies need manual approval.
279
+ - Terraform state lives in a private, versioned bucket with S3-native state
280
+ locking (`use_lockfile`), so concurrent applies cannot corrupt it.
281
+ - The app connects to its database with a **dedicated least-privilege IAM
282
+ credential** (read/write on that database, nothing else), not with your
283
+ main API key.
284
+ - A weekly drift-detection plan opens an issue when the real infrastructure
285
+ no longer matches the code.
286
+
287
+ ## What it costs to start
288
+
289
+ keel is tuned to sit **near the free tier** while you have little or no
290
+ traffic. A rough monthly picture for a minimal setup, staging and prod
291
+ included (Scaleway `fr-par` list prices, excl. VAT):
292
+
293
+ | Component | Minimal setup | Monthly cost |
294
+ | --------------------------------- | -------------------------- | ------------------------------------------ |
295
+ | Serverless Containers (both envs) | scale-to-zero, low traffic | ~€0 (200k vCPU-s + 400k GB-s free / month) |
296
+ | Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
297
+ | Object Storage (Terraform state) | a few MB | ~€0 |
298
+ | Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
299
+ | **Total to start** | | **under ~€1 / month** |
300
+
301
+ Compute is billed per second, only while actually serving: an idle container
302
+ and a paused database drop to zero and you pay a few cents of storage. Cost
303
+ grows with real usage, not with the number of environments. When traffic
304
+ arrives, raise `min_scale` / `max_scale` in the tfvars and you move from
305
+ "near free" to paying for the capacity you actually use, with no
306
+ re-architecting.
307
+
308
+ ## Failure recovery
309
+
310
+ Every bootstrap step checks whether its resource already exists, and progress
311
+ is recorded in `.keel.json` inside the project directory. If a run fails
312
+ halfway, fix the cause and **re-run the same command**: completed steps are
313
+ skipped, existing resources are reused, nothing is duplicated.
314
+
315
+ ## FAQ
316
+
317
+ **Do I need Terraform, `scw` or `gh` installed?**
318
+ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
319
+
320
+ **Can the repository be private?**
321
+ Yes. The CLI asks for the name and the visibility; default is public (the
322
+ infra holds no secrets), or choose private interactively or with `--private`.
323
+
324
+ **Why is the container not created on the first apply?**
325
+ A Serverless Container needs an image, and none exists yet. Registry and
326
+ database are created immediately; the container is gated on
327
+ `container_image`, so the first apply is green instead of failing.
328
+
329
+ **Why Basic Auth "at the app level"?**
330
+ Scaleway Serverless Containers have no built-in auth in front of public
331
+ endpoints. Credentials live in Infisical, the container gets
332
+ `BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
333
+
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.
337
+
338
+ ## CLI reference
339
+
340
+ ```
341
+ --name <name> Project name (dns-safe: lowercase, digits, hyphens)
342
+ --dir <path> Target directory (default: ./<name>)
343
+ --region <region> fr-par | nl-ams | pl-waw (default: fr-par)
344
+ --scw-access-key <key> or env SCW_ACCESS_KEY
345
+ --scw-secret-key <key> or env SCW_SECRET_KEY
346
+ --scw-project-id <id> or env SCW_DEFAULT_PROJECT_ID
347
+ --scw-organization-id <id> or env SCW_DEFAULT_ORGANIZATION_ID
348
+ --infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
349
+ --infisical-client-id <id> or env INFISICAL_CLIENT_ID
350
+ --infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
351
+ --infisical-project-name <n> Infisical project (default: project name)
352
+ --github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
353
+ --repo-name <name> GitHub repository name (default: project name)
354
+ --private / --public Repository visibility (default: public)
355
+ --no-basic-auth Disable Basic Auth on staging
356
+ --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
358
+ --config <file.json> Load answers from a JSON file
359
+ --advanced Also ask scaling questions interactively
360
+ --yes Accept defaults, skip the confirmation prompt
361
+ --dry-run Generate locally, touch no account
362
+ ```
363
+
364
+ A `--config` file uses the same nested shape as the flags; anything missing
365
+ is taken from the environment or asked interactively:
366
+
367
+ ```json
368
+ {
369
+ "projectName": "my-app",
370
+ "region": "fr-par",
371
+ "github": { "repoPrivate": true },
372
+ "scaling": { "prodMaxScale": 4 }
373
+ }
374
+ ```
375
+
376
+ ## Development
377
+
378
+ ```sh
379
+ npm install
380
+ npm run build # tsc -> dist/
381
+ npm test # vitest unit tests
382
+ npm run lint # eslint
383
+ npm run verify:templates # render templates + terraform fmt/validate (needs terraform)
384
+ node dist/index.js --dry-run --yes --name demo # end-to-end without accounts
385
+ ```
386
+
387
+ ### Releasing (maintainers)
388
+
389
+ Publishing runs in CI, never from a laptop:
390
+
391
+ ```sh
392
+ npm version minor # bumps package.json, commits and tags vX.Y.Z
393
+ git push --follow-tags # the Release workflow publishes to npm with provenance
394
+ ```
395
+
396
+ The workflow refuses a tag that does not match `package.json` and skips
397
+ versions that are already on the registry, so re-running it is always safe.
398
+
399
+ ## License
400
+
401
+ [MIT](LICENSE)
@@ -0,0 +1,190 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { Octokit } from '@octokit/rest';
7
+ // The ESM build of libsodium-wrappers is broken (missing libsodium.mjs), so
8
+ // load the CommonJS build explicitly.
9
+ const require = createRequire(import.meta.url);
10
+ const sodium = require('libsodium-wrappers');
11
+ export class GitHubError extends Error {
12
+ }
13
+ export async function createContext(answers) {
14
+ const octokit = new Octokit({ auth: answers.github.token });
15
+ let login;
16
+ let ownerId;
17
+ try {
18
+ const { data } = await octokit.users.getAuthenticated();
19
+ login = data.login;
20
+ ownerId = data.id;
21
+ }
22
+ catch {
23
+ throw new GitHubError('GitHub rejected the token. It needs the "repo" and "workflow" scopes ' +
24
+ '(classic PAT) or equivalent fine-grained permissions.');
25
+ }
26
+ return {
27
+ octokit,
28
+ owner: login,
29
+ ownerId,
30
+ repo: answers.github.repoName,
31
+ repoPrivate: answers.github.repoPrivate,
32
+ };
33
+ }
34
+ /** Create the repository, or reuse it when it already exists. */
35
+ export async function ensureRepo(ctx) {
36
+ try {
37
+ 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 };
42
+ }
43
+ catch (error) {
44
+ if (error instanceof GitHubError)
45
+ throw error;
46
+ if (error.status !== 404)
47
+ throw error;
48
+ }
49
+ const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
50
+ name: ctx.repo,
51
+ private: ctx.repoPrivate,
52
+ description: 'Serverless infrastructure on Scaleway, generated by keel',
53
+ has_wiki: false,
54
+ has_projects: false,
55
+ auto_init: false,
56
+ });
57
+ return { created: true, url: data.html_url };
58
+ }
59
+ /**
60
+ * Push the generated repo over HTTPS. The token is handed to git through a
61
+ * temporary GIT_ASKPASS helper reading an environment variable, so it never
62
+ * appears in the remote URL, in .git/config or in the process list.
63
+ */
64
+ export function pushRepo(ctx, token, targetDir) {
65
+ const remoteUrl = `https://github.com/${ctx.owner}/${ctx.repo}.git`;
66
+ const askpassDir = mkdtempSync(join(tmpdir(), 'keel-askpass-'));
67
+ const askpass = join(askpassDir, 'askpass.sh');
68
+ writeFileSync(askpass, '#!/bin/sh\necho "$KEEL_GIT_TOKEN"\n');
69
+ chmodSync(askpass, 0o700);
70
+ try {
71
+ const git = (args) => spawnSync('git', args, {
72
+ cwd: targetDir,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ env: {
75
+ ...process.env,
76
+ GIT_ASKPASS: askpass,
77
+ KEEL_GIT_TOKEN: token,
78
+ GIT_TERMINAL_PROMPT: '0',
79
+ },
80
+ });
81
+ git(['remote', 'remove', 'origin']);
82
+ const addRemote = git(['remote', 'add', 'origin', remoteUrl]);
83
+ if (addRemote.status !== 0) {
84
+ throw new GitHubError(`git remote add failed: ${addRemote.stderr.toString().trim()}`);
85
+ }
86
+ // The remote embeds the username only; the password comes from askpass.
87
+ const setUser = git(['config', 'credential.username', 'x-access-token']);
88
+ if (setUser.status !== 0) {
89
+ throw new GitHubError(`git config failed: ${setUser.stderr.toString().trim()}`);
90
+ }
91
+ const push = git(['push', '-u', 'origin', 'main']);
92
+ if (push.status !== 0) {
93
+ throw new GitHubError(`git push failed: ${push.stderr.toString().trim()}`);
94
+ }
95
+ }
96
+ finally {
97
+ rmSync(askpassDir, { recursive: true, force: true });
98
+ }
99
+ }
100
+ /** Encrypt and upload every CI secret, then set the plain variables. */
101
+ export async function configureRepo(ctx, answers, infisicalProjectId) {
102
+ // 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, {
105
+ SCW_ACCESS_KEY: answers.scaleway.accessKey,
106
+ SCW_SECRET_KEY: answers.scaleway.secretKey,
107
+ SCW_DEFAULT_PROJECT_ID: answers.scaleway.projectId,
108
+ SCW_DEFAULT_ORGANIZATION_ID: answers.scaleway.organizationId,
109
+ INFISICAL_CLIENT_ID: answers.infisical.clientId,
110
+ INFISICAL_CLIENT_SECRET: answers.infisical.clientSecret,
111
+ });
112
+ await setVariables(ctx, {
113
+ TF_STATE_BUCKET: answers.stateBucket,
114
+ SCW_REGION: answers.region,
115
+ INFISICAL_PROJECT_ID: infisicalProjectId,
116
+ INFISICAL_HOST: answers.infisical.host,
117
+ });
118
+ await configureEnvironments(ctx);
119
+ await protectMainBranch(ctx);
120
+ }
121
+ async function setSecrets(ctx, secrets) {
122
+ await sodium.ready;
123
+ const { data: key } = await ctx.octokit.actions.getRepoPublicKey({
124
+ owner: ctx.owner,
125
+ repo: ctx.repo,
126
+ });
127
+ for (const [name, value] of Object.entries(secrets)) {
128
+ const sealed = sodium.crypto_box_seal(sodium.from_string(value), sodium.from_base64(key.key, sodium.base64_variants.ORIGINAL));
129
+ await ctx.octokit.actions.createOrUpdateRepoSecret({
130
+ owner: ctx.owner,
131
+ repo: ctx.repo,
132
+ secret_name: name,
133
+ encrypted_value: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
134
+ key_id: key.key_id,
135
+ });
136
+ }
137
+ }
138
+ async function setVariables(ctx, variables) {
139
+ for (const [name, value] of Object.entries(variables)) {
140
+ try {
141
+ await ctx.octokit.actions.createRepoVariable({
142
+ owner: ctx.owner,
143
+ repo: ctx.repo,
144
+ name,
145
+ value,
146
+ });
147
+ }
148
+ catch (error) {
149
+ if (error.status !== 409)
150
+ throw error;
151
+ await ctx.octokit.actions.updateRepoVariable({
152
+ owner: ctx.owner,
153
+ repo: ctx.repo,
154
+ name,
155
+ value,
156
+ });
157
+ }
158
+ }
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
+ });
173
+ }
174
+ /** Require a green plan on PRs and forbid force-pushes/deletion of main. */
175
+ async function protectMainBranch(ctx) {
176
+ await ctx.octokit.repos.updateBranchProtection({
177
+ owner: ctx.owner,
178
+ repo: ctx.repo,
179
+ branch: 'main',
180
+ required_status_checks: {
181
+ strict: false,
182
+ contexts: ['plan (staging)', 'plan (prod)'],
183
+ },
184
+ enforce_admins: false,
185
+ required_pull_request_reviews: null,
186
+ restrictions: null,
187
+ allow_force_pushes: false,
188
+ allow_deletions: false,
189
+ });
190
+ }