forked from heavy-duty/stoke
Compare commits
15 commits
design/fig
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0cb85c7b | |||
|
|
0531bde366 | ||
| 87b3cf98d9 | |||
| 907917a870 | |||
| c85be2e083 | |||
|
|
955ce393fc | ||
|
|
8255c568b1 | ||
|
|
b4b38d1d97 | ||
| 1165ee22c3 | |||
| f5a44021da | |||
| 3e93b20ae6 | |||
| f4b0bdbe4e | |||
| 036364f844 | |||
| 355fcc1f67 | |||
| 1b990d523a |
18 changed files with 1591 additions and 29 deletions
25
.forgejo/workflows/ci.yml
Normal file
25
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# CI: run the test suite on every pull request and on pushes to main, so
|
||||
# regressions are caught before they reach a release tag.
|
||||
#
|
||||
# Requirements:
|
||||
# - A Forgejo Actions runner on the instance. Adjust `runs-on` to a label
|
||||
# your runner actually advertises (common: docker, ubuntu-latest).
|
||||
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
steps:
|
||||
- name: Check out commit
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
run: npm ci && npm test
|
||||
251
README.md
251
README.md
|
|
@ -104,6 +104,8 @@ Example stored config:
|
|||
|
||||
## Commands
|
||||
|
||||
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
|
||||
|
||||
```text
|
||||
|
|
@ -125,6 +127,8 @@ Options:
|
|||
-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-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:
|
||||
|
|
@ -159,9 +163,31 @@ Flow:
|
|||
|
||||
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.
|
||||
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.
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
|
||||
### `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`
|
||||
|
||||
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`.
|
||||
|
||||
### `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`
|
||||
|
||||
List pull requests in a repository.
|
||||
|
|
@ -500,14 +586,148 @@ Options:
|
|||
--event <event> approve|approved, request-changes|request_changes, comment (required)
|
||||
-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)
|
||||
--commit <sha> commit SHA the review applies to (sent as commit_id)
|
||||
```
|
||||
|
||||
```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 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`
|
||||
|
||||
|
|
@ -709,6 +929,33 @@ stoke user show -u andres
|
|||
|
||||
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
|
||||
|
||||
```text
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
|
|
@ -19,7 +19,7 @@ The file has four pages:
|
|||
| `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
|
||||
|
||||
|
|
@ -76,17 +76,39 @@ wordmark. Vector source in [`assets/logo-mark.svg`](../assets/logo-mark.svg).
|
|||
All imagery generated with **Artlist** (Seedream 5.0 Pro for stills, Kling 1.6 for the
|
||||
ambient loop) and embedded in the Figma file.
|
||||
|
||||
| File | What |
|
||||
| Asset | Where it lives |
|
||||
| --- | --- |
|
||||
| `assets/hero-forge.png` | 2048×878 · 21:9 hero backdrop — glowing coals, hot steel, sparks |
|
||||
| `assets/operator-hand.png` | 3:2 — a hand stoking coals (philosophy section) |
|
||||
| `assets/ember-texture.png` | 21:9 — molten ember bed (CTA / section backdrops) |
|
||||
| `assets/ember-loop.mp4` | 5s ambient ember loop (bonus motion asset) |
|
||||
| `assets/logo-mark.svg` | Ember spark logo mark (vector) |
|
||||
| `assets/preview-landing.png` | Full desktop landing render |
|
||||
| `assets/preview-brand.png` | Brand system page render |
|
||||
| `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 + `assets/`.
|
||||
- Nothing here changes the CLI. It adds a `docs/` design record, one vector
|
||||
asset, and a packaging fix.
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||
"main": "src/cli.js",
|
||||
"scripts": {
|
||||
|
|
@ -28,5 +28,12 @@
|
|||
},
|
||||
"bin": {
|
||||
"stoke": "src/cli.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"docs/DESIGN.md",
|
||||
"assets/logo-mark.svg"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,19 @@ echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTI
|
|||
# read these.
|
||||
$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
|
||||
# currently produces for its Debian registry (malformed Ed25519 MPI encoding
|
||||
# in the upstream signing library). Try the properly signed source first so
|
||||
|
|
|
|||
67
src/api.js
67
src/api.js
|
|
@ -134,9 +134,20 @@ class ForgejoClient {
|
|||
async getAll(endpoint, params = {}) {
|
||||
const pageSize = 50;
|
||||
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) {
|
||||
const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString();
|
||||
const items = await this.get(`${endpoint}?${query}`);
|
||||
const query = new URLSearchParams(baseQuery);
|
||||
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;
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
|
|
@ -159,6 +170,14 @@ class ForgejoClient {
|
|||
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 = {}) {
|
||||
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 });
|
||||
}
|
||||
|
||||
async createPullRequestReview(owner, repo, index, event, body) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, {
|
||||
async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) {
|
||||
const payload = {
|
||||
event,
|
||||
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) {
|
||||
|
|
@ -190,6 +211,42 @@ class ForgejoClient {
|
|||
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) {
|
||||
return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, {
|
||||
permission,
|
||||
|
|
|
|||
553
src/cli.js
553
src/cli.js
|
|
@ -3,7 +3,7 @@
|
|||
const { Command, InvalidArgumentError } = require('commander');
|
||||
const readline = require('node:readline');
|
||||
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 { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
||||
const { ForgejoClient } = require('./api');
|
||||
|
|
@ -84,12 +84,37 @@ function parseId(value) {
|
|||
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() {
|
||||
const host = require('node:os').hostname() || 'unknown';
|
||||
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 = [
|
||||
'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:issue', 'write:issue',
|
||||
'read:misc', 'write:misc',
|
||||
|
|
@ -99,6 +124,10 @@ const DEFAULT_TOKEN_SCOPES = [
|
|||
'read:user', 'write:user',
|
||||
];
|
||||
|
||||
function parseScopesOption(csv) {
|
||||
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function printErrorAndExit(err) {
|
||||
console.error(`Authentication failed: ${err.message}`);
|
||||
if (err.status) {
|
||||
|
|
@ -124,10 +153,27 @@ auth
|
|||
.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-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) => {
|
||||
try {
|
||||
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 (passwordFile) password = readSecretFile(passwordFile, 'password');
|
||||
|
||||
|
|
@ -159,7 +205,7 @@ auth
|
|||
const me = await client.verifyBasicAuth(username, password);
|
||||
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) {
|
||||
throw new Error('Token generation succeeded but no token value was returned.');
|
||||
}
|
||||
|
|
@ -173,6 +219,7 @@ auth
|
|||
tokenId: tokenRes.id,
|
||||
};
|
||||
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
|
||||
console.log(`Scopes: ${scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
|
|
@ -229,7 +276,8 @@ auth
|
|||
auth
|
||||
.command('status')
|
||||
.description('Show the current authentication status')
|
||||
.action(async () => {
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
|
|
@ -239,6 +287,10 @@ auth
|
|||
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const me = await client.get('/user');
|
||||
if (options.json) {
|
||||
printJson(me);
|
||||
return;
|
||||
}
|
||||
console.log('Instance: ', config.url);
|
||||
console.log('Login: ', me.login);
|
||||
console.log('Username: ', me.username);
|
||||
|
|
@ -279,11 +331,16 @@ repo
|
|||
.command('list')
|
||||
.description('List repositories for the authenticated user')
|
||||
.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) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listRepos();
|
||||
if (options.json) {
|
||||
printJson(repos);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
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
|
||||
.command('create')
|
||||
.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('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
|
||||
.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) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -537,6 +656,10 @@ issue
|
|||
state: options.state,
|
||||
type: options.type,
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(issues);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
|
||||
if (!display.length) {
|
||||
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
|
||||
.command('pr')
|
||||
.description('Manage pull requests');
|
||||
|
|
@ -595,6 +776,7 @@ pr
|
|||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -602,6 +784,10 @@ pr
|
|||
const pulls = await client.listPullRequests(options.owner, options.repo, {
|
||||
state: options.state,
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(pulls);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
|
||||
if (!display.length) {
|
||||
console.log('No pull requests found.');
|
||||
|
|
@ -699,11 +885,16 @@ pr
|
|||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <number>', 'pull request 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 prData = await client.getPullRequest(options.owner, options.repo, options.number);
|
||||
if (options.json) {
|
||||
printJson(prData);
|
||||
return;
|
||||
}
|
||||
const author = prData.user?.login || '(unknown)';
|
||||
const headRef = prData.head?.ref || '?';
|
||||
const baseRef = prData.base?.ref || '?';
|
||||
|
|
@ -760,6 +951,7 @@ pr
|
|||
.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('--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) => {
|
||||
try {
|
||||
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.`);
|
||||
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}.`);
|
||||
if (result && 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
|
||||
.command('branch')
|
||||
.description('Manage branches');
|
||||
|
|
@ -796,11 +1252,16 @@ branchCmd
|
|||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
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;
|
||||
if (!display.length) {
|
||||
console.log('No branches found.');
|
||||
|
|
@ -883,11 +1344,16 @@ org
|
|||
.description('List repositories owned by an organization')
|
||||
.requiredOption('-o, --org <org>', 'organization name')
|
||||
.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) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listOrgRepos(options.org);
|
||||
if (options.json) {
|
||||
printJson(repos);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
|
|
@ -938,11 +1404,16 @@ team
|
|||
.command('list')
|
||||
.description('List teams in an organization')
|
||||
.requiredOption('-o, --org <org>', 'organization name')
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const teams = await client.listOrgTeams(options.org);
|
||||
if (options.json) {
|
||||
printJson(teams);
|
||||
return;
|
||||
}
|
||||
if (!teams.length) {
|
||||
console.log('No teams found.');
|
||||
return;
|
||||
|
|
@ -991,11 +1462,16 @@ team
|
|||
.command('member-list')
|
||||
.description('List members of a team')
|
||||
.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) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const members = await client.listTeamMembers(options.teamId);
|
||||
if (options.json) {
|
||||
printJson(members);
|
||||
return;
|
||||
}
|
||||
if (!members.length) {
|
||||
console.log('No members found.');
|
||||
return;
|
||||
|
|
@ -1055,11 +1531,16 @@ user
|
|||
.description('Search/list users on the Forgejo instance')
|
||||
.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('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const users = await client.searchUsers(options.query);
|
||||
if (options.json) {
|
||||
printJson(users);
|
||||
return;
|
||||
}
|
||||
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
|
||||
if (!display.length) {
|
||||
console.log('No users found.');
|
||||
|
|
@ -1082,11 +1563,16 @@ user
|
|||
.command('show')
|
||||
.description('Show a single user profile')
|
||||
.requiredOption('-u, --user <username>', 'username to look up')
|
||||
.option('--json', 'print raw JSON instead of human-readable output', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const u = await client.getUser(options.user);
|
||||
if (options.json) {
|
||||
printJson(u);
|
||||
return;
|
||||
}
|
||||
console.log(`Login: ${u.login}`);
|
||||
console.log(`Full name: ${u.full_name || '-'}`);
|
||||
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) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -151,6 +151,24 @@ test('getPullRequest fetches a single pull request', async () => {
|
|||
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 () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
|
||||
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);
|
||||
assert.equal(body.body, rawBody);
|
||||
});
|
||||
|
||||
test('createPullRequestReview omits commit_id unless a commitId is given', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 90 }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '');
|
||||
assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body)));
|
||||
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' });
|
||||
assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123');
|
||||
});
|
||||
|
||||
test('getAll joins pagination with & when the endpoint already has a query', async () => {
|
||||
const calls = mockFetch(() => jsonResponse([]));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.getAll('/repos/o/r/pulls?state=closed');
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1');
|
||||
});
|
||||
|
||||
test('getAll overrides caller-supplied limit/page instead of duplicating them', async () => {
|
||||
const calls = mockFetch((url) => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
return jsonResponse(page === 1 ? Array.from({ length: 50 }, (_, i) => ({ id: i })) : []);
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const all = await client.getAll('/repos/o/r/pulls?state=closed&limit=1&page=9');
|
||||
assert.equal(all.length, 50);
|
||||
const first = new URL(calls[0].url).searchParams;
|
||||
const second = new URL(calls[1].url).searchParams;
|
||||
assert.deepEqual(first.getAll('limit'), ['50']);
|
||||
assert.deepEqual(first.getAll('page'), ['1']);
|
||||
assert.deepEqual(second.getAll('page'), ['2']);
|
||||
assert.equal(first.get('state'), 'closed');
|
||||
});
|
||||
|
||||
test('release endpoints map to the expected URLs and payloads', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.listReleases('owner', 'repo');
|
||||
await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1');
|
||||
await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false });
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1');
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1');
|
||||
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases');
|
||||
assert.equal(calls[2].opts.method, 'POST');
|
||||
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
|
||||
});
|
||||
|
||||
test('label endpoints map to the expected URLs and payloads', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ id: 3 }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.listLabels('owner', 'repo');
|
||||
await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' });
|
||||
await client.deleteLabel('owner', 'repo', 3);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels?limit=50&page=1');
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels');
|
||||
assert.equal(calls[1].opts.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[1].opts.body), { name: 'release', color: '0e8a16', description: '' });
|
||||
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3');
|
||||
assert.equal(calls[2].opts.method, 'DELETE');
|
||||
});
|
||||
|
||||
test('issue label add/remove hit the issue labels endpoints', async () => {
|
||||
const calls = mockFetch(() => jsonResponse(null, 204));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.addIssueLabels('owner', 'repo', 7, [3, 4]);
|
||||
await client.removeIssueLabel('owner', 'repo', 7, 3);
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] });
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3');
|
||||
assert.equal(calls[1].opts.method, 'DELETE');
|
||||
});
|
||||
|
|
|
|||
426
test/cli.test.js
426
test/cli.test.js
|
|
@ -23,7 +23,7 @@ test('--version matches package.json', () => {
|
|||
|
||||
test('--help lists every top-level command', () => {
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -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 = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Runs `auth login` against a stub Forgejo server and captures the body of
|
||||
// the token-creation request. GETs answer as /user; the POST to
|
||||
// /users/{name}/tokens is what carries the scopes under test.
|
||||
function runLoginWithServer(extraArgs) {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
|
||||
runLoginWithServer.n += 1;
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
let tokenBody = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
tokenBody = JSON.parse(data);
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ res, tokenBody, cfg }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
}
|
||||
runLoginWithServer.n = 0;
|
||||
|
||||
test('auth login creates a token with the reduced default scopes', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer([]);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, [
|
||||
'read:issue', 'write:issue',
|
||||
'read:repository', 'write:repository',
|
||||
'read:user',
|
||||
'read:organization',
|
||||
]);
|
||||
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login --full-scopes restores the full scope set', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, [
|
||||
'read:activitypub', 'write:activitypub',
|
||||
'read:issue', 'write:issue',
|
||||
'read:misc', 'write:misc',
|
||||
'read:organization', 'write:organization',
|
||||
'read:package', 'write:package',
|
||||
'read:repository', 'write:repository',
|
||||
'read:user', 'write:user',
|
||||
]);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login --scopes parses a comma-separated list', async () => {
|
||||
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
|
||||
try {
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
|
||||
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
|
||||
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
|
||||
});
|
||||
|
||||
test('issue show validates --number before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Id must be a positive integer/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment rejects a missing body before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Comment body is required/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment rejects a whitespace-only body before any network call', () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`);
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' }));
|
||||
try {
|
||||
const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Comment body is required/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue comment posts to the issue comments endpoint', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
let request = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
request = { url: req.url, body: data };
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' }));
|
||||
});
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const res = await spawnAsync(
|
||||
['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ res, request }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.res.status, 0, result.res.stderr);
|
||||
assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments');
|
||||
assert.equal(JSON.parse(result.request.body).body, 'Confirmed.');
|
||||
assert.match(result.res.stdout, /Comment added to #7\./);
|
||||
assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('read commands print the raw API JSON with --json', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`);
|
||||
const payloads = {
|
||||
'/api/v1/user': { login: 'bot' },
|
||||
'/api/v1/user/repos': [{ full_name: 'o/r' }],
|
||||
'/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }],
|
||||
'/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' },
|
||||
'/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }],
|
||||
'/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' },
|
||||
};
|
||||
const commands = [
|
||||
[['auth', 'status', '--json'], { login: 'bot' }],
|
||||
[['repo', 'list', '--json'], [{ full_name: 'o/r' }]],
|
||||
[['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]],
|
||||
[['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }],
|
||||
[['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]],
|
||||
[['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }],
|
||||
];
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const results = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||
const payload = payloads[pathname];
|
||||
if (!payload) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: `no mock for ${pathname}` }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const out = [];
|
||||
for (const [args] of commands) {
|
||||
out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg }));
|
||||
}
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve(out));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
results.forEach((res, i) => {
|
||||
const [args, expected] = commands[i];
|
||||
assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`);
|
||||
assert.deepEqual(JSON.parse(res.stdout), expected);
|
||||
});
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
||||
test('pr review --commit sends commit_id only when given', async () => {
|
||||
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`);
|
||||
const TIMEOUT_MS = 5000;
|
||||
let timer;
|
||||
const bodies = [];
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const fail = (err) => {
|
||||
clearTimeout(timer);
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; });
|
||||
req.on('end', () => {
|
||||
bodies.push(JSON.parse(data));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 42 }));
|
||||
});
|
||||
});
|
||||
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const { port } = server.address();
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' }));
|
||||
try {
|
||||
const withCommit = await spawnAsync(
|
||||
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
const withoutCommit = await spawnAsync(
|
||||
['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
clearTimeout(timer);
|
||||
server.close(() => resolve({ withCommit, withoutCommit }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
try {
|
||||
assert.equal(result.withCommit.status, 0, result.withCommit.stderr);
|
||||
assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr);
|
||||
assert.equal(bodies[0].commit_id, 'abc123');
|
||||
assert.ok(!('commit_id' in bodies[1]));
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
109
test/clone.test.js
Normal file
109
test/clone.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync, execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||
const TOKEN = 'stoke-secret-token-for-clone-tests';
|
||||
|
||||
// A local stand-in for the forge: a directory holding bare repositories laid
|
||||
// out as <owner>/<repo>.git, so config.url can point at it with a file:// URL
|
||||
// and `repo clone` exercises real git clones without any network.
|
||||
let root;
|
||||
let remote;
|
||||
let work;
|
||||
let cfg;
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], {
|
||||
cwd: cwd || work,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, STOKE_CONFIG_FILE: cfg },
|
||||
});
|
||||
}
|
||||
|
||||
before(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-'));
|
||||
remote = path.join(root, 'remote');
|
||||
work = path.join(root, 'work');
|
||||
const seed = path.join(root, 'seed');
|
||||
fs.mkdirSync(path.join(remote, 'o'), { recursive: true });
|
||||
fs.mkdirSync(work);
|
||||
|
||||
git(['init', '-b', 'main', seed]);
|
||||
fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n');
|
||||
git(['-C', seed, 'add', 'README.md']);
|
||||
git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']);
|
||||
git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]);
|
||||
|
||||
cfg = path.join(root, 'config.json');
|
||||
fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('repo clone rejects an invalid --depth before running git', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Depth must be a positive integer/);
|
||||
});
|
||||
|
||||
test('repo clone defaults the destination to the repository name', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const dest = path.join(work, 'r');
|
||||
assert.ok(fs.existsSync(path.join(dest, '.git')));
|
||||
assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n');
|
||||
});
|
||||
|
||||
test('repo clone honors an explicit destination directory', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git')));
|
||||
});
|
||||
|
||||
test('repo clone fails with git\'s status when the destination is not empty', () => {
|
||||
const dest = path.join(work, 'occupied');
|
||||
fs.mkdirSync(dest);
|
||||
fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n');
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']);
|
||||
assert.equal(res.status, 128);
|
||||
assert.match(res.stderr, /already exists and is not an empty directory/);
|
||||
});
|
||||
|
||||
test('repo clone propagates git\'s failure for a missing repository', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||
assert.equal(res.status, 128);
|
||||
assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i);
|
||||
});
|
||||
|
||||
test('repo clone --origin sets the remote name', () => {
|
||||
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']);
|
||||
assert.ok(url.trim().endsWith('/o/r.git'));
|
||||
});
|
||||
|
||||
test('repo clone never exposes the token in output or repository config', () => {
|
||||
const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']);
|
||||
assert.equal(ok.status, 0, ok.stderr);
|
||||
const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
|
||||
|
||||
for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) {
|
||||
assert.ok(!output.includes(TOKEN), 'token leaked into CLI output');
|
||||
}
|
||||
|
||||
const dest = path.join(work, 'redacted');
|
||||
const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
|
||||
assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config');
|
||||
const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']);
|
||||
assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL');
|
||||
});
|
||||
|
|
@ -12,12 +12,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh');
|
|||
// candInitial `apt-cache policy` Candidate before any update
|
||||
// candAfterUpdate Candidate after any `apt-get update`
|
||||
// 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,
|
||||
// so every scenario doubles as a regression test for locale-safe parsing.
|
||||
const cleanups = [];
|
||||
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-'));
|
||||
cleanups.push(root);
|
||||
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.
|
||||
stub('id', 'echo 1000');
|
||||
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('apt-cache', [
|
||||
'cand="$(cat "$STATE_DIR/candidate")"',
|
||||
|
|
@ -73,6 +81,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi
|
|||
STATE_DIR: state,
|
||||
CAND_AFTER_UPDATE: candAfterUpdate || '',
|
||||
CAND_AFTER_NODESOURCE: candAfterNodesource || '',
|
||||
RELEASE_STATUS: releaseStatus || '',
|
||||
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.doesNotMatch(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
||||
test('registry Release file 404s: fails fast with a clear message before apt runs', () => {
|
||||
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '404' });
|
||||
assert.notEqual(s.res.status, 0);
|
||||
assert.match(s.res.stderr, /no stoke package has been published/);
|
||||
assert.match(s.res.stderr, /npm/);
|
||||
assert.match(s.res.stderr, /dists\/stable\/Release returned 404/);
|
||||
assert.equal(s.aptGetLog, '', 'must abort before any apt-get invocation');
|
||||
});
|
||||
|
||||
test('registry Release file present: proceeds with the install', () => {
|
||||
const s = runScenario({ candInitial: '22.23.1-1nodesource1', releaseStatus: '200' });
|
||||
assert.equal(s.res.status, 0, s.res.stderr);
|
||||
assert.match(s.aptGetLog, /install -y stoke/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue