forked from heavy-duty/stoke
Compare commits
15 commits
design/fig
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0cb85c7b | |||
|
|
0531bde366 | ||
| 87b3cf98d9 | |||
| 907917a870 | |||
| c85be2e083 | |||
|
|
955ce393fc | ||
|
|
8255c568b1 | ||
|
|
b4b38d1d97 | ||
| 1165ee22c3 | |||
| f5a44021da | |||
| 3e93b20ae6 | |||
| f4b0bdbe4e | |||
| 036364f844 | |||
| 355fcc1f67 | |||
| 1b990d523a |
13 changed files with 1701 additions and 19 deletions
25
.forgejo/workflows/ci.yml
Normal file
25
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# CI: run the test suite on every pull request and on pushes to main, so
|
||||||
|
# regressions are caught before they reach a release tag.
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
|
||||||
|
# your runner actually advertises (common: docker, ubuntu-latest).
|
||||||
|
|
||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Check out commit
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm ci && npm test
|
||||||
251
README.md
251
README.md
|
|
@ -104,6 +104,8 @@ Example stored config:
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
Read commands (`auth status`, `repo list`, `issue list`, `issue show`, `pr list`, `pr show`, `release list`, `release view`, `label list`, `branch list`, `org repos`, `org team list`, `org team member-list`, `user list`, `user show`) accept a `--json` flag that prints the raw API response, pretty-printed, instead of the human-readable format — useful for scripting.
|
||||||
|
|
||||||
### Global options
|
### Global options
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -125,6 +127,8 @@ Options:
|
||||||
-t, --token <token> use an existing personal access token instead of generating one
|
-t, --token <token> use an existing personal access token instead of generating one
|
||||||
--token-file <path> read an existing personal access token from a file
|
--token-file <path> read an existing personal access token from a file
|
||||||
--token-name <name> name for the generated token
|
--token-name <name> name for the generated token
|
||||||
|
--full-scopes grant full read/write access on all non-admin scopes
|
||||||
|
--scopes <csv> comma-separated list of scopes for the generated token
|
||||||
```
|
```
|
||||||
|
|
||||||
Interactive example:
|
Interactive example:
|
||||||
|
|
@ -159,9 +163,31 @@ Flow:
|
||||||
|
|
||||||
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name.
|
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name.
|
||||||
2. Calls `POST /api/v1/users/{login}/tokens` to generate a personal access token.
|
2. Calls `POST /api/v1/users/{login}/tokens` to generate a personal access token.
|
||||||
3. Requests the standard non-admin scopes: `read/write` for `activitypub`, `issue`, `misc`, `organization`, `package`, `repository`, and `user`.
|
3. Requests the default least-privilege scopes (see "Token scopes" below).
|
||||||
4. Writes the token, token id, user details and URL to the config file.
|
4. Writes the token, token id, user details and URL to the config file.
|
||||||
|
|
||||||
|
Token scopes:
|
||||||
|
|
||||||
|
By default the generated token is least-privilege, covering the common
|
||||||
|
issue/PR/repository commands:
|
||||||
|
|
||||||
|
- `read:issue`, `write:issue` — issues, PR comments/reviews, labels
|
||||||
|
- `read:repository`, `write:repository` — repositories, branches, releases, collaborators, pull requests
|
||||||
|
- `read:user` — `auth status`, `user list`, `user show`
|
||||||
|
- `read:organization` — `org repos`, `org team list`, `org team member-list`
|
||||||
|
|
||||||
|
Organization administration (`org create`, `org avatar`, `org team create`,
|
||||||
|
`org team member-add`, `org team member-remove`) and package publishing need
|
||||||
|
broader access. Pass `--full-scopes` for the previous all-scopes behavior
|
||||||
|
(`read`/`write` on `activitypub`, `issue`, `misc`, `organization`, `package`,
|
||||||
|
`repository`, `user`), or `--scopes <csv>` for a custom list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke auth login --scopes read:issue,write:issue,read:repository
|
||||||
|
```
|
||||||
|
|
||||||
|
The scopes the token was created with are printed after a successful login.
|
||||||
|
|
||||||
### `stoke auth logout`
|
### `stoke auth logout`
|
||||||
|
|
||||||
Revoke the stored token remotely and delete the local config.
|
Revoke the stored token remotely and delete the local config.
|
||||||
|
|
@ -196,6 +222,29 @@ stoke auth status
|
||||||
|
|
||||||
Calls `GET /api/v1/user` with the stored token.
|
Calls `GET /api/v1/user` with the stored token.
|
||||||
|
|
||||||
|
### `stoke repo clone`
|
||||||
|
|
||||||
|
Clone a repository from the configured Forgejo instance using the stored credentials.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Arguments:
|
||||||
|
[directory] destination directory (default: repository name)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
--branch <branch> checkout this branch instead of the default branch
|
||||||
|
--depth <depth> create a shallow clone with the given history depth
|
||||||
|
--origin <name> name for the created remote (default: origin)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke repo clone -o heavy-duty -r stoke
|
||||||
|
stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`.
|
||||||
|
|
||||||
### `stoke repo create`
|
### `stoke repo create`
|
||||||
|
|
||||||
Create a new repository for the authenticated user.
|
Create a new repository for the authenticated user.
|
||||||
|
|
@ -391,6 +440,43 @@ stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md
|
||||||
|
|
||||||
Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
|
Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
|
||||||
|
|
||||||
|
### `stoke issue show`
|
||||||
|
|
||||||
|
Show details of an issue.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
-n, --number <number> issue number (required)
|
||||||
|
--json print raw JSON instead of human-readable output
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke issue show -o heavy-duty -r stoke -n 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `GET /api/v1/repos/{owner}/{repo}/issues/{number}`.
|
||||||
|
|
||||||
|
### `stoke issue comment`
|
||||||
|
|
||||||
|
Add a comment to an issue. Body is required (whitespace-only is rejected). When both `-b` and `--body-file` are set, **`--body-file` wins**.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
-n, --number <number> issue number (required)
|
||||||
|
-b, --body <body> comment body (markdown; required unless --body-file)
|
||||||
|
--body-file <path> read the comment body from a file (wins over -b)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke issue comment -o heavy-duty -r stoke -n 10 -b "Confirmed."
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments`.
|
||||||
|
|
||||||
### `stoke pr list`
|
### `stoke pr list`
|
||||||
|
|
||||||
List pull requests in a repository.
|
List pull requests in a repository.
|
||||||
|
|
@ -500,14 +586,148 @@ Options:
|
||||||
--event <event> approve|approved, request-changes|request_changes, comment (required)
|
--event <event> approve|approved, request-changes|request_changes, comment (required)
|
||||||
-b, --body <body> review body (markdown; required for request-changes and comment)
|
-b, --body <body> review body (markdown; required for request-changes and comment)
|
||||||
--body-file <path> read the review body from a file (wins over -b)
|
--body-file <path> read the review body from a file (wins over -b)
|
||||||
|
--commit <sha> commit SHA the review applies to (sent as commit_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stoke pr review -o heavy-duty -r stoke -n 3 --event approve -b "Ship it."
|
stoke pr review -o heavy-duty -r stoke -n 3 --event approve -b "Ship it."
|
||||||
stoke pr review -o heavy-duty -r stoke -n 3 --event request-changes --body-file notes.md
|
stoke pr review -o heavy-duty -r stoke -n 3 --event request-changes --body-file notes.md
|
||||||
|
stoke pr review -o heavy-duty -r stoke -n 3 --event approve --commit 9fceb02
|
||||||
```
|
```
|
||||||
|
|
||||||
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. Prints the review URL when the forge returns one.
|
Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. `--commit` is sent as `commit_id`; when omitted, no `commit_id` is sent. Prints the review URL when the forge returns one.
|
||||||
|
|
||||||
|
### `stoke release list`
|
||||||
|
|
||||||
|
List releases in a repository.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
-l, --limit <number> maximum releases to display (default: 50; use 0 for all)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke release list -o heavy-duty -r stoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `GET /api/v1/repos/{owner}/{repo}/releases` and auto-paginates.
|
||||||
|
|
||||||
|
### `stoke release view`
|
||||||
|
|
||||||
|
Show the release for a tag, including its notes.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
--tag <tag> tag name of the release (required)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke release view -o heavy-duty -r stoke --tag v1.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`.
|
||||||
|
|
||||||
|
### `stoke release create`
|
||||||
|
|
||||||
|
Create a release. If the tag does not exist yet, Forgejo creates it from `--target` (or the repository default branch). When both `-b` and `--body-file` are set, **`--body-file` wins**.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
--tag <tag> tag name for the release (required)
|
||||||
|
--target <ref> branch or commit the tag is created from (default: default branch)
|
||||||
|
-t, --title <title> release title (default: the tag name)
|
||||||
|
-b, --body <body> release notes (markdown)
|
||||||
|
--body-file <path> read the release notes from a file (wins over -b)
|
||||||
|
--draft create as a draft release
|
||||||
|
--prerelease mark as a prerelease
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke release create -o heavy-duty -r stoke --tag v1.3.0 --body-file release-notes.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `POST /api/v1/repos/{owner}/{repo}/releases`.
|
||||||
|
|
||||||
|
### `stoke label list`
|
||||||
|
|
||||||
|
List labels in a repository.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
-l, --limit <number> maximum labels to display (default: 50; use 0 for all)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke label list -o heavy-duty -r stoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `GET /api/v1/repos/{owner}/{repo}/labels` and auto-paginates.
|
||||||
|
|
||||||
|
### `stoke label create`
|
||||||
|
|
||||||
|
Create a label in a repository. The color is validated (6 hex digits, with or without a leading `#`) before any network call.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
--name <name> label name (required)
|
||||||
|
--color <color> label color, 6 hex digits (required)
|
||||||
|
-d, --description <description> label description
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke label create -o heavy-duty -r stoke --name release --color 0E8A16 \
|
||||||
|
-d "Release flow and version/packaging work"
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `POST /api/v1/repos/{owner}/{repo}/labels`.
|
||||||
|
|
||||||
|
### `stoke label delete`
|
||||||
|
|
||||||
|
Delete a label from a repository, by `--id` or `--name` (exactly one is required; passing both is rejected).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke label delete -o heavy-duty -r stoke --name needs-triage
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `DELETE /api/v1/repos/{owner}/{repo}/labels/{id}`. A `--name` is resolved to an id via the repository label list first.
|
||||||
|
|
||||||
|
### `stoke label add`
|
||||||
|
|
||||||
|
Add labels to an issue or pull request (PRs are issues as far as labels are concerned).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
-o, --owner <owner> repository owner (required)
|
||||||
|
-r, --repo <repo> repository name (required)
|
||||||
|
-n, --number <number> issue or pull request number (required)
|
||||||
|
--name <name...> one or more label names (required)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke label add -o heavy-duty -r stoke -n 12 --name release scope:cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/labels`. Names are resolved to ids first; an unknown name fails with `Label not found`.
|
||||||
|
|
||||||
|
### `stoke label remove`
|
||||||
|
|
||||||
|
Remove labels from an issue or pull request.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke label remove -o heavy-duty -r stoke -n 12 --name needs-triage
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `DELETE /api/v1/repos/{owner}/{repo}/issues/{number}/labels/{id}` once per label.
|
||||||
|
|
||||||
### `stoke branch list`
|
### `stoke branch list`
|
||||||
|
|
||||||
|
|
@ -709,6 +929,33 @@ stoke user show -u andres
|
||||||
|
|
||||||
Calls `GET /api/v1/users/{username}`.
|
Calls `GET /api/v1/users/{username}`.
|
||||||
|
|
||||||
|
### `stoke api`
|
||||||
|
|
||||||
|
Make an authenticated request to any Forgejo API endpoint and print the JSON response. The escape hatch for everything stoke does not wrap yet — pass the endpoint path without the `/api/v1` prefix.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Arguments:
|
||||||
|
<endpoint> endpoint path starting with / (required)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-X, --method <method> GET, POST, PUT, PATCH or DELETE
|
||||||
|
(default: GET, or POST when --input is given)
|
||||||
|
--input <json> JSON request body, inline or @path to read from a file
|
||||||
|
--paginate fetch all pages (GET endpoints returning a JSON array);
|
||||||
|
overrides any limit/page in the endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stoke api /user
|
||||||
|
stoke api "/repos/heavy-duty/stoke/pulls?state=closed" --paginate
|
||||||
|
stoke api /repos/heavy-duty/stoke/issues/12/comments --input '{"body":"hi"}'
|
||||||
|
stoke api /repos/heavy-duty/stoke/contents/CHANGELOG.md --input @payload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Calls `{METHOD} /api/v1{endpoint}` with the stored token. The endpoint must start with `/`; the method, `--paginate` + non-GET, `GET` + `--input` (a GET cannot carry a body), and malformed `--input` JSON are all rejected before any network call.
|
||||||
|
|
||||||
|
**Security:** `stoke api` is a full authenticated passthrough — it does anything the stored token is allowed to do. Never interpolate untrusted strings (issue titles, PR bodies, user input) into the endpoint or `--input`; treat every call like the credential it carries.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
28
assets/logo-mark.svg
Normal file
28
assets/logo-mark.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Mark">
|
||||||
|
<g clip-path="url(#clip0_0_4)">
|
||||||
|
<rect width="120" height="120" rx="28" fill="#0C0A09"/>
|
||||||
|
<g id="Ellipse" filter="url(#filter0_f_0_4)">
|
||||||
|
<circle cx="60" cy="85" r="75" fill="#FF6A2C" fill-opacity="0.35"/>
|
||||||
|
</g>
|
||||||
|
<path id="spark" d="M60 22L68.061 51.939L98 60L68.061 68.061L60 98L51.939 68.061L22 60L51.939 51.939L60 22Z" fill="url(#paint0_linear_0_4)"/>
|
||||||
|
<path id="Star" d="M89 20L91.489 28.511L100 31L91.489 33.489L89 42L86.511 33.489L78 31L86.511 28.511L89 20Z" fill="#FFB347"/>
|
||||||
|
</g>
|
||||||
|
<rect x="0.5" y="0.5" width="119" height="119" rx="27.5" stroke="#3A2A1E"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_0_4" x="-55" y="-30" width="230" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="20" result="effect1_foregroundBlur_0_4"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_0_4" x1="60" y1="22" x2="60" y2="98" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFB347"/>
|
||||||
|
<stop offset="0.5" stop-color="#FF6A2C"/>
|
||||||
|
<stop offset="1" stop-color="#E2452B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_0_4">
|
||||||
|
<rect width="120" height="120" rx="28" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
114
docs/DESIGN.md
Normal file
114
docs/DESIGN.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# stoke — Brand & Landing Design
|
||||||
|
|
||||||
|
> _The operator's hand on the forge._
|
||||||
|
|
||||||
|
A complete brand identity and marketing landing page for **stoke**, the CLI for the
|
||||||
|
Heavy Duty Forgejo forge. Designed from the codebase itself — every command group,
|
||||||
|
install path, and principle on the page maps to something the tool actually does.
|
||||||
|
|
||||||
|
## 🔗 Figma
|
||||||
|
|
||||||
|
**[stoke — Brand & Landing (Figma)](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing)**
|
||||||
|
|
||||||
|
The file has four pages:
|
||||||
|
|
||||||
|
| Page | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `01 · Brand System` | Logo lockup + ember spark mark, 14-token color palette, type scale, voice & principles |
|
||||||
|
| `02 · Landing — Desktop` | Full 1440px landing: nav, hero, ecosystem strip, command showcase, philosophy split, install, CTA, footer |
|
||||||
|
| `03 · Landing — Mobile` | 390px responsive landing |
|
||||||
|
| `04 · Assets & Components` | Command-group icon set (24px line) + generated-asset gallery |
|
||||||
|
|
||||||
|
> Renders live in the [Figma file](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing) — see "Why the raster assets are not committed here" below.
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
`stoke` is named after the act of feeding and tending a fire. The identity takes that
|
||||||
|
literally: a working **forge** — controlled heat, hot steel, precision — rendered as
|
||||||
|
hot ember on near-black anthracite. Industrial craft meets modern dev tool. The product
|
||||||
|
principle from the README ("every real operation performed against Forgejo becomes a new
|
||||||
|
CLI command") is the spine of the page.
|
||||||
|
|
||||||
|
### Color — ember on anthracite
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `bg/base` | `#0C0A09` | Anthracite canvas |
|
||||||
|
| `bg/raised` | `#1A1614` | Raised surfaces |
|
||||||
|
| `bg/inset` | `#14100E` | Wells / code blocks |
|
||||||
|
| `border/subtle` | `#2A2320` | Hairlines |
|
||||||
|
| `border/ember` | `#3A2A1E` | Warm edges |
|
||||||
|
| `ember/red` | `#E2452B` | Deep heat |
|
||||||
|
| `ember/orange` | `#FF6A2C` | **Primary** · CTAs |
|
||||||
|
| `ember/amber` | `#FFB347` | Bright heat · command text |
|
||||||
|
| `hot/white` | `#FFF3E6` | White-hot highlight |
|
||||||
|
| `steel/blue` | `#3B6EA5` | Quench accent · links / URLs |
|
||||||
|
| `text/primary` | `#F5EDE6` | Primary text |
|
||||||
|
| `text/secondary` | `#A89A8E` | Secondary text |
|
||||||
|
| `text/muted` | `#6B5F56` | Meta / muted |
|
||||||
|
| `text/onEmber` | `#1A0E06` | Text on ember fills |
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
- **Archivo** (Black / SemiBold) — geometric-industrial display & headings, tight tracking
|
||||||
|
- **Inter** (Regular / Medium) — body copy
|
||||||
|
- **JetBrains Mono** — everything command-flavored (it's a CLI): prompts, flags, install snippets
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
|
||||||
|
An **ember spark** mark — a four-point spark that reads as both a flame and a striker
|
||||||
|
feeding the forge — set over a warm glow, locked up with a heavy lowercase `stoke`
|
||||||
|
wordmark. Vector source in [`assets/logo-mark.svg`](../assets/logo-mark.svg).
|
||||||
|
|
||||||
|
## Page structure (desktop)
|
||||||
|
|
||||||
|
1. **Nav** — logo, section links, `Get started`
|
||||||
|
2. **Hero** — forge backdrop, `Every operation becomes a command.`, copy-able `apt-get install stoke`, CTAs
|
||||||
|
3. **Ecosystem strip** — the six repos the forge manages: `box · rig · cast · infra · handbook · incubator`
|
||||||
|
4. **Command showcase** — a live, syntax-colored `stoke` terminal session + cards for all 7 command groups (auth, repo, pr, issue, branch, org, user) with real example commands and command counts
|
||||||
|
5. **Philosophy split** — "Built for the hand on the forge" with the operator image + feature checklist (apt-native, 0600 tokens, batch import, dogfooded)
|
||||||
|
6. **Install** — apt (recommended) and from-source code cards, with the Node ≥ 22.12 note
|
||||||
|
7. **CTA band** — `Feed the forge.` over ember texture
|
||||||
|
8. **Footer** — logo, link columns, forge URL, version
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
All imagery generated with **Artlist** (Seedream 5.0 Pro for stills, Kling 1.6 for the
|
||||||
|
ambient loop) and embedded in the Figma file.
|
||||||
|
|
||||||
|
| Asset | Where it lives |
|
||||||
|
| --- | --- |
|
||||||
|
| `assets/logo-mark.svg` | **In this repo** — ember spark logo mark, vector, 1.4 kB |
|
||||||
|
| Hero backdrop · 2048×878 21:9 — glowing coals, hot steel, sparks | Figma, page 04 |
|
||||||
|
| Operator's hand stoking coals · 3:2 (philosophy section) | Figma, page 04 |
|
||||||
|
| Molten ember bed · 21:9 (CTA / section backdrops) | Figma, page 04 |
|
||||||
|
| 5s ambient ember loop (motion) | Figma, page 04 |
|
||||||
|
| Desktop landing render · brand system render | Figma, pages 02 / 01 |
|
||||||
|
|
||||||
|
### Why the raster assets are not committed here
|
||||||
|
|
||||||
|
The first version of this change committed all of them — **23 MB of PNG and MP4
|
||||||
|
into a CLI repo.** That was a mistake, and it was mine. Two concrete costs:
|
||||||
|
|
||||||
|
- `stoke`'s `.git` is ~23 MB; the assets would have **doubled it**, permanently.
|
||||||
|
Git history is forever, so a marketing render committed today is still being
|
||||||
|
cloned by every contributor in five years.
|
||||||
|
- `package.json` had no `files` whitelist, so `npm pack` shipped the working
|
||||||
|
directory. Measured: **the tarball went from ~100 kB to 23.7 MB** — a 200×
|
||||||
|
bloat delivered to every user of a command-line tool, for images none of them
|
||||||
|
will ever look at.
|
||||||
|
|
||||||
|
Binaries that exist to be *looked at* belong where people look at them: the Figma
|
||||||
|
file, which is linked at the top and is the source of truth anyway. The vector
|
||||||
|
logo stays because it is 1.4 kB of text, diffs cleanly, and is the one asset the
|
||||||
|
project itself might need to render.
|
||||||
|
|
||||||
|
This change also adds the missing `files` whitelist to `package.json`, so the
|
||||||
|
published tarball now contains only `src/` and the docs a user needs — a
|
||||||
|
pre-existing packaging bug that shipping the test suite had been hiding.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This PR was opened with `stoke pr create` — the tool designing its own storefront.
|
||||||
|
- Nothing here changes the CLI. It adds a `docs/` design record, one vector
|
||||||
|
asset, and a packaging fix.
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^15.0.0"
|
"commander": "^15.0.0"
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "stoke",
|
"name": "stoke",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||||
"main": "src/cli.js",
|
"main": "src/cli.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -28,5 +28,12 @@
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"stoke": "src/cli.js"
|
"stoke": "src/cli.js"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"src/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"docs/DESIGN.md",
|
||||||
|
"assets/logo-mark.svg"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,19 @@ echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTI
|
||||||
# read these.
|
# read these.
|
||||||
$SUDO chmod 0644 "$KEYRING" "$LIST"
|
$SUDO chmod 0644 "$KEYRING" "$LIST"
|
||||||
|
|
||||||
|
# Fail fast with a clear message when the registry has no package published
|
||||||
|
# yet: without a Release file, `apt-get update` would only fail with a
|
||||||
|
# generic "repository does not have a Release file" error. A definitive 404
|
||||||
|
# is fatal; any other curl outcome (e.g. a network hiccup) is left for
|
||||||
|
# apt-get update to report.
|
||||||
|
RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release"
|
||||||
|
if [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then
|
||||||
|
echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2
|
||||||
|
echo "($RELEASE_URL returned 404)." >&2
|
||||||
|
echo "Install stoke via npm or manually instead — see the README." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
|
# Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo
|
||||||
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
|
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
|
||||||
# in the upstream signing library). Try the properly signed source first so
|
# in the upstream signing library). Try the properly signed source first so
|
||||||
|
|
|
||||||
67
src/api.js
67
src/api.js
|
|
@ -134,9 +134,20 @@ class ForgejoClient {
|
||||||
async getAll(endpoint, params = {}) {
|
async getAll(endpoint, params = {}) {
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const all = [];
|
const all = [];
|
||||||
|
// The pager owns limit/page: a caller-supplied pair must be overridden,
|
||||||
|
// not duplicated — a duplicated limit pins the page size the server
|
||||||
|
// honors first and can truncate or loop the walk.
|
||||||
|
const queryIndex = endpoint.indexOf('?');
|
||||||
|
const path = queryIndex === -1 ? endpoint : endpoint.slice(0, queryIndex);
|
||||||
|
const baseQuery = new URLSearchParams(queryIndex === -1 ? '' : endpoint.slice(queryIndex + 1));
|
||||||
|
baseQuery.delete('limit');
|
||||||
|
baseQuery.delete('page');
|
||||||
for (let page = 1; page <= 1000; page += 1) {
|
for (let page = 1; page <= 1000; page += 1) {
|
||||||
const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString();
|
const query = new URLSearchParams(baseQuery);
|
||||||
const items = await this.get(`${endpoint}?${query}`);
|
for (const [key, value] of Object.entries(params)) query.set(key, value);
|
||||||
|
query.set('limit', String(pageSize));
|
||||||
|
query.set('page', String(page));
|
||||||
|
const items = await this.get(`${path}?${query.toString()}`);
|
||||||
if (!Array.isArray(items) || items.length === 0) break;
|
if (!Array.isArray(items) || items.length === 0) break;
|
||||||
all.push(...items);
|
all.push(...items);
|
||||||
if (items.length < pageSize) break;
|
if (items.length < pageSize) break;
|
||||||
|
|
@ -159,6 +170,14 @@ class ForgejoClient {
|
||||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload);
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIssue(owner, repo, index) {
|
||||||
|
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIssueComment(owner, repo, index, body) {
|
||||||
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
|
||||||
|
}
|
||||||
|
|
||||||
async listPullRequests(owner, repo, opts = {}) {
|
async listPullRequests(owner, repo, opts = {}) {
|
||||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
|
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
|
||||||
}
|
}
|
||||||
|
|
@ -175,11 +194,13 @@ class ForgejoClient {
|
||||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPullRequestReview(owner, repo, index, event, body) {
|
async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) {
|
||||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, {
|
const payload = {
|
||||||
event,
|
event,
|
||||||
body: body || '',
|
body: body || '',
|
||||||
});
|
};
|
||||||
|
if (commitId) payload.commit_id = commitId;
|
||||||
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async mergePullRequest(owner, repo, index, payload) {
|
async mergePullRequest(owner, repo, index, payload) {
|
||||||
|
|
@ -190,6 +211,42 @@ class ForgejoClient {
|
||||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
|
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listReleases(owner, repo, opts = {}) {
|
||||||
|
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReleaseByTag(owner, repo, tag) {
|
||||||
|
return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRelease(owner, repo, payload) {
|
||||||
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLabels(owner, repo, opts = {}) {
|
||||||
|
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLabel(owner, repo, payload) {
|
||||||
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLabel(owner, repo, id) {
|
||||||
|
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull requests are issues as far as labels are concerned, so these two
|
||||||
|
// serve both surfaces.
|
||||||
|
async addIssueLabels(owner, repo, index, labelIds) {
|
||||||
|
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels`, {
|
||||||
|
labels: labelIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeIssueLabel(owner, repo, index, labelId) {
|
||||||
|
return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels/${labelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
async addCollaborator(owner, repo, username, permission) {
|
async addCollaborator(owner, repo, username, permission) {
|
||||||
return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, {
|
return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, {
|
||||||
permission,
|
permission,
|
||||||
|
|
|
||||||
553
src/cli.js
553
src/cli.js
|
|
@ -3,7 +3,7 @@
|
||||||
const { Command, InvalidArgumentError } = require('commander');
|
const { Command, InvalidArgumentError } = require('commander');
|
||||||
const readline = require('node:readline');
|
const readline = require('node:readline');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync, spawnSync } = require('node:child_process');
|
||||||
const { stdin: input, stdout: output } = require('node:process');
|
const { stdin: input, stdout: output } = require('node:process');
|
||||||
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
||||||
const { ForgejoClient } = require('./api');
|
const { ForgejoClient } = require('./api');
|
||||||
|
|
@ -84,12 +84,37 @@ function parseId(value) {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDepth(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) {
|
||||||
|
throw new InvalidArgumentError('Depth must be a positive integer.');
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read commands share a --json flag that prints the raw API response
|
||||||
|
// (pretty-printed) instead of the human-readable format.
|
||||||
|
function printJson(data) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
function makeTokenName() {
|
function makeTokenName() {
|
||||||
const host = require('node:os').hostname() || 'unknown';
|
const host = require('node:os').hostname() || 'unknown';
|
||||||
return `stoke-${host}-${Date.now()}`;
|
return `stoke-${host}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Least-privilege default: enough for the daily issue/PR/repository commands.
|
||||||
|
// Org administration (org create/avatar, team create/member-*) and anything
|
||||||
|
// else outside this set needs --full-scopes or an explicit --scopes list.
|
||||||
const DEFAULT_TOKEN_SCOPES = [
|
const DEFAULT_TOKEN_SCOPES = [
|
||||||
|
'read:issue', 'write:issue',
|
||||||
|
'read:repository', 'write:repository',
|
||||||
|
'read:user',
|
||||||
|
'read:organization',
|
||||||
|
];
|
||||||
|
|
||||||
|
// The previous behavior: full read/write on every non-admin scope.
|
||||||
|
const FULL_TOKEN_SCOPES = [
|
||||||
'read:activitypub', 'write:activitypub',
|
'read:activitypub', 'write:activitypub',
|
||||||
'read:issue', 'write:issue',
|
'read:issue', 'write:issue',
|
||||||
'read:misc', 'write:misc',
|
'read:misc', 'write:misc',
|
||||||
|
|
@ -99,6 +124,10 @@ const DEFAULT_TOKEN_SCOPES = [
|
||||||
'read:user', 'write:user',
|
'read:user', 'write:user',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function parseScopesOption(csv) {
|
||||||
|
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function printErrorAndExit(err) {
|
function printErrorAndExit(err) {
|
||||||
console.error(`Authentication failed: ${err.message}`);
|
console.error(`Authentication failed: ${err.message}`);
|
||||||
if (err.status) {
|
if (err.status) {
|
||||||
|
|
@ -124,10 +153,27 @@ auth
|
||||||
.option('-t, --token <token>', 'use an existing personal access token instead of generating one')
|
.option('-t, --token <token>', 'use an existing personal access token instead of generating one')
|
||||||
.option('--token-file <path>', 'read an existing personal access token from a file')
|
.option('--token-file <path>', 'read an existing personal access token from a file')
|
||||||
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName())
|
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName())
|
||||||
|
.option('--full-scopes', 'grant full read/write access on all non-admin scopes', false)
|
||||||
|
.option('--scopes <csv>', 'comma-separated list of scopes for the generated token')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
|
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
|
||||||
|
|
||||||
|
if (options.fullScopes && options.scopes) {
|
||||||
|
console.error('Use either --full-scopes or --scopes, not both.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let scopes = DEFAULT_TOKEN_SCOPES;
|
||||||
|
if (options.fullScopes) {
|
||||||
|
scopes = FULL_TOKEN_SCOPES;
|
||||||
|
} else if (options.scopes) {
|
||||||
|
scopes = parseScopesOption(options.scopes);
|
||||||
|
if (!scopes.length) {
|
||||||
|
console.error('--scopes produced an empty scope list.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenFile) token = readSecretFile(tokenFile, 'token');
|
if (tokenFile) token = readSecretFile(tokenFile, 'token');
|
||||||
if (passwordFile) password = readSecretFile(passwordFile, 'password');
|
if (passwordFile) password = readSecretFile(passwordFile, 'password');
|
||||||
|
|
||||||
|
|
@ -159,7 +205,7 @@ auth
|
||||||
const me = await client.verifyBasicAuth(username, password);
|
const me = await client.verifyBasicAuth(username, password);
|
||||||
const login = me.login;
|
const login = me.login;
|
||||||
|
|
||||||
const tokenRes = await client.createToken(login, password, tokenName, DEFAULT_TOKEN_SCOPES);
|
const tokenRes = await client.createToken(login, password, tokenName, scopes);
|
||||||
if (!tokenRes.sha1) {
|
if (!tokenRes.sha1) {
|
||||||
throw new Error('Token generation succeeded but no token value was returned.');
|
throw new Error('Token generation succeeded but no token value was returned.');
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +219,7 @@ auth
|
||||||
tokenId: tokenRes.id,
|
tokenId: tokenRes.id,
|
||||||
};
|
};
|
||||||
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
|
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
|
||||||
|
console.log(`Scopes: ${scopes.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|
@ -229,7 +276,8 @@ auth
|
||||||
auth
|
auth
|
||||||
.command('status')
|
.command('status')
|
||||||
.description('Show the current authentication status')
|
.description('Show the current authentication status')
|
||||||
.action(async () => {
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (!config || !config.token) {
|
if (!config || !config.token) {
|
||||||
|
|
@ -239,6 +287,10 @@ auth
|
||||||
|
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const me = await client.get('/user');
|
const me = await client.get('/user');
|
||||||
|
if (options.json) {
|
||||||
|
printJson(me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('Instance: ', config.url);
|
console.log('Instance: ', config.url);
|
||||||
console.log('Login: ', me.login);
|
console.log('Login: ', me.login);
|
||||||
console.log('Username: ', me.username);
|
console.log('Username: ', me.username);
|
||||||
|
|
@ -279,11 +331,16 @@ repo
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List repositories for the authenticated user')
|
.description('List repositories for the authenticated user')
|
||||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const repos = await client.listRepos();
|
const repos = await client.listRepos();
|
||||||
|
if (options.json) {
|
||||||
|
printJson(repos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No repositories found.');
|
console.log('No repositories found.');
|
||||||
|
|
@ -302,6 +359,67 @@ repo
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hand the stored token to git through environment-based config instead of
|
||||||
|
// the remote URL or `git clone -c`: GIT_CONFIG_* variables only live for the
|
||||||
|
// duration of this process, so the token never reaches the command line,
|
||||||
|
// the remote URL, or the cloned repository's .git/config (which `-c` would
|
||||||
|
// write into). GIT_TERMINAL_PROMPT=0 keeps git from interactively asking
|
||||||
|
// for credentials the session already owns.
|
||||||
|
function gitAuthEnv(config) {
|
||||||
|
const username = config.username || config.login || 'stoke';
|
||||||
|
const basic = Buffer.from(`${username}:${config.token}`).toString('base64');
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
GIT_CONFIG_COUNT: '1',
|
||||||
|
GIT_CONFIG_KEY_0: `http.${config.url}.extraHeader`,
|
||||||
|
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
repo
|
||||||
|
.command('clone')
|
||||||
|
.description('Clone a repository using the stored Forgejo credentials')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.argument('[directory]', 'destination directory (defaults to the repository name)')
|
||||||
|
.option('--branch <branch>', 'checkout this branch instead of the default branch')
|
||||||
|
.option('--depth <depth>', 'create a shallow clone with the given history depth', parseDepth)
|
||||||
|
.option('--origin <name>', 'name for the created remote', 'origin')
|
||||||
|
.action((directory, options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
if (!config || !config.url || !config.token) {
|
||||||
|
throw new Error('Not authenticated. Run: stoke auth login');
|
||||||
|
}
|
||||||
|
const base = config.url.replace(/\/+$/, '');
|
||||||
|
const cloneUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`;
|
||||||
|
|
||||||
|
const args = ['clone', '--origin', options.origin];
|
||||||
|
if (options.branch) args.push('--branch', options.branch);
|
||||||
|
if (options.depth) args.push('--depth', String(options.depth));
|
||||||
|
args.push(cloneUrl);
|
||||||
|
if (directory) args.push(directory);
|
||||||
|
|
||||||
|
const res = spawnSync('git', args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: gitAuthEnv({ ...config, url: base }),
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
throw new Error(`Failed to run git: ${res.error.message}`);
|
||||||
|
}
|
||||||
|
if (res.status !== 0) {
|
||||||
|
// Git's error output already went to stderr; forward its exit status
|
||||||
|
// so scripts see the same failure a plain `git clone` would produce.
|
||||||
|
process.exit(res.status == null ? 1 : res.status);
|
||||||
|
}
|
||||||
|
console.log(`Cloned ${options.owner}/${options.repo} into ${directory || options.repo}.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Repository clone failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
repo
|
repo
|
||||||
.command('create')
|
.command('create')
|
||||||
.description('Create a new repository for the authenticated user')
|
.description('Create a new repository for the authenticated user')
|
||||||
|
|
@ -529,6 +647,7 @@ issue
|
||||||
.option('-s, --state <state>', 'issue state: open, closed, all', 'open')
|
.option('-s, --state <state>', 'issue state: open, closed, all', 'open')
|
||||||
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
|
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
|
||||||
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -537,6 +656,10 @@ issue
|
||||||
state: options.state,
|
state: options.state,
|
||||||
type: options.type,
|
type: options.type,
|
||||||
});
|
});
|
||||||
|
if (options.json) {
|
||||||
|
printJson(issues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
|
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No issues found.');
|
console.log('No issues found.');
|
||||||
|
|
@ -584,6 +707,64 @@ issue
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
issue
|
||||||
|
.command('show')
|
||||||
|
.description('Show details of an issue')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('-n, --number <number>', 'issue number', parseId)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const issueData = await client.getIssue(options.owner, options.repo, options.number);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(issueData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const author = issueData.user?.login || '(unknown)';
|
||||||
|
console.log(`#${issueData.number} [${issueData.state}] ${issueData.title}`);
|
||||||
|
console.log(`URL: ${issueData.html_url}`);
|
||||||
|
console.log(`Author: ${author}`);
|
||||||
|
console.log(`Created: ${issueData.created_at}`);
|
||||||
|
if (issueData.body) {
|
||||||
|
console.log('\n' + issueData.body);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to show issue: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
issue
|
||||||
|
.command('comment')
|
||||||
|
.description('Add a comment to an issue')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('-n, --number <number>', 'issue number', parseId)
|
||||||
|
.option('-b, --body <body>', 'comment body (markdown; required unless --body-file)')
|
||||||
|
.option('--body-file <path>', 'read the comment body from a file (wins over -b)')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const rawBody = readBodyOption(options) || '';
|
||||||
|
if (rawBody.trim().length === 0) {
|
||||||
|
console.error('Comment body is required. Use -b/--body or --body-file.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const result = await client.createIssueComment(options.owner, options.repo, options.number, rawBody);
|
||||||
|
console.log(`Comment added to #${options.number}.`);
|
||||||
|
console.log(`URL: ${result.html_url}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to comment on issue: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const pr = program
|
const pr = program
|
||||||
.command('pr')
|
.command('pr')
|
||||||
.description('Manage pull requests');
|
.description('Manage pull requests');
|
||||||
|
|
@ -595,6 +776,7 @@ pr
|
||||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
.option('-s, --state <state>', 'PR state: open, closed, all', 'open')
|
.option('-s, --state <state>', 'PR state: open, closed, all', 'open')
|
||||||
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -602,6 +784,10 @@ pr
|
||||||
const pulls = await client.listPullRequests(options.owner, options.repo, {
|
const pulls = await client.listPullRequests(options.owner, options.repo, {
|
||||||
state: options.state,
|
state: options.state,
|
||||||
});
|
});
|
||||||
|
if (options.json) {
|
||||||
|
printJson(pulls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
|
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No pull requests found.');
|
console.log('No pull requests found.');
|
||||||
|
|
@ -699,11 +885,16 @@ pr
|
||||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
.requiredOption('-n, --number <number>', 'pull request number', parseId)
|
.requiredOption('-n, --number <number>', 'pull request number', parseId)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const prData = await client.getPullRequest(options.owner, options.repo, options.number);
|
const prData = await client.getPullRequest(options.owner, options.repo, options.number);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(prData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const author = prData.user?.login || '(unknown)';
|
const author = prData.user?.login || '(unknown)';
|
||||||
const headRef = prData.head?.ref || '?';
|
const headRef = prData.head?.ref || '?';
|
||||||
const baseRef = prData.base?.ref || '?';
|
const baseRef = prData.base?.ref || '?';
|
||||||
|
|
@ -760,6 +951,7 @@ pr
|
||||||
.requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment')
|
.requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment')
|
||||||
.option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)')
|
.option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)')
|
||||||
.option('--body-file <path>', 'read the review body from a file (wins over -b)')
|
.option('--body-file <path>', 'read the review body from a file (wins over -b)')
|
||||||
|
.option('--commit <sha>', 'commit SHA the review applies to (sent as commit_id)')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -774,7 +966,7 @@ pr
|
||||||
console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`);
|
console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody);
|
const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody, { commitId: options.commit });
|
||||||
console.log(`Review submitted on !${options.number}: ${event}.`);
|
console.log(`Review submitted on !${options.number}: ${event}.`);
|
||||||
if (result && result.html_url) {
|
if (result && result.html_url) {
|
||||||
console.log(`URL: ${result.html_url}`);
|
console.log(`URL: ${result.html_url}`);
|
||||||
|
|
@ -786,6 +978,270 @@ pr
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const release = program
|
||||||
|
.command('release')
|
||||||
|
.description('Manage releases');
|
||||||
|
|
||||||
|
release
|
||||||
|
.command('list')
|
||||||
|
.description('List releases in a repository')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const releases = await client.listReleases(options.owner, options.repo);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(releases);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const display = options.limit > 0 ? releases.slice(0, options.limit) : releases;
|
||||||
|
if (!display.length) {
|
||||||
|
console.log('No releases found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const rel of display) {
|
||||||
|
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
|
||||||
|
const tag = flags ? `${rel.tag_name} [${flags}]` : rel.tag_name;
|
||||||
|
console.log(`${tag} ${rel.name || ''}`.trimEnd());
|
||||||
|
}
|
||||||
|
if (releases.length > display.length) {
|
||||||
|
console.log(`...and ${releases.length - display.length} more (use -l 0 for all).`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to list releases: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
release
|
||||||
|
.command('view')
|
||||||
|
.description('Show the release for a tag')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('--tag <tag>', 'tag name of the release')
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(rel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
|
||||||
|
console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd());
|
||||||
|
console.log(`URL: ${rel.html_url}`);
|
||||||
|
console.log(`Target: ${rel.target_commitish}`);
|
||||||
|
console.log(`Author: ${rel.author?.login || '(unknown)'}`);
|
||||||
|
console.log(`Published: ${rel.published_at}`);
|
||||||
|
if (rel.body) {
|
||||||
|
console.log('\n' + rel.body);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to show release: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
release
|
||||||
|
.command('create')
|
||||||
|
.description('Create a release (creates the tag too if it does not exist)')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('--tag <tag>', 'tag name for the release')
|
||||||
|
.option('--target <ref>', 'branch or commit the tag is created from (default: repository default branch)')
|
||||||
|
.option('-t, --title <title>', 'release title (default: the tag name)')
|
||||||
|
.option('-b, --body <body>', 'release notes (markdown)')
|
||||||
|
.option('--body-file <path>', 'read the release notes from a file (wins over -b)')
|
||||||
|
.option('--draft', 'create as a draft release', false)
|
||||||
|
.option('--prerelease', 'mark as a prerelease', false)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const payload = {
|
||||||
|
tag_name: options.tag,
|
||||||
|
name: options.title || options.tag,
|
||||||
|
body: readBodyOption(options) || '',
|
||||||
|
draft: options.draft,
|
||||||
|
prerelease: options.prerelease,
|
||||||
|
};
|
||||||
|
if (options.target) payload.target_commitish = options.target;
|
||||||
|
const result = await client.createRelease(options.owner, options.repo, payload);
|
||||||
|
console.log(`Release created: ${result.tag_name} ${result.name || ''}`.trimEnd());
|
||||||
|
console.log(`URL: ${result.html_url}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Release creation failed: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseColor(value) {
|
||||||
|
const hex = value.replace(/^#/, '');
|
||||||
|
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||||
|
throw new InvalidArgumentError('Color must be 6 hex digits (with or without a leading #).');
|
||||||
|
}
|
||||||
|
return hex.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label add/remove/lookup go through names on the CLI but ids on the wire,
|
||||||
|
// so every caller resolves against the repo's label list first.
|
||||||
|
async function resolveLabelIds(client, owner, repo, names) {
|
||||||
|
const labels = await client.listLabels(owner, repo);
|
||||||
|
const byName = new Map(labels.map((l) => [l.name, l.id]));
|
||||||
|
return names.map((name) => {
|
||||||
|
const id = byName.get(name);
|
||||||
|
if (id === undefined) {
|
||||||
|
throw new Error(`Label not found in ${owner}/${repo}: ${name}`);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = program
|
||||||
|
.command('label')
|
||||||
|
.description('Manage repository labels');
|
||||||
|
|
||||||
|
label
|
||||||
|
.command('list')
|
||||||
|
.description('List labels in a repository')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const labels = await client.listLabels(options.owner, options.repo);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(labels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const display = options.limit > 0 ? labels.slice(0, options.limit) : labels;
|
||||||
|
if (!display.length) {
|
||||||
|
console.log('No labels found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const l of display) {
|
||||||
|
console.log(`#${l.id} ${l.name} #${l.color}${l.description ? ` — ${l.description}` : ''}`);
|
||||||
|
}
|
||||||
|
if (labels.length > display.length) {
|
||||||
|
console.log(`...and ${labels.length - display.length} more (use -l 0 for all).`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to list labels: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
label
|
||||||
|
.command('create')
|
||||||
|
.description('Create a label in a repository')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('--name <name>', 'label name')
|
||||||
|
.requiredOption('--color <color>', 'label color, 6 hex digits (with or without #)', parseColor)
|
||||||
|
.option('-d, --description <description>', 'label description', '')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const result = await client.createLabel(options.owner, options.repo, {
|
||||||
|
name: options.name,
|
||||||
|
color: options.color,
|
||||||
|
description: options.description,
|
||||||
|
});
|
||||||
|
console.log(`Label created: #${result.id} ${result.name} #${result.color}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Label creation failed: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
label
|
||||||
|
.command('delete')
|
||||||
|
.description('Delete a label from a repository (by --id or --name)')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.option('--id <id>', 'label id', parseId)
|
||||||
|
.option('--name <name>', 'label name')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
if (!options.id && !options.name) {
|
||||||
|
console.error('One of --id or --name is required.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (options.id && options.name) {
|
||||||
|
console.error('Use either --id or --name, not both.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const ids = options.id
|
||||||
|
? [options.id]
|
||||||
|
: await resolveLabelIds(client, options.owner, options.repo, [options.name]);
|
||||||
|
await client.deleteLabel(options.owner, options.repo, ids[0]);
|
||||||
|
console.log(`Label deleted: ${options.name || `#${options.id}`} from ${options.owner}/${options.repo}.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Label deletion failed: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
label
|
||||||
|
.command('add')
|
||||||
|
.description('Add labels to an issue or pull request')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
|
||||||
|
.requiredOption('--name <name...>', 'one or more label names')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
|
||||||
|
await client.addIssueLabels(options.owner, options.repo, options.number, ids);
|
||||||
|
console.log(`Labels added to #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to add labels: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
label
|
||||||
|
.command('remove')
|
||||||
|
.description('Remove labels from an issue or pull request')
|
||||||
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
|
.requiredOption('-n, --number <number>', 'issue or pull request number', parseId)
|
||||||
|
.requiredOption('--name <name...>', 'one or more label names')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const ids = await resolveLabelIds(client, options.owner, options.repo, options.name);
|
||||||
|
for (const id of ids) {
|
||||||
|
await client.removeIssueLabel(options.owner, options.repo, options.number, id);
|
||||||
|
}
|
||||||
|
console.log(`Labels removed from #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to remove labels: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const branchCmd = program
|
const branchCmd = program
|
||||||
.command('branch')
|
.command('branch')
|
||||||
.description('Manage branches');
|
.description('Manage branches');
|
||||||
|
|
@ -796,11 +1252,16 @@ branchCmd
|
||||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||||
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const branches = await client.listBranches(options.owner, options.repo);
|
const branches = await client.listBranches(options.owner, options.repo);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(branches);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
|
const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No branches found.');
|
console.log('No branches found.');
|
||||||
|
|
@ -883,11 +1344,16 @@ org
|
||||||
.description('List repositories owned by an organization')
|
.description('List repositories owned by an organization')
|
||||||
.requiredOption('-o, --org <org>', 'organization name')
|
.requiredOption('-o, --org <org>', 'organization name')
|
||||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const repos = await client.listOrgRepos(options.org);
|
const repos = await client.listOrgRepos(options.org);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(repos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No repositories found.');
|
console.log('No repositories found.');
|
||||||
|
|
@ -938,11 +1404,16 @@ team
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List teams in an organization')
|
.description('List teams in an organization')
|
||||||
.requiredOption('-o, --org <org>', 'organization name')
|
.requiredOption('-o, --org <org>', 'organization name')
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const teams = await client.listOrgTeams(options.org);
|
const teams = await client.listOrgTeams(options.org);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(teams);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!teams.length) {
|
if (!teams.length) {
|
||||||
console.log('No teams found.');
|
console.log('No teams found.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -991,11 +1462,16 @@ team
|
||||||
.command('member-list')
|
.command('member-list')
|
||||||
.description('List members of a team')
|
.description('List members of a team')
|
||||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const members = await client.listTeamMembers(options.teamId);
|
const members = await client.listTeamMembers(options.teamId);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(members);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!members.length) {
|
if (!members.length) {
|
||||||
console.log('No members found.');
|
console.log('No members found.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -1055,11 +1531,16 @@ user
|
||||||
.description('Search/list users on the Forgejo instance')
|
.description('Search/list users on the Forgejo instance')
|
||||||
.option('-q, --query <query>', 'search query (empty lists all visible users)', '')
|
.option('-q, --query <query>', 'search query (empty lists all visible users)', '')
|
||||||
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
|
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const users = await client.searchUsers(options.query);
|
const users = await client.searchUsers(options.query);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(users);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
|
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
|
||||||
if (!display.length) {
|
if (!display.length) {
|
||||||
console.log('No users found.');
|
console.log('No users found.');
|
||||||
|
|
@ -1082,11 +1563,16 @@ user
|
||||||
.command('show')
|
.command('show')
|
||||||
.description('Show a single user profile')
|
.description('Show a single user profile')
|
||||||
.requiredOption('-u, --user <username>', 'username to look up')
|
.requiredOption('-u, --user <username>', 'username to look up')
|
||||||
|
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const client = ForgejoClient.fromConfig(config);
|
const client = ForgejoClient.fromConfig(config);
|
||||||
const u = await client.getUser(options.user);
|
const u = await client.getUser(options.user);
|
||||||
|
if (options.json) {
|
||||||
|
printJson(u);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(`Login: ${u.login}`);
|
console.log(`Login: ${u.login}`);
|
||||||
console.log(`Full name: ${u.full_name || '-'}`);
|
console.log(`Full name: ${u.full_name || '-'}`);
|
||||||
console.log(`Email: ${u.email || '-'}`);
|
console.log(`Email: ${u.email || '-'}`);
|
||||||
|
|
@ -1098,6 +1584,65 @@ user
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('api')
|
||||||
|
.description('Make an authenticated request to any Forgejo API endpoint and print the JSON response')
|
||||||
|
.argument('<endpoint>', 'endpoint path starting with / (the /api/v1 prefix is added for you)')
|
||||||
|
.option('-X, --method <method>', 'HTTP method (default: GET, or POST when --input is given)')
|
||||||
|
.option('--input <json>', 'JSON request body, inline or @path to read it from a file')
|
||||||
|
.option('--paginate', 'fetch all pages (GET endpoints returning a JSON array)', false)
|
||||||
|
.action(async (endpoint, options) => {
|
||||||
|
try {
|
||||||
|
if (!endpoint.startsWith('/')) {
|
||||||
|
console.error('Endpoint must start with / (e.g. /repos/owner/repo/pulls?state=closed).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const method = (options.method || (options.input ? 'POST' : 'GET')).toUpperCase();
|
||||||
|
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||||
|
console.error(`Unsupported method: ${method}. Use GET, POST, PUT, PATCH or DELETE.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (options.paginate && method !== 'GET') {
|
||||||
|
console.error('--paginate only works with GET.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (method === 'GET' && options.input !== undefined) {
|
||||||
|
console.error('GET requests cannot carry a body. Drop --input, or use -X POST/PUT/PATCH/DELETE.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = null;
|
||||||
|
if (options.input !== undefined) {
|
||||||
|
const raw = options.input.startsWith('@')
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(options.input.slice(1), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Could not read input file ${options.input.slice(1)}: ${err.message}`);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: options.input;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`--input is not valid JSON: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = ForgejoClient.fromConfig(config);
|
||||||
|
const data = options.paginate
|
||||||
|
? await client.getAll(endpoint)
|
||||||
|
: await client.request(method, endpoint, body);
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`API request failed: ${err.message}`);
|
||||||
|
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program.parseAsync(process.argv).catch((err) => {
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,24 @@ test('getPullRequest fetches a single pull request', async () => {
|
||||||
assert.equal(calls[0].opts.method, 'GET');
|
assert.equal(calls[0].opts.method, 'GET');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getIssue fetches a single issue', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Bug' }));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
const issue = await client.getIssue('owner', 'repo', 7);
|
||||||
|
assert.equal(issue.number, 7);
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7');
|
||||||
|
assert.equal(calls[0].opts.method, 'GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createIssueComment posts to the issue comments endpoint', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ id: 5, html_url: 'https://forge.test/comment/5' }));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.createIssueComment('owner', 'repo', 7, 'Me too.');
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments');
|
||||||
|
assert.equal(calls[0].opts.method, 'POST');
|
||||||
|
assert.equal(JSON.parse(calls[0].opts.body).body, 'Me too.');
|
||||||
|
});
|
||||||
|
|
||||||
test('createPullRequestComment posts to the issue comments endpoint', async () => {
|
test('createPullRequestComment posts to the issue comments endpoint', async () => {
|
||||||
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
|
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
|
||||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
|
@ -179,3 +197,74 @@ test('createPullRequestReview preserves leading and trailing whitespace in the b
|
||||||
const body = JSON.parse(calls[0].opts.body);
|
const body = JSON.parse(calls[0].opts.body);
|
||||||
assert.equal(body.body, rawBody);
|
assert.equal(body.body, rawBody);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createPullRequestReview omits commit_id unless a commitId is given', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ id: 90 }));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '');
|
||||||
|
assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body)));
|
||||||
|
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' });
|
||||||
|
assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAll joins pagination with & when the endpoint already has a query', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse([]));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.getAll('/repos/o/r/pulls?state=closed');
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAll overrides caller-supplied limit/page instead of duplicating them', async () => {
|
||||||
|
const calls = mockFetch((url) => {
|
||||||
|
const page = Number(new URL(url).searchParams.get('page'));
|
||||||
|
return jsonResponse(page === 1 ? Array.from({ length: 50 }, (_, i) => ({ id: i })) : []);
|
||||||
|
});
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
const all = await client.getAll('/repos/o/r/pulls?state=closed&limit=1&page=9');
|
||||||
|
assert.equal(all.length, 50);
|
||||||
|
const first = new URL(calls[0].url).searchParams;
|
||||||
|
const second = new URL(calls[1].url).searchParams;
|
||||||
|
assert.deepEqual(first.getAll('limit'), ['50']);
|
||||||
|
assert.deepEqual(first.getAll('page'), ['1']);
|
||||||
|
assert.deepEqual(second.getAll('page'), ['2']);
|
||||||
|
assert.equal(first.get('state'), 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release endpoints map to the expected URLs and payloads', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' }));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.listReleases('owner', 'repo');
|
||||||
|
await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1');
|
||||||
|
await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false });
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1');
|
||||||
|
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1');
|
||||||
|
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases');
|
||||||
|
assert.equal(calls[2].opts.method, 'POST');
|
||||||
|
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label endpoints map to the expected URLs and payloads', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse({ id: 3 }));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.listLabels('owner', 'repo');
|
||||||
|
await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' });
|
||||||
|
await client.deleteLabel('owner', 'repo', 3);
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels?limit=50&page=1');
|
||||||
|
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels');
|
||||||
|
assert.equal(calls[1].opts.method, 'POST');
|
||||||
|
assert.deepEqual(JSON.parse(calls[1].opts.body), { name: 'release', color: '0e8a16', description: '' });
|
||||||
|
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3');
|
||||||
|
assert.equal(calls[2].opts.method, 'DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue label add/remove hit the issue labels endpoints', async () => {
|
||||||
|
const calls = mockFetch(() => jsonResponse(null, 204));
|
||||||
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
await client.addIssueLabels('owner', 'repo', 7, [3, 4]);
|
||||||
|
await client.removeIssueLabel('owner', 'repo', 7, 3);
|
||||||
|
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels');
|
||||||
|
assert.equal(calls[0].opts.method, 'POST');
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] });
|
||||||
|
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3');
|
||||||
|
assert.equal(calls[1].opts.method, 'DELETE');
|
||||||
|
});
|
||||||
|
|
|
||||||
426
test/cli.test.js
426
test/cli.test.js
|
|
@ -23,7 +23,7 @@ test('--version matches package.json', () => {
|
||||||
|
|
||||||
test('--help lists every top-level command', () => {
|
test('--help lists every top-level command', () => {
|
||||||
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
|
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
|
||||||
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'branch', 'collaborator', 'org', 'user']) {
|
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'release', 'label', 'branch', 'collaborator', 'org', 'user', 'api']) {
|
||||||
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
|
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -176,6 +176,132 @@ test('pr review approve allows an empty body before any network call', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('label create rejects an invalid color before any network call', () => {
|
||||||
|
const res = run(['label', 'create', '-o', 'o', '-r', 'r', '--name', 'x', '--color', 'red']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Color must be 6 hex digits/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label delete requires one of --id or --name before any network call', () => {
|
||||||
|
const res = run(['label', 'delete', '-o', 'o', '-r', 'r']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /One of --id or --name is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api rejects an endpoint without a leading slash before any network call', () => {
|
||||||
|
const res = run(['api', 'repos/o/r']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Endpoint must start with \//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api rejects an unsupported method before any network call', () => {
|
||||||
|
const res = run(['api', '/user', '-X', 'HEAD']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Unsupported method/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api rejects --paginate with a non-GET method before any network call', () => {
|
||||||
|
const res = run(['api', '/user', '-X', 'POST', '--paginate']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /--paginate only works with GET/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api rejects GET with --input before any network call', () => {
|
||||||
|
const res = run(['api', '/user', '-X', 'GET', '--input', '{}']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /GET requests cannot carry a body/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label delete rejects --id and --name together before any network call', () => {
|
||||||
|
const res = run(['label', 'delete', '-o', 'o', '-r', 'r', '--id', '3', '--name', 'x']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /either --id or --name, not both/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api rejects invalid JSON input before any network call', () => {
|
||||||
|
const res = run(['api', '/user', '--input', '{nope']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /not valid JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api sends the token and prints the JSON response', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-api.json`);
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
let authHeader = null;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
authHeader = req.headers.authorization;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ login: 'bot' }));
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = await spawnAsync(['api', '/user'], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve({ res, authHeader }));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(result.res.status, 0, result.res.stderr);
|
||||||
|
assert.equal(result.authHeader, 'token tok');
|
||||||
|
assert.deepEqual(JSON.parse(result.res.stdout), { login: 'bot' });
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label add fails closed on an unknown label name', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-lbl.json`);
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = await spawnAsync(
|
||||||
|
['label', 'add', '-o', 'o', '-r', 'r', '-n', '7', '--name', 'ghost'],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve(res));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /Label not found in o\/r: ghost/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function spawnAsync(args, env = {}) {
|
function spawnAsync(args, env = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(process.execPath, [CLI, ...args], {
|
const child = spawn(process.execPath, [CLI, ...args], {
|
||||||
|
|
@ -296,3 +422,301 @@ test('pr review prints the review URL from the API response', async () => {
|
||||||
fs.unlinkSync(cfg);
|
fs.unlinkSync(cfg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Runs `auth login` against a stub Forgejo server and captures the body of
|
||||||
|
// the token-creation request. GETs answer as /user; the POST to
|
||||||
|
// /users/{name}/tokens is what carries the scopes under test.
|
||||||
|
function runLoginWithServer(extraArgs) {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
|
||||||
|
runLoginWithServer.n += 1;
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
let tokenBody = null;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
tokenBody = JSON.parse(data);
|
||||||
|
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
try {
|
||||||
|
const res = await spawnAsync(
|
||||||
|
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve({ res, tokenBody, cfg }));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
runLoginWithServer.n = 0;
|
||||||
|
|
||||||
|
test('auth login creates a token with the reduced default scopes', async () => {
|
||||||
|
const { res, tokenBody, cfg } = await runLoginWithServer([]);
|
||||||
|
try {
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
assert.deepEqual(tokenBody.scopes, [
|
||||||
|
'read:issue', 'write:issue',
|
||||||
|
'read:repository', 'write:repository',
|
||||||
|
'read:user',
|
||||||
|
'read:organization',
|
||||||
|
]);
|
||||||
|
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth login --full-scopes restores the full scope set', async () => {
|
||||||
|
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
|
||||||
|
try {
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
assert.deepEqual(tokenBody.scopes, [
|
||||||
|
'read:activitypub', 'write:activitypub',
|
||||||
|
'read:issue', 'write:issue',
|
||||||
|
'read:misc', 'write:misc',
|
||||||
|
'read:organization', 'write:organization',
|
||||||
|
'read:package', 'write:package',
|
||||||
|
'read:repository', 'write:repository',
|
||||||
|
'read:user', 'write:user',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth login --scopes parses a comma-separated list', async () => {
|
||||||
|
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
|
||||||
|
try {
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
|
||||||
|
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
|
||||||
|
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue show validates --number before any network call', () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Id must be a positive integer/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue comment rejects a missing body before any network call', () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Comment body is required/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue comment rejects a whitespace-only body before any network call', () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Comment body is required/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue comment posts to the issue comments endpoint', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`);
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
let request = null;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
request = { url: req.url, body: data };
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const res = await spawnAsync(
|
||||||
|
['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve({ res, request }));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(result.res.status, 0, result.res.stderr);
|
||||||
|
assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments');
|
||||||
|
assert.equal(JSON.parse(result.request.body).body, 'Confirmed.');
|
||||||
|
assert.match(result.res.stdout, /Comment added to #7\./);
|
||||||
|
assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read commands print the raw API JSON with --json', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`);
|
||||||
|
const payloads = {
|
||||||
|
'/api/v1/user': { login: 'bot' },
|
||||||
|
'/api/v1/user/repos': [{ full_name: 'o/r' }],
|
||||||
|
'/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }],
|
||||||
|
'/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' },
|
||||||
|
'/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }],
|
||||||
|
'/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' },
|
||||||
|
};
|
||||||
|
const commands = [
|
||||||
|
[['auth', 'status', '--json'], { login: 'bot' }],
|
||||||
|
[['repo', 'list', '--json'], [{ full_name: 'o/r' }]],
|
||||||
|
[['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]],
|
||||||
|
[['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }],
|
||||||
|
[['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]],
|
||||||
|
[['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }],
|
||||||
|
];
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
const results = await new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||||
|
const payload = payloads[pathname];
|
||||||
|
if (!payload) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ message: `no mock for ${pathname}` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const out = [];
|
||||||
|
for (const [args] of commands) {
|
||||||
|
out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg }));
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve(out));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
try {
|
||||||
|
results.forEach((res, i) => {
|
||||||
|
const [args, expected] = commands[i];
|
||||||
|
assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`);
|
||||||
|
assert.deepEqual(JSON.parse(res.stdout), expected);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pr review --commit sends commit_id only when given', async () => {
|
||||||
|
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`);
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
let timer;
|
||||||
|
const bodies = [];
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const fail = (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try { server.close(); } catch { /* already closed */ }
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
bodies.push(JSON.parse(data));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id: 42 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||||
|
server.listen(0, '127.0.0.1', async () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||||
|
try {
|
||||||
|
const withCommit = await spawnAsync(
|
||||||
|
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
const withoutCommit = await spawnAsync(
|
||||||
|
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'],
|
||||||
|
{ STOKE_CONFIG_FILE: cfg },
|
||||||
|
);
|
||||||
|
clearTimeout(timer);
|
||||||
|
server.close(() => resolve({ withCommit, withoutCommit }));
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(result.withCommit.status, 0, result.withCommit.stderr);
|
||||||
|
assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr);
|
||||||
|
assert.equal(bodies[0].commit_id, 'abc123');
|
||||||
|
assert.ok(!('commit_id' in bodies[1]));
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(cfg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
109
test/clone.test.js
Normal file
109
test/clone.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawnSync, execFileSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||||
|
const TOKEN = 'stoke-secret-token-for-clone-tests';
|
||||||
|
|
||||||
|
// A local stand-in for the forge: a directory holding bare repositories laid
|
||||||
|
// out as <owner>/<repo>.git, so config.url can point at it with a file:// URL
|
||||||
|
// and `repo clone` exercises real git clones without any network.
|
||||||
|
let root;
|
||||||
|
let remote;
|
||||||
|
let work;
|
||||||
|
let cfg;
|
||||||
|
|
||||||
|
function git(args, cwd) {
|
||||||
|
return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args, cwd) {
|
||||||
|
return spawnSync(process.execPath, [CLI, ...args], {
|
||||||
|
cwd: cwd || work,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, STOKE_CONFIG_FILE: cfg },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-'));
|
||||||
|
remote = path.join(root, 'remote');
|
||||||
|
work = path.join(root, 'work');
|
||||||
|
const seed = path.join(root, 'seed');
|
||||||
|
fs.mkdirSync(path.join(remote, 'o'), { recursive: true });
|
||||||
|
fs.mkdirSync(work);
|
||||||
|
|
||||||
|
git(['init', '-b', 'main', seed]);
|
||||||
|
fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n');
|
||||||
|
git(['-C', seed, 'add', 'README.md']);
|
||||||
|
git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']);
|
||||||
|
git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]);
|
||||||
|
|
||||||
|
cfg = path.join(root, 'config.json');
|
||||||
|
fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone rejects an invalid --depth before running git', () => {
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']);
|
||||||
|
assert.equal(res.status, 1);
|
||||||
|
assert.match(res.stderr, /Depth must be a positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone defaults the destination to the repository name', () => {
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']);
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
const dest = path.join(work, 'r');
|
||||||
|
assert.ok(fs.existsSync(path.join(dest, '.git')));
|
||||||
|
assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone honors an explicit destination directory', () => {
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']);
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone fails with git\'s status when the destination is not empty', () => {
|
||||||
|
const dest = path.join(work, 'occupied');
|
||||||
|
fs.mkdirSync(dest);
|
||||||
|
fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n');
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']);
|
||||||
|
assert.equal(res.status, 128);
|
||||||
|
assert.match(res.stderr, /already exists and is not an empty directory/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone propagates git\'s failure for a missing repository', () => {
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||||
|
assert.equal(res.status, 128);
|
||||||
|
assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone --origin sets the remote name', () => {
|
||||||
|
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']);
|
||||||
|
assert.equal(res.status, 0, res.stderr);
|
||||||
|
const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']);
|
||||||
|
assert.ok(url.trim().endsWith('/o/r.git'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo clone never exposes the token in output or repository config', () => {
|
||||||
|
const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']);
|
||||||
|
assert.equal(ok.status, 0, ok.stderr);
|
||||||
|
const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||||
|
|
||||||
|
for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) {
|
||||||
|
assert.ok(!output.includes(TOKEN), 'token leaked into CLI output');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = path.join(work, 'redacted');
|
||||||
|
const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
|
||||||
|
assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config');
|
||||||
|
const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']);
|
||||||
|
assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL');
|
||||||
|
});
|
||||||
|
|
@ -12,12 +12,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
|
||||||
// candInitial `apt-cache policy` Candidate before any update
|
// candInitial `apt-cache policy` Candidate before any update
|
||||||
// candAfterUpdate Candidate after any `apt-get update`
|
// candAfterUpdate Candidate after any `apt-get update`
|
||||||
// candAfterNodesource Candidate after an update once nodesource.list exists
|
// candAfterNodesource Candidate after an update once nodesource.list exists
|
||||||
|
// releaseStatus HTTP status curl reports for the registry Release file
|
||||||
// The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set,
|
// The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set,
|
||||||
// so every scenario doubles as a regression test for locale-safe parsing.
|
// so every scenario doubles as a regression test for locale-safe parsing.
|
||||||
const cleanups = [];
|
const cleanups = [];
|
||||||
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
|
process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList }) {
|
function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus }) {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-'));
|
||||||
cleanups.push(root);
|
cleanups.push(root);
|
||||||
const bin = path.join(root, 'bin');
|
const bin = path.join(root, 'bin');
|
||||||
|
|
@ -39,7 +40,14 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
// Force the non-root path so every mutation goes through the sudo stub.
|
// Force the non-root path so every mutation goes through the sudo stub.
|
||||||
stub('id', 'echo 1000');
|
stub('id', 'echo 1000');
|
||||||
stub('sudo', 'exec "$@"');
|
stub('sudo', 'exec "$@"');
|
||||||
stub('curl', 'echo "FAKE-KEY"');
|
// Registry Release-file probes (URLs under /dists/) answer with the
|
||||||
|
// scenario's HTTP status; everything else is a key fetch.
|
||||||
|
stub('curl', [
|
||||||
|
'for a in "$@"; do',
|
||||||
|
' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac',
|
||||||
|
'done',
|
||||||
|
'echo "FAKE-KEY"',
|
||||||
|
].join('\n'));
|
||||||
stub('stoke', 'echo 1.2.0');
|
stub('stoke', 'echo 1.2.0');
|
||||||
stub('apt-cache', [
|
stub('apt-cache', [
|
||||||
'cand="$(cat "$STATE_DIR/candidate")"',
|
'cand="$(cat "$STATE_DIR/candidate")"',
|
||||||
|
|
@ -73,6 +81,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
||||||
STATE_DIR: state,
|
STATE_DIR: state,
|
||||||
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
||||||
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
||||||
|
RELEASE_STATUS: releaseStatus || '',
|
||||||
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C
|
LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -147,3 +156,18 @@ test('pre-existing user-managed nodesource.list is never overwritten', () => {
|
||||||
assert.equal(s.nodesourceList, marker);
|
assert.equal(s.nodesourceList, marker);
|
||||||
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
assert.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registry Release file 404s: fails fast with a clear message before apt runs', () => {
|
||||||
|
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '404' });
|
||||||
|
assert.notEqual(s.res.status, 0);
|
||||||
|
assert.match(s.res.stderr, /no stoke package has been published/);
|
||||||
|
assert.match(s.res.stderr, /npm/);
|
||||||
|
assert.match(s.res.stderr, /dists\/stable\/Release returned 404/);
|
||||||
|
assert.equal(s.aptGetLog, '', 'must abort before any apt-get invocation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registry Release file present: proceeds with the install', () => {
|
||||||
|
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '200' });
|
||||||
|
assert.equal(s.res.status, 0, s.res.stderr);
|
||||||
|
assert.match(s.aptGetLog, /install -y stoke/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue