Compare commits

...

11 commits

Author SHA1 Message Date
kimi-reviewer-andresmgsl
0531bde366 Add issue show/comment, --json output, and pr review --commit
Some checks failed
ci / test (pull_request) Has been cancelled
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
Some checks are pending
ci / test (push) Waiting to run
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
Some checks are pending
ci / test (push) Waiting to run
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
Some checks are pending
ci / test (push) Waiting to run
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
Some checks failed
ci / test (pull_request) Has been cancelled
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: #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
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
12 changed files with 993 additions and 13 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

View file

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

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.

View file

@ -28,5 +28,12 @@
},
"bin": {
"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.
$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

View file

@ -170,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);
}
@ -186,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) {

View file

@ -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}`);
@ -796,11 +988,16 @@ release
.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.');
@ -826,11 +1023,16 @@ release
.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}`);
@ -913,11 +1115,16 @@ label
.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.');
@ -1045,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.');
@ -1132,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.');
@ -1187,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;
@ -1240,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;
@ -1304,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.');
@ -1331,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 || '-'}`);

View file

@ -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');
@ -180,6 +198,15 @@ test('createPullRequestReview preserves leading and trailing whitespace in the b
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');

View file

@ -421,4 +421,302 @@ test('pr review prints the review URL from the API response', async () => {
} finally {
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
// 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/);
});