Compare commits

...

15 commits

Author SHA1 Message Date
ee0cb85c7b Merge pull request 'Add issue show/comment, --json output, and pr review --commit' (#20) from feat/issue-cmds-json-review-commit into main 2026-07-26 22:01:19 +00:00
kimi-reviewer-andresmgsl
0531bde366 Add issue show/comment, --json output, and pr review --commit 2026-07-26 22:00:47 +00:00
87b3cf98d9 Merge pull request 'auth login: default to least-privilege token scopes' (#19) from fix/auth-login-scopes into main 2026-07-26 22:00:12 +00:00
907917a870 Merge pull request 'install-apt: fail fast when the registry has no Release file' (#18) from fix/install-apt-fail-fast into main 2026-07-26 22:00:09 +00:00
c85be2e083 Merge pull request 'Add CI workflow for PRs and main pushes' (#17) from ci/pr-main-tests into main 2026-07-26 22:00:00 +00:00
kimi-reviewer-andresmgsl
955ce393fc auth login: default to least-privilege token scopes (#9)
Tokens minted by stoke auth login previously got read/write on every
non-admin scope. Default to the reduced set the common issue/PR/repo
commands need (read/write issue + repository, read user + organization),
add --full-scopes to restore the old behavior and --scopes <csv> for a
custom list, and print the granted scopes after login.
2026-07-26 21:43:06 +00:00
kimi-reviewer-andresmgsl
8255c568b1 install-apt: fail fast with a clear message when the registry has no Release file 2026-07-26 21:41:16 +00:00
kimi-reviewer-andresmgsl
b4b38d1d97 Add CI workflow for PRs and main pushes 2026-07-26 21:39:57 +00:00
1165ee22c3 Merge pull request 'design: stoke brand system (replaces #12, without the 23MB of binaries)' (#16) from design/brand-system-v2 into main
Reviewed-on: heavy-duty/stoke#16
2026-07-26 21:28:22 +00:00
f5a44021da design: stoke brand system, without the 23MB of binaries
Replaces #12, which committed every generated render into the repo. Rebuilt on
a clean branch because merging the original would have written those blobs into
main's history permanently, even with a later commit deleting them.

What changed from #12:

- Keeps docs/DESIGN.md and assets/logo-mark.svg (1.4 kB of vector text).
- Drops ~23 MB of PNG/MP4. They live in the Figma file, which was already the
  source of truth and is linked from the doc. stoke's .git is ~23 MB; those
  assets would have doubled it, forever.
- Adds the missing "files" whitelist to package.json. There wasn't one, so
  npm pack shipped the whole working directory: measured 23.7 MB with the
  assets, and it was already shipping the test suite without them. Now 26.7 kB
  across 7 files.

The packaging bug is pre-existing and independent of the design work; the
oversized PR is just what made it visible.

61/61 tests pass; `stoke --version` → 1.3.0.
2026-07-26 21:26:30 +00:00
3e93b20ae6 Add stoke repo clone with ephemeral token handling (#14)
Closes #13.

Independently verified end-to-end: token absent from .git, clean remote URL,
no extraHeader persisted. 53/53 tests pass.
2026-07-26 21:24:42 +00:00
f4b0bdbe4e Merge pull request 'Add release, label, and api commands (v1.3.0)' (#15) from feat/releases-labels-api into main
Reviewed-on: heavy-duty/stoke#15
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-07-26 21:02:23 +00:00
036364f844 Address review: pager owns limit/page, reject GET+input, label delete exclusivity 2026-07-26 20:52:29 +00:00
355fcc1f67 Add release, label, and api commands (v1.3.0) 2026-07-26 20:31:07 +00:00
1b990d523a Add stoke repo clone with ephemeral token handling (#13)
Clone repositories from the configured Forgejo instance using the stored
session. The token is passed to git through GIT_CONFIG_* environment-based
config (http.<url>.extraHeader) with GIT_TERMINAL_PROMPT=0, so it never
appears in the remote URL, on the command line, in logs, or in the cloned
repository's .git/config. Git streams its own output and its exit status is
forwarded to the caller.

Supports an optional destination directory plus --branch, --depth and
--origin. Adds tests covering destination handling, exit-status
propagation, remote naming, depth validation and credential redaction.
2026-07-23 22:44:21 +00:00
13 changed files with 1701 additions and 19 deletions

25
.forgejo/workflows/ci.yml Normal file
View 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
View file

@ -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
View 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
View 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
View file

@ -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"

View file

@ -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"
]
} }

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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');
});

View file

@ -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
View 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');
});

View file

@ -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/);
});