Compare commits
11 commits
eabfc30a66
...
0531bde366
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0531bde366 | ||
| 87b3cf98d9 | |||
| 907917a870 | |||
| c85be2e083 | |||
|
|
955ce393fc | ||
|
|
8255c568b1 | ||
|
|
b4b38d1d97 | ||
| 1165ee22c3 | |||
| f5a44021da | |||
| 3e93b20ae6 | |||
| 1b990d523a |
10 changed files with 573 additions and 6 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
|
||||
49
README.md
49
README.md
|
|
@ -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
28
assets/logo-mark.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Mark">
|
||||
<g clip-path="url(#clip0_0_4)">
|
||||
<rect width="120" height="120" rx="28" fill="#0C0A09"/>
|
||||
<g id="Ellipse" filter="url(#filter0_f_0_4)">
|
||||
<circle cx="60" cy="85" r="75" fill="#FF6A2C" fill-opacity="0.35"/>
|
||||
</g>
|
||||
<path id="spark" d="M60 22L68.061 51.939L98 60L68.061 68.061L60 98L51.939 68.061L22 60L51.939 51.939L60 22Z" fill="url(#paint0_linear_0_4)"/>
|
||||
<path id="Star" d="M89 20L91.489 28.511L100 31L91.489 33.489L89 42L86.511 33.489L78 31L86.511 28.511L89 20Z" fill="#FFB347"/>
|
||||
</g>
|
||||
<rect x="0.5" y="0.5" width="119" height="119" rx="27.5" stroke="#3A2A1E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_0_4" x="-55" y="-30" width="230" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="20" result="effect1_foregroundBlur_0_4"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_0_4" x1="60" y1="22" x2="60" y2="98" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB347"/>
|
||||
<stop offset="0.5" stop-color="#FF6A2C"/>
|
||||
<stop offset="1" stop-color="#E2452B"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_0_4">
|
||||
<rect width="120" height="120" rx="28" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
114
docs/DESIGN.md
Normal file
114
docs/DESIGN.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# stoke — Brand & Landing Design
|
||||
|
||||
> _The operator's hand on the forge._
|
||||
|
||||
A complete brand identity and marketing landing page for **stoke**, the CLI for the
|
||||
Heavy Duty Forgejo forge. Designed from the codebase itself — every command group,
|
||||
install path, and principle on the page maps to something the tool actually does.
|
||||
|
||||
## 🔗 Figma
|
||||
|
||||
**[stoke — Brand & Landing (Figma)](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing)**
|
||||
|
||||
The file has four pages:
|
||||
|
||||
| Page | Contents |
|
||||
| --- | --- |
|
||||
| `01 · Brand System` | Logo lockup + ember spark mark, 14-token color palette, type scale, voice & principles |
|
||||
| `02 · Landing — Desktop` | Full 1440px landing: nav, hero, ecosystem strip, command showcase, philosophy split, install, CTA, footer |
|
||||
| `03 · Landing — Mobile` | 390px responsive landing |
|
||||
| `04 · Assets & Components` | Command-group icon set (24px line) + generated-asset gallery |
|
||||
|
||||
> Renders live in the [Figma file](https://www.figma.com/design/q6bYh7pRChTbg0diKAVrcS/stoke-Brand-Landing) — see "Why the raster assets are not committed here" below.
|
||||
|
||||
## Concept
|
||||
|
||||
`stoke` is named after the act of feeding and tending a fire. The identity takes that
|
||||
literally: a working **forge** — controlled heat, hot steel, precision — rendered as
|
||||
hot ember on near-black anthracite. Industrial craft meets modern dev tool. The product
|
||||
principle from the README ("every real operation performed against Forgejo becomes a new
|
||||
CLI command") is the spine of the page.
|
||||
|
||||
### Color — ember on anthracite
|
||||
|
||||
| Token | Hex | Use |
|
||||
| --- | --- | --- |
|
||||
| `bg/base` | `#0C0A09` | Anthracite canvas |
|
||||
| `bg/raised` | `#1A1614` | Raised surfaces |
|
||||
| `bg/inset` | `#14100E` | Wells / code blocks |
|
||||
| `border/subtle` | `#2A2320` | Hairlines |
|
||||
| `border/ember` | `#3A2A1E` | Warm edges |
|
||||
| `ember/red` | `#E2452B` | Deep heat |
|
||||
| `ember/orange` | `#FF6A2C` | **Primary** · CTAs |
|
||||
| `ember/amber` | `#FFB347` | Bright heat · command text |
|
||||
| `hot/white` | `#FFF3E6` | White-hot highlight |
|
||||
| `steel/blue` | `#3B6EA5` | Quench accent · links / URLs |
|
||||
| `text/primary` | `#F5EDE6` | Primary text |
|
||||
| `text/secondary` | `#A89A8E` | Secondary text |
|
||||
| `text/muted` | `#6B5F56` | Meta / muted |
|
||||
| `text/onEmber` | `#1A0E06` | Text on ember fills |
|
||||
|
||||
### Type
|
||||
|
||||
- **Archivo** (Black / SemiBold) — geometric-industrial display & headings, tight tracking
|
||||
- **Inter** (Regular / Medium) — body copy
|
||||
- **JetBrains Mono** — everything command-flavored (it's a CLI): prompts, flags, install snippets
|
||||
|
||||
### Logo
|
||||
|
||||
An **ember spark** mark — a four-point spark that reads as both a flame and a striker
|
||||
feeding the forge — set over a warm glow, locked up with a heavy lowercase `stoke`
|
||||
wordmark. Vector source in [`assets/logo-mark.svg`](../assets/logo-mark.svg).
|
||||
|
||||
## Page structure (desktop)
|
||||
|
||||
1. **Nav** — logo, section links, `Get started`
|
||||
2. **Hero** — forge backdrop, `Every operation becomes a command.`, copy-able `apt-get install stoke`, CTAs
|
||||
3. **Ecosystem strip** — the six repos the forge manages: `box · rig · cast · infra · handbook · incubator`
|
||||
4. **Command showcase** — a live, syntax-colored `stoke` terminal session + cards for all 7 command groups (auth, repo, pr, issue, branch, org, user) with real example commands and command counts
|
||||
5. **Philosophy split** — "Built for the hand on the forge" with the operator image + feature checklist (apt-native, 0600 tokens, batch import, dogfooded)
|
||||
6. **Install** — apt (recommended) and from-source code cards, with the Node ≥ 22.12 note
|
||||
7. **CTA band** — `Feed the forge.` over ember texture
|
||||
8. **Footer** — logo, link columns, forge URL, version
|
||||
|
||||
## Assets
|
||||
|
||||
All imagery generated with **Artlist** (Seedream 5.0 Pro for stills, Kling 1.6 for the
|
||||
ambient loop) and embedded in the Figma file.
|
||||
|
||||
| Asset | Where it lives |
|
||||
| --- | --- |
|
||||
| `assets/logo-mark.svg` | **In this repo** — ember spark logo mark, vector, 1.4 kB |
|
||||
| Hero backdrop · 2048×878 21:9 — glowing coals, hot steel, sparks | Figma, page 04 |
|
||||
| Operator's hand stoking coals · 3:2 (philosophy section) | Figma, page 04 |
|
||||
| Molten ember bed · 21:9 (CTA / section backdrops) | Figma, page 04 |
|
||||
| 5s ambient ember loop (motion) | Figma, page 04 |
|
||||
| Desktop landing render · brand system render | Figma, pages 02 / 01 |
|
||||
|
||||
### Why the raster assets are not committed here
|
||||
|
||||
The first version of this change committed all of them — **23 MB of PNG and MP4
|
||||
into a CLI repo.** That was a mistake, and it was mine. Two concrete costs:
|
||||
|
||||
- `stoke`'s `.git` is ~23 MB; the assets would have **doubled it**, permanently.
|
||||
Git history is forever, so a marketing render committed today is still being
|
||||
cloned by every contributor in five years.
|
||||
- `package.json` had no `files` whitelist, so `npm pack` shipped the working
|
||||
directory. Measured: **the tarball went from ~100 kB to 23.7 MB** — a 200×
|
||||
bloat delivered to every user of a command-line tool, for images none of them
|
||||
will ever look at.
|
||||
|
||||
Binaries that exist to be *looked at* belong where people look at them: the Figma
|
||||
file, which is linked at the top and is the source of truth anyway. The vector
|
||||
logo stays because it is 1.4 kB of text, diffs cleanly, and is the one asset the
|
||||
project itself might need to render.
|
||||
|
||||
This change also adds the missing `files` whitelist to `package.json`, so the
|
||||
published tarball now contains only `src/` and the docs a user needs — a
|
||||
pre-existing packaging bug that shipping the test suite had been hiding.
|
||||
|
||||
## Notes
|
||||
|
||||
- This PR was opened with `stoke pr create` — the tool designing its own storefront.
|
||||
- Nothing here changes the CLI. It adds a `docs/` design record, one vector
|
||||
asset, and a packaging fix.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
106
src/cli.js
106
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,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')
|
||||
|
|
|
|||
|
|
@ -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
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