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:
commit
1acf172cad
7 changed files with 3590 additions and 38 deletions
66
CHANGELOG.md
66
CHANGELOG.md
|
|
@ -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
|
what it was told to write. Basic-auth-only is the safe slice until
|
||||||
then.
|
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
|
### Changed
|
||||||
|
|
||||||
- **`state:needs-human` no longer waits on the cron to become true** (#131)
|
- **`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
|
block, because the password cannot be read and a block a rebuild cannot
|
||||||
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
|
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
|
### Fixed
|
||||||
|
|
||||||
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
||||||
|
|
|
||||||
149
README.md
149
README.md
|
|
@ -119,6 +119,9 @@ cast inventory <org>/<repo> --env <env>
|
||||||
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
|
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
|
||||||
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
|
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 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 smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||||
cast team [--env <env>]
|
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
|
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.
|
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.
|
- **`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
|
- **`smoke`** — contract test against the project's `smoke_target`: proves
|
||||||
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
|
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,
|
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
|
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.
|
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
|
## Many Coolifys
|
||||||
|
|
||||||
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of
|
`--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
|
## Scripts
|
||||||
|
|
||||||
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
|
Operational helpers, all argument-driven (`scripts/`): restore a database backup
|
||||||
Coolify, restore a database backup into a target container.
|
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
|
**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
|
box over SSH; none of them expects to be executing *on* a server. Anything that
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
|
||||||
227
src/cli.ts
227
src/cli.ts
|
|
@ -84,6 +84,13 @@ import {
|
||||||
renderFleetDiff,
|
renderFleetDiff,
|
||||||
renderProjectHeading,
|
renderProjectHeading,
|
||||||
} from "./fleet.js";
|
} from "./fleet.js";
|
||||||
|
import {
|
||||||
|
createGithubApp,
|
||||||
|
generateWebhookSecret,
|
||||||
|
registerGithubApp,
|
||||||
|
resolveAppName,
|
||||||
|
seedGithubAppBinding,
|
||||||
|
} from "./github-app.js";
|
||||||
import {
|
import {
|
||||||
type LiveResource,
|
type LiveResource,
|
||||||
type SweepEnvironment,
|
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 inventory --env <env> --emit-draft <dir> [--recipient age1…] [--no-secrets]
|
||||||
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
|
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 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 smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
|
||||||
cast team [--env <env>]
|
cast team [--env <env>]
|
||||||
cast versions # list installed versions
|
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,
|
coordinate: --path, --project, --environment, --resource,
|
||||||
--hostname-overlay.
|
--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):
|
capture (adopt a hand-built instance into the age secret store):
|
||||||
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
|
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
|
||||||
for a manifest that has not declared generated_secrets yet.
|
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)})`;
|
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> {
|
async function main(): Promise<number> {
|
||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
if (command === "-h" || command === "--help" || command === "help") {
|
if (command === "-h" || command === "--help" || command === "help") {
|
||||||
|
|
@ -2298,6 +2522,9 @@ async function main(): Promise<number> {
|
||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (command === "github-app") {
|
||||||
|
return await githubAppCommand(rest);
|
||||||
|
}
|
||||||
if (command === "smoke") {
|
if (command === "smoke") {
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args: rest,
|
args: rest,
|
||||||
|
|
|
||||||
1432
src/github-app.ts
Normal file
1432
src/github-app.ts
Normal file
File diff suppressed because it is too large
Load diff
396
test/github-app-register-cli.test.ts
Normal file
396
test/github-app-register-cli.test.ts
Normal 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
1322
test/github-app.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue