feat: cast github-app create/register — run the App Manifest flow instead of transcribing it #124
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
|
||||
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
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 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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
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