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
10 changed files with 573 additions and 6 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

@ -127,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:
@ -161,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.
@ -198,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.

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

@ -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,6 +84,14 @@ 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) {
@ -95,7 +103,18 @@ function makeTokenName() {
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',
@ -105,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) {
@ -130,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');
@ -165,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.');
}
@ -179,6 +219,7 @@ auth
tokenId: tokenRes.id,
};
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
console.log(`Scopes: ${scopes.join(', ')}`);
}
saveConfig(config);
@ -318,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')

View file

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

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