Merge pull request #124 from dan-claude-bot/feat/github-app

feat: cast github-app create/register — run the App Manifest flow instead of transcribing it
This commit is contained in:
Daniel Marin 2026-07-21 14:30:13 +01:00 committed by GitHub
commit 1acf172cad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 3590 additions and 38 deletions

View file

@ -73,6 +73,65 @@ actually cutting it, and this file starts there.
what it was told to write. Basic-auth-only is the safe slice until
then.
- **`cast github-app create` / `cast github-app register`** (#7, superseding
#5) — the GitHub App was the one piece of a Coolify instance cast could
not reproduce: made by hand in a browser, its four identifiers copied out
of the UI by eye, its private key downloaded to `~/Downloads`, its details
fed to `scripts/register-github-app.sh` as a six-variable env pile.
Nothing about that survived in state. There is no REST endpoint that
creates a GitHub App — no `POST /apps`, no GraphQL mutation, no `gh app`
subcommand, no PAT scope — so `create` runs the only programmatic path
there is, GitHub's App Manifest flow: a one-shot page served on
`127.0.0.1` whose form POST your own browser session authenticates,
followed by an unauthenticated code exchange. That exchange is the only
moment GitHub ever yields the private key, the client secret and the
webhook secret together, and cast now persists all three to
`<state>/github-apps/` at 0600 under a `.gitignore` of `*`, so `git add
-A` in the state repo cannot commit them by accident. `register` adopts
credentials you already hold (an App made by hand, or a DR restore from a
stored PEM) and reads the client secret from **stdin only** — argv is
visible in `ps`. `create` does not reimplement it: it obtains credentials
and then calls exactly the `register` path.
#5's three footguns are gone structurally rather than by validation. The
Coolify-facing name is resolved from `github_apps.<org>/<repo>` in
`environments.yaml` — the value every later `cast apply` resolves the App
by — and `--name` only *seeds* an absent entry (keyed by full slug, #6),
and is refused outright when it disagrees with one that exists. The state
file is written only after the App is registered *and* verified, because a
state file naming an App that does not work is worse than one naming none.
`--webhook-secret` is optional: a webhook-inactive App is the correct shape
for a tailnet-only Coolify, and nobody has to invent a placeholder.
The one-shot secrets are written the instant GitHub yields them, *before*
`create` waits ~5 minutes for you to install the App — so a timeout, a
dropped connection or a `Ctrl-C` during that wait cannot destroy a private
key GitHub will never re-show. Until the install lands the record carries
`"installation_id": null`, the single field GitHub will answer again, and
it is backfilled on success. A name collision is refused *before* the
browser flow starts, when no App exists yet and nothing can be lost.
Both verbs end at the step that matters most: `GET
/github-apps/{id}/repositories`, asserting the repo is actually reachable.
Until now a misconfigured App failed silently and surfaced hours later, in
a different command, as an unresolvable source at `cast apply` time. When
that check fails its advice is to re-run `register` — which now re-verifies
the existing Coolify Source instead of registering a second one, because
Coolify does not enforce unique Source names (`GithubController@create`
validates `name` without `unique` and calls a plain `GithubApp::create`).
No new dependencies — `node:http` serves the callback, `node:crypto`'s
`createSign("RSA-SHA256")` mints the App JWT that recovers the installation
id from the App's own key (never from the `installation_id` GitHub appends
to a redirect, which GitHub documents as a spoofable hint).
**Unvalidated, and load-bearing**: that GitHub accepts a `redirect_url` on
`http://127.0.0.1:<port>` at all. The manifest docs are silent on the
scheme, the precedent (Probot's setup flow) is strong, and validating it
requires a logged-in GitHub session — so the first real run is an
operator's, and README keeps the manual browser path documented until
`create` has succeeded once.
### Changed
- **`state:needs-human` no longer waits on the cron to become true** (#131)
@ -161,6 +220,13 @@ actually cutting it, and this file starts there.
block, because the password cannot be read and a block a rebuild cannot
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
### Removed
- **`scripts/register-github-app.sh`** — replaced by `cast github-app
register`. Kept as a thin wrapper it would have preserved exactly the
interface #5 catalogued as producing three live footguns, while adding a
second surface to keep in step with the CLI.
### Fixed
- **A PR that deletes a shipped release heading is now CI-red** (#133,

149
README.md
View file

@ -119,6 +119,9 @@ cast inventory <org>/<repo> --env <env>
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id> \
--client-id <id> --client-secret-stdin --private-key <file>
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
```
@ -166,6 +169,12 @@ cast team [--env <env>]
environment's name at a plan that says, for every database, whether it is backed
up and when the last backup landed. See *Tearing an environment down* below.
- **`server add`** — uploads a server's private key and registers it with Coolify.
- **`github-app create`** — creates the GitHub App Coolify clones private repos
with, by running GitHub's App Manifest flow, then registers it. Two browser
clicks, zero transcription. See *The GitHub App* below.
- **`github-app register`** — adopts an App you already hold: one created by hand,
or a disaster-recovery restore from a stored private key. `create` ends by
running exactly this.
- **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
@ -208,6 +217,141 @@ no credentials at all it says so, and names the fix.
The token is never put in the clone URL or in `http.extraheader` — both leak it
into `ps`, and the latter persists it into the clone's git config.
## The GitHub App: `cast github-app`
That is how *cast* clones. **Coolify** clones with a GitHub App, and the App used
to be the one piece of a Coolify instance cast could not reproduce: created by
hand in a browser, its four identifiers copied out of the UI by eye, its private
key downloaded to `~/Downloads`, its details fed to a shell script as a
six-variable env pile. Nothing about that survived in state. Rebuild the instance
and you redid the hoops from memory.
```sh
cast github-app create heavy-duty/incubator --env prod --name hdb-coolify-prod
```
There is **no REST endpoint that creates a GitHub App** — no `POST /apps`, no
GraphQL mutation, no `gh app` subcommand, and no PAT scope that unlocks one. The
only programmatic path is GitHub's [App Manifest
flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest):
a browser form POST whose authentication is your existing GitHub session,
followed by an unauthenticated code exchange. It is how Coolify's own *Create
GitHub App* button works, and it is why this command serves you a page instead of
calling an API.
What `create` does:
1. If `gh` is on `PATH` and authenticated, checks you are an **admin** of the org
— so you learn you cannot create Apps there *before* the browser dance, not
after. `gh` is never required; an absent one skips the check silently.
2. Resolves the App's Coolify-facing name from **`github_apps.<org>/<repo>` in
`environments.yaml`**, which is what every later `cast apply` resolves this
repo's App by. `--name` seeds that entry when it is absent and is **refused**
when it disagrees with one that exists.
3. Serves a one-shot page on `127.0.0.1` that submits an App manifest —
`contents: read` + `metadata: read`, webhook inactive, private.
4. You click *Create GitHub App*; GitHub redirects back to the loopback server,
which checks the CSRF `state` and shuts down.
5. Exchanges the code. **This response is the only moment GitHub ever hands over
the private key, the client secret and the webhook secret together.**
6. **Writes all three to disk immediately**, before waiting on anything —
see [Where the credentials land](#where-the-credentials-land). Everything
after this point can fail for ordinary reasons (a slow install screen, a
dropped network, `Ctrl-C`), and none of those may cost you a key GitHub will
not reissue.
7. Prints (and tries to open) the install URL; you pick the repository.
8. Recovers the installation id by minting an RS256 JWT with the App's own key —
never from the `installation_id` GitHub appends to a redirect, which GitHub
documents as a spoofable hint — then fills it into the record from step 6.
9. Uploads the key to Coolify and creates the App record — unless a Source of
that name already exists, in which case it verifies that one rather than
registering a second (Coolify does not enforce unique Source names).
10. **Asks Coolify which repositories the App can actually see, and fails if
`<org>/<repo>` is not among them.** This is the step that matters most:
without it a misconfigured App fails silently and surfaces hours later, in a
different command, as an unresolvable source at `cast apply` time.
If the install never lands, `create` stops at step 8 and tells you the exact
`register` command that finishes the job against the files from step 6. Nothing
is lost and nothing has to be recreated — in particular, do **not** re-run
`create`, which would mint a second App. For that same reason `create` refuses
up front, before the browser flow, when `<name>.pem` already exists.
`register` is the same command from step 9 onwards, for an App you already hold —
one made by hand, or a disaster-recovery restore from a stored PEM:
```sh
pbpaste | cast github-app register heavy-duty/incubator --env prod \
--app-id 12345 --installation-id 99887766 --client-id Iv23li… \
--client-secret-stdin --private-key ~/Downloads/app.private-key.pem
```
The client secret is read from **stdin only** — argv is visible in `ps` and kept
in shell history. `--webhook-secret` is optional: a webhook-**inactive** App is
the right shape for a tailnet-only Coolify where deliveries can never arrive and
deploys are CI-triggered, and cast generates a value rather than making you
invent one.
### Where the credentials land
Into the state directory you point cast at — cast itself stores nothing:
```
<state>/github-apps/
├── .gitignore # `*` — written by cast
├── <name>.pem # 0600, the private key
└── <name>.json # 0600, app id, installation id, client id + secret, webhook secret
```
Both are written the instant GitHub yields them, which is *before* `create`
waits for you to install the App. Until the install lands, `<name>.json` carries
`"installation_id": null` — that is the one field GitHub will answer again as
often as it is asked, and it is filled in on success. Re-running against an
existing file is idempotent on identical content and a **refusal** otherwise;
`--force` is the deliberate escape hatch for a stale half-run.
All three secrets, because GitHub shows them once and `register` needs the client
secret to be re-runnable at all — a disaster-recovery restore that is missing it
is not a restore. They are written **plaintext at 0600**, not into `secrets/`:
that store holds per-repo-per-env *application* env vars, whose whole purpose is
to be decrypted and injected into the running container, which is the last place
an App private key belongs — and its age identity may not exist on the machine
doing the bootstrap at all. Encrypting the one credential that makes recovery
possible behind a key that might not be there is how DR fails at the moment it is
needed.
So the guard is structural rather than cryptographic: the `.gitignore` means
`git add -A` in your state repo cannot commit these by accident. Committing them
stays possible and has to be deliberate — encrypt them yourself and commit the
ciphertext, or keep the directory out of the repo and back it up somewhere that
is not a git remote.
### Until it has worked once
`create`'s design rests on GitHub accepting a `redirect_url` on
`http://127.0.0.1:<port>`. The manifest docs are silent on the scheme (loopback
HTTP is documented for *OAuth* redirect URIs), and the precedent is strong —
Probot's setup flow does exactly this — but it is unvalidated, because validating
it needs a logged-in GitHub session. **The manual path below stays supported
until `create` has succeeded against a real GitHub once.** If it fails, create
the App by hand in the browser and use `github-app register`, which does not
depend on the assumption at all.
<details>
<summary>The manual path</summary>
1. Org → Settings → Developer settings → GitHub Apps → **New GitHub App**.
Permissions: **Contents: Read-only**, **Metadata: Read-only**. Uncheck
*Active* under Webhook. Uncheck *Any account* (keep it private).
2. Note the **App ID** and **Client ID**; generate a **client secret**; generate
and download a **private key**.
3. **Install App** → pick the repository. The installation id is the last path
segment of the URL you land on (`…/settings/installations/<id>`).
4. Feed all of it to `cast github-app register` (above), which validates the name
against state and verifies the repo is reachable.
</details>
## Many Coolifys
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of
@ -1003,8 +1147,9 @@ the way back to zero from a half-applied first run.
## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
Coolify, restore a database backup into a target container.
Operational helpers, all argument-driven (`scripts/`): restore a database backup
into a target container. (`register-github-app.sh` is gone — it is
`cast github-app register` now.)
**They run where cast runs — off the box.** They drive the Coolify API, or reach a
box over SSH; none of them expects to be executing *on* a server. Anything that

View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Register a GitHub App (created via the manifest flow on your org) with a
# Coolify instance, so cast can create applications from private repos.
#
# usage: CAST_STATE=~/infra ./register-github-app.sh
#
# Takes everything as input — the app's identity is yours, not this tool's.
# APP_NAME must match the `github_apps.<repo>` value in your environments.yaml:
# that is the name cast resolves when it creates an application.
set -euo pipefail
STATE="${CAST_STATE:-.}"
# shellcheck disable=SC1091
source "${STATE}/.coolify.env"
: "${APP_NAME:?the Coolify-facing GitHub App name (must match github_apps.<repo> in environments.yaml)}"
: "${ORG:?the GitHub org or user the App is installed on}"
: "${APP_ID:?}"; : "${INSTALLATION_ID:?}"; : "${CLIENT_ID:?}"; : "${CLIENT_SECRET:?}"
: "${WEBHOOK_SECRET:?}"; : "${PRIVATE_KEY_FILE:?path to the App private key PEM}"
api() { curl -fsS -H "Authorization: Bearer ${COOLIFY_ACCESS_TOKEN}" -H "Content-Type: application/json" "$@"; }
KEY_UUID=$(api -X POST "${COOLIFY_BASE_URL}/api/v1/security/keys" \
-d "$(jq -n --arg name "${APP_NAME}-key" --rawfile pk "$PRIVATE_KEY_FILE" \
'{name:$name, private_key:$pk}')" | jq -r .uuid)
api -X POST "${COOLIFY_BASE_URL}/api/v1/github-apps" -d "$(jq -n \
--arg name "$APP_NAME" --arg org "$ORG" \
--arg app_id "$APP_ID" --arg inst "$INSTALLATION_ID" --arg cid "$CLIENT_ID" \
--arg csec "$CLIENT_SECRET" --arg wh "$WEBHOOK_SECRET" --arg key "$KEY_UUID" \
--arg api_url "https://api.github.com" --arg html_url "https://github.com" \
'{name:$name, organization:$org, api_url:$api_url, html_url:$html_url,
app_id:($app_id|tonumber), installation_id:($inst|tonumber), client_id:$cid, client_secret:$csec,
webhook_secret:$wh, private_key_uuid:$key}')"
echo "github app registered as ${APP_NAME}"

View file

@ -84,6 +84,13 @@ import {
renderFleetDiff,
renderProjectHeading,
} from "./fleet.js";
import {
createGithubApp,
generateWebhookSecret,
registerGithubApp,
resolveAppName,
seedGithubAppBinding,
} from "./github-app.js";
import {
type LiveResource,
type SweepEnvironment,
@ -124,6 +131,10 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765] [--force]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id>
--client-id <id> --client-secret-stdin --private-key <file>
[--webhook-secret <v>] [--name <n>] [--force]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>]
cast versions # list installed versions
@ -179,6 +190,32 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
coordinate: --path, --project, --environment, --resource,
--hostname-overlay.
github-app (the credential Coolify clones private repos with):
create runs GitHub's App Manifest flow the ONLY programmatic way to make a
GitHub App then falls through into exactly what \`register\` does. It
serves a one-shot page on 127.0.0.1, your browser session authenticates
the form, and the conversion response hands over the private key, the
client secret and the webhook secret in one body. Nothing is transcribed.
All three are written to disk the instant they arrive BEFORE the wait
for you to install the App so a timeout or a Ctrl-C during that wait
cannot lose a key GitHub shows exactly once. If the install never lands,
cast prints the \`register\` command that finishes the job; do not re-run
\`create\`, which would mint a second App.
register adopts credentials you already hold: an App created by hand, or a
disaster-recovery restore from a stored PEM. The client secret is read
from STDIN (never argv); --webhook-secret is optional, because a
webhook-INACTIVE App is the right shape for a tailnet-only Coolify.
--name seeds \`github_apps.<org>/<repo>\` in environments.yaml when it is
ABSENT, and is refused when it disagrees with an entry that exists.
The state file is the authority: its value is what every later
\`cast apply\` resolves this repo's App by.
--force overwrite an existing PEM/credentials file under <state>/github-apps/.
Both verbs end by asking Coolify which repositories the App can actually see
and failing if <org>/<repo> is not among them the check that turns a silent
misconfiguration into an error next to the thing that caused it. Re-running
\`register\` after that failure RE-VERIFIES an existing Coolify Source of the
same name rather than registering a second one.
capture (adopt a hand-built instance into the age secret store):
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
for a manifest that has not declared generated_secrets yet.
@ -1423,6 +1460,193 @@ function formatVersion(): string {
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
}
// `--client-secret-stdin`, mirroring `docker login --password-stdin`: argv is
// visible in `ps` and lands in shell history, and a GitHub App client secret is
// shown by GitHub exactly once.
async function readAllStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8").trim();
}
// `cast github-app register|create` — one command surface, ONE registration
// implementation (see src/github-app.ts). Everything up to the point where
// credentials exist differs between the two verbs; everything from there on is
// registerGithubApp, which `create` calls rather than reimplements.
async function githubAppCommand(rest: string[]): Promise<number> {
const verb = rest[0];
if (verb !== "register" && verb !== "create") {
console.error(USAGE);
return 2;
}
const { values, positionals } = parseArgs({
args: rest.slice(1),
allowPositionals: true,
options: {
state: { type: "string" },
env: { type: "string" },
instance: { type: "string" },
name: { type: "string" },
force: { type: "boolean" },
// create
port: { type: "string" },
// register
"app-id": { type: "string" },
"installation-id": { type: "string" },
"client-id": { type: "string" },
"client-secret-stdin": { type: "boolean" },
"private-key": { type: "string" },
"webhook-secret": { type: "string" },
},
});
const orgRepo = positionals[0];
// --env is required for the same reason `server add` requires it: this
// writes to a live Coolify, and every write first asserts that the token
// belongs to the environment's declared team.
if (!orgRepo || !orgRepo.includes("/") || !values.env) {
console.error(USAGE);
return 2;
}
// `register`'s two ids reach `Number()` far below, and a non-numeric string
// becomes NaN silently. That matters more here than it usually would, because
// `register` deliberately persists BEFORE it talks to Coolify:
// `JSON.stringify(NaN)` is `null`, so `--app-id nope` would write a credential
// record whose app_id is null and could upload the security key before
// `POST /github-apps` rejects it — a half-run leaving a corrupt record on disk
// and a stray key on the server (cast#7 review).
//
// This sits with the other ARGV checks, above openCoolify/assertTeam, because
// "reject before any write or network call" has to mean the team read too. A
// typo should cost nothing, not one request.
//
// Digits-only rather than Number.isInteger: `1e3` and `0x10` are integers to
// JavaScript but are not how a GitHub App id is written, and quietly storing
// 1000 for `1e3` is the same class of wrong answer this check exists to stop.
if (verb === "register") {
for (const [flag, raw] of [
["--app-id", values["app-id"]],
["--installation-id", values["installation-id"]],
] as const) {
if (raw !== undefined && (!/^\d+$/.test(raw) || Number(raw) <= 0)) {
console.error(
`${flag} must be a positive integer (got ${JSON.stringify(raw)})`,
);
return 2;
}
}
}
// `--port` on the create path has the same defect the ids had, and the same
// rule applies: `Number("abc")` is NaN, which reaches `server.listen(NaN)` in
// github-app.ts and dies as an uncaught ERR_SOCKET_BAD_PORT stack trace —
// after `detectOwnerType` and the org-admin preflight have already gone out.
// Nothing is lost when it fails (no App and no secret exist yet), so this is
// about the command honouring its own stated rule rather than about damage:
// reject before any write or network call, and fail with a sentence instead
// of a stack trace.
//
// Range-checked as well as digits-only, because `--port 99999` is accepted by
// every check the ids need and still cannot be listened on.
if (values.port !== undefined) {
const p = Number(values.port);
if (!/^\d+$/.test(values.port) || p < 1 || p > 65535) {
console.error(
`--port must be a port number between 1 and 65535 (got ${JSON.stringify(values.port)})`,
);
return 2;
}
}
const stateDir = stateDirFrom(values.state);
const bindingsPath = join(stateDir, "environments.yaml");
const bindings = loadBindings(bindingsPath);
const binding = bindings.environments[values.env];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
return 2;
}
// Step 2, before anything reaches a network: the Coolify-facing name comes
// from state. See resolveAppName — this is #5's footgun 1, dissolved.
const { name, seed } = resolveAppName({
bindings,
orgRepo,
nameFlag: values.name,
});
const { instance, client } = openCoolify(stateDir, values.instance, binding);
assertWritable(instance, `github-app ${verb}`);
const team = await assertTeam(client, binding.team, values.env);
console.log(`team ${formatTeam(team)}`);
console.log(
`github app name: ${name}${seed ? " (from --name, not yet in environments.yaml)" : " (from environments.yaml)"}`,
);
const org = orgRepo.split("/")[0] ?? orgRepo;
if (verb === "create") {
await createGithubApp({
client,
orgRepo,
name,
stateDir,
force: values.force,
port: values.port ? Number(values.port) : undefined,
});
} else {
const appId = values["app-id"];
const installationId = values["installation-id"];
const clientId = values["client-id"];
const privateKey = values["private-key"];
if (!appId || !installationId || !clientId || !privateKey) {
console.error(USAGE);
return 2;
}
if (!values["client-secret-stdin"]) {
console.error(
"--client-secret-stdin is required: the client secret is read from stdin,\nnever from argv (which `ps` shows and shell history keeps).",
);
return 2;
}
const clientSecret = await readAllStdin();
if (!clientSecret) {
console.error("no client secret on stdin");
return 2;
}
// #5's footgun 3: a webhook-inactive App is the right configuration for a
// tailnet-only Coolify, and the old script still demanded a secret for it.
const webhookSecret = values["webhook-secret"] ?? generateWebhookSecret();
if (!values["webhook-secret"]) {
console.log(
"no --webhook-secret: generated one (fine for a webhook-inactive App)",
);
}
await registerGithubApp({
client,
name,
org,
orgRepo,
stateDir,
force: values.force,
creds: {
appId: Number(appId),
installationId: Number(installationId),
clientId,
clientSecret,
webhookSecret,
privateKeyPem: readFileSync(privateKey, "utf8"),
},
});
}
// Only after the App is registered AND verified: a state file that names an
// App which does not work is worse than one that names none.
if (seed) {
seedGithubAppBinding(bindingsPath, orgRepo, name);
console.log(
`environments.yaml: github_apps["${orgRepo}"] = ${name} (added)`,
);
}
return 0;
}
async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
@ -2298,6 +2522,9 @@ async function main(): Promise<number> {
});
return 0;
}
if (command === "github-app") {
return await githubAppCommand(rest);
}
if (command === "smoke") {
const { values, positionals } = parseArgs({
args: rest,

1432
src/github-app.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,396 @@
import { spawn } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// `cast github-app register` through the real CLI: argv parsing, the stdin-only
// client secret, the team assert, the name resolved from state, the
// post-condition check, and WHEN environments.yaml is written.
//
// The Coolify here is a stub. Registration against a live instance is
// operator-only territory (#7's testability boundary) and nothing in this file
// pretends otherwise — what it proves is that cast sends the right things and
// reacts correctly to each answer.
let privateKeyPem: string;
beforeAll(() => {
privateKeyPem = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
});
type Stub = {
url: string;
hits: string[];
bodies: Record<string, Record<string, unknown>>;
close: () => Promise<void>;
};
const stubs: Stub[] = [];
async function stubCoolify(opts: { repositories: unknown }): Promise<Stub> {
const hits: string[] = [];
const bodies: Record<string, Record<string, unknown>> = {};
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
const key = `${req.method} ${path}`;
hits.push(key);
let raw = "";
req.on("data", (d) => {
raw += String(d);
});
req.on("end", () => {
if (raw) bodies[key] = JSON.parse(raw);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/security/keys") return json({ uuid: "key-uuid-1" });
if (path === "/github-apps" && req.method === "POST")
return json({ id: 7, uuid: "app-uuid" });
// A clean instance: nothing registered under this name yet, so register
// goes on to create. (The list read is how it avoids a duplicate Source
// on a re-run — Coolify does not enforce unique names.)
if (path === "/github-apps" && req.method === "GET") return json([]);
if (path === "/github-apps/7/repositories")
return json({ repositories: opts.repositories });
res.writeHead(404);
res.end("{}");
});
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
bodies,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
function fixture(
url: string,
githubApps: string,
): { state: string; pem: string } {
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
writeFileSync(
join(state, "environments.yaml"),
[
"# hand-maintained",
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
githubApps,
"",
].join("\n"),
);
const pem = join(state, "downloaded.pem");
writeFileSync(pem, privateKeyPem);
return { state, pem };
}
function run(
args: string[],
stdin: string | null,
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", ...args], {
stdio: [stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
});
if (stdin !== null) {
child.stdin?.end(stdin);
}
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const REGISTER = (state: string, pem: string) => [
"github-app",
"register",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
state,
"--app-id",
"12345",
"--installation-id",
"99887766",
"--client-id",
"Iv23liABCDEF",
"--client-secret-stdin",
"--private-key",
pem,
];
describe("cast github-app register", () => {
it("registers against the name in state, verifies the repo, and never takes the secret from argv", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(REGISTER(f.state, f.pem), "the-client-secret\n");
expect(r.code).toBe(0);
expect(r.output).toContain('team id=0 name="Root Team" ✓');
expect(r.output).toContain("(from environments.yaml)");
expect(r.output).toContain(
"verified: hdb-coolify-prod can clone heavy-duty/incubator ✓",
);
// The secret reached Coolify, and it came off stdin — it is nowhere in
// argv, which `ps` shows and shell history keeps.
expect(stub.bodies["POST /github-apps"].client_secret).toBe(
"the-client-secret",
);
expect(stub.bodies["POST /security/keys"].name).toBe(
"hdb-coolify-prod-key",
);
// A webhook-INACTIVE App is the right shape for a tailnet-only Coolify, so
// no operator has to invent a placeholder any more (#5 footgun 3).
expect(r.output).toContain("generated one");
expect(
String(stub.bodies["POST /github-apps"].webhook_secret).length,
).toBeGreaterThan(0);
// The credentials landed in the state dir, under a git-ignored directory.
expect(
readFileSync(
join(f.state, "github-apps", "hdb-coolify-prod.pem"),
"utf8",
),
).toBe(privateKeyPem);
expect(
readFileSync(join(f.state, "github-apps", ".gitignore"), "utf8"),
).toContain("*");
});
it("seeds an ABSENT binding from --name, keyed by the full slug, comments intact", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(0);
const after = readFileSync(join(f.state, "environments.yaml"), "utf8");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
expect(after).toContain("# hand-maintained");
});
it("REFUSES a --name that disagrees with the state file", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "My Cool App"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("disagrees with environments.yaml");
// Refused before it touched Coolify at all — not even the team assert.
expect(stub.hits).toEqual([]);
});
it("refuses a client secret passed any way other than stdin", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const withoutFlag = REGISTER(f.state, f.pem).filter(
(a) => a !== "--client-secret-stdin",
);
const r = await run(withoutFlag, null);
expect(r.code).toBe(2);
expect(r.output).toContain("--client-secret-stdin is required");
});
it("fails, and does NOT seed state, when the App cannot see the repo", async () => {
// A state file naming an App that does not work is worse than one naming
// none: the next `cast apply` resolves it, uses it, and fails at clone time.
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/something-else" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("cannot see heavy-duty/incubator");
expect(r.output).toContain("can see: heavy-duty/something-else");
expect(readFileSync(join(f.state, "environments.yaml"), "utf8")).toContain(
"github_apps: {}",
);
});
it("refuses a read-only instance before any write", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
writeFileSync(
join(f.state, ".coolify.env"),
`COOLIFY_BASE_URL="${stub.url}"\nCOOLIFY_ACCESS_TOKEN="t"\nCOOLIFY_READ_ONLY=true\n`,
);
const r = await run(REGISTER(f.state, f.pem), "s\n");
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to github-app register");
expect(stub.hits).toEqual([]);
});
// Invalid ids must be refused before ANYTHING happens (cast#7 review).
// `register` persists the credential record before it calls Coolify, and
// `Number("nope")` is NaN which `JSON.stringify` writes as `null` — so
// without this gate a typo produces a credential file with a null app_id AND
// a security key uploaded to a live Coolify, from a run that then fails.
// Both halves are asserted: no stub hit, and no file written.
for (const [what, argv] of [
["a non-numeric --app-id", ["--app-id", "nope"]],
["a non-numeric --installation-id", ["--installation-id", "nope"]],
["a zero --app-id", ["--app-id", "0"]],
["a decimal --app-id", ["--app-id", "12.5"]],
// Integers to JavaScript, but not how an id is written — and silently
// storing 1000 for "1e3" is the quiet wrong answer, not a convenience.
["an exponent --app-id", ["--app-id", "1e3"]],
["a hex --app-id", ["--app-id", "0x10"]],
] as const) {
it(`refuses ${what} before touching disk or Coolify`, async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const base = REGISTER(f.state, f.pem);
const i = base.indexOf(argv[0]);
const args = [...base];
args[i + 1] = argv[1];
const r = await run(args, "s\n");
expect(r.code).toBe(2);
expect(r.output).toContain("must be a positive integer");
// Nothing reached the network...
expect(stub.hits).toEqual([]);
// ...and nothing was created or rewritten in the state dir.
expect(readdirSync(f.state).sort()).toEqual(before);
});
}
// A NEGATIVE id never reaches the check above: parseArgs reads a leading dash
// as an option and rejects `-5` as unknown, exiting 1 rather than 2. That is
// still a refusal before any write or request, which is the property that
// matters — but it is a different code path with a different exit code, so it
// gets its own case rather than a loosened assertion hiding the difference.
it("refuses a negative --app-id before touching disk or Coolify", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const base = REGISTER(f.state, f.pem);
const args = [...base];
args[base.indexOf("--app-id") + 1] = "-5";
const r = await run(args, "s\n");
expect(r.code).not.toBe(0);
expect(stub.hits).toEqual([]);
expect(readdirSync(f.state).sort()).toEqual(before);
});
// `--port` belongs to the CREATE path, and had the same defect the ids did:
// `Number("abc")` is NaN, which reaches server.listen(NaN) and dies as an
// uncaught ERR_SOCKET_BAD_PORT stack trace — after detectOwnerType and the
// org-admin preflight have already gone out. Nothing is lost when it fails
// (no App and no secret exist yet), so this is about the command honouring
// its own rule — reject before any write or network call — and failing with
// a sentence rather than a stack trace.
//
// Driven through `create` because that is the path that reads the flag. The
// validation sits in the shared preamble, above openCoolify, so the run ends
// before the browser flow this command would otherwise need.
for (const [what, port] of [
["a non-numeric --port", "abc"],
["an out-of-range --port", "99999"],
["a zero --port", "0"],
["a decimal --port", "80.5"],
] as const) {
it(`refuses ${what} before touching disk or Coolify`, async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const r = await run(
[
"github-app",
"create",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
f.state,
"--port",
port,
],
null,
);
expect(r.code).toBe(2);
expect(r.output).toContain("--port must be a port number");
expect(stub.hits).toEqual([]);
expect(readdirSync(f.state).sort()).toEqual(before);
});
}
it("prints usage for an unknown subcommand", async () => {
const r = await run(["github-app", "wat"], null);
expect(r.code).toBe(2);
expect(r.output).toContain("cast github-app create");
expect(r.output).toContain("cast github-app register");
});
});

1322
test/github-app.test.ts Normal file

File diff suppressed because it is too large Load diff