forked from heavy-duty/stoke
Merge pull request 'Audit: fix auth/config bugs, add issue/pr create, tests and docs' (#2) from audit/fixes-and-hardening into main
This commit is contained in:
commit
007b09e3be
9 changed files with 650 additions and 109 deletions
111
README.md
111
README.md
|
|
@ -6,7 +6,7 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman
|
|||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18 (uses the global `fetch` API)
|
||||
- Node.js >= 22.12 (required by `commander@15`; the CLI also uses the global `fetch` API)
|
||||
- npm
|
||||
|
||||
## Installation
|
||||
|
|
@ -27,9 +27,11 @@ node src/cli.js <command>
|
|||
|
||||
Authentication state is stored in a JSON file:
|
||||
|
||||
- Default: `~/.config/stoke/config.json`
|
||||
- Default: `~/.config/stoke/config.json` (or `$XDG_CONFIG_HOME/stoke/config.json` when `XDG_CONFIG_HOME` is set)
|
||||
- Override with `--config <path>` or `STOKE_CONFIG_FILE` (or `FORGEJO_CONFIG_FILE` as a fallback)
|
||||
|
||||
Note: the global `--config` flag must be passed before the subcommand, e.g. `stoke --config /path/to/config.json auth status`.
|
||||
|
||||
The configuration directory is created with permissions `0700` and the file with `0600` so only the owner can read the token.
|
||||
|
||||
Example stored config:
|
||||
|
|
@ -54,8 +56,8 @@ Example stored config:
|
|||
| `STOKE_PASSWORD` | Default password for `auth login` |
|
||||
| `STOKE_CONFIG_FILE` | Path to the config file |
|
||||
| `STOKE_CONFIG_DIR` | Directory for the config file |
|
||||
| `XDG_CONFIG_HOME` | Followed when resolving the default config directory |
|
||||
| `GITHUB_TOKEN` | GitHub token used by `repo import` when `--github-token` is omitted |
|
||||
| `XDG_CONFIG_HOME` | Followed when resolving the default config directory (`$XDG_CONFIG_HOME/stoke`) |
|
||||
| `GITHUB_TOKEN` | GitHub token used by `repo import` and `repo import-batch` when `--github-token` is omitted |
|
||||
|
||||
`FORGEJO_*` variants are still accepted as fallbacks for backward compatibility.
|
||||
|
||||
|
|
@ -123,13 +125,24 @@ Flow:
|
|||
|
||||
Revoke the stored token remotely and delete the local config.
|
||||
|
||||
```bash
|
||||
stoke auth logout
|
||||
```text
|
||||
Options:
|
||||
-p, --password <password> account password, needed to revoke the token remotely
|
||||
--password-file <path> read account password from a file
|
||||
--local-only skip remote revocation and only delete the local config
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke auth logout # prompts for the password on a TTY
|
||||
stoke auth logout --password-file /run/secrets/pw # non-interactive
|
||||
stoke auth logout --local-only # keep the token active, just forget it locally
|
||||
```
|
||||
|
||||
Forgejo only accepts Basic auth on its token endpoints — a token cannot revoke itself — so remote revocation needs the account password. Without one (or with `--local-only`), the token stays active on the server and can be revoked from the web UI under Settings > Applications.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Calls `DELETE /api/v1/users/{login}/tokens/{id}` using the stored token.
|
||||
1. Calls `DELETE /api/v1/users/{login}/tokens/{id}` using Basic auth.
|
||||
2. Removes `~/.config/stoke/config.json`.
|
||||
|
||||
### `stoke auth status`
|
||||
|
|
@ -152,7 +165,8 @@ Options:
|
|||
-d, --description <description> repository description
|
||||
--private make the repository private
|
||||
--public make the repository public
|
||||
--auto-init initialize with a README (default: true)
|
||||
--auto-init initialize with a README (default)
|
||||
--no-auto-init create an empty repository without a README
|
||||
--default-branch <branch> default branch name (default: "main")
|
||||
```
|
||||
|
||||
|
|
@ -205,9 +219,11 @@ Options:
|
|||
--wiki migrate wiki (default)
|
||||
--no-wiki skip wiki
|
||||
--lfs migrate LFS objects
|
||||
--github-token <token> GitHub token (defaults to GITHUB_TOKEN or `gh auth token`)
|
||||
--github-token <token> source service token (for GitHub defaults to GITHUB_TOKEN or `gh auth token`)
|
||||
```
|
||||
|
||||
A source token is only required when `--service github` (the default): it raises rate limits and enables private repositories. For other services (`git`, `gitlab`, `gitea`, ...) no token is sent unless one is explicitly provided.
|
||||
|
||||
Example used to mirror `heavy-duty/box`:
|
||||
|
||||
```bash
|
||||
|
|
@ -259,6 +275,23 @@ stoke repo import-batch -f repos.json --dry-run
|
|||
|
||||
Calls `POST /api/v1/repos/migrate` once per entry.
|
||||
|
||||
### `stoke repo rename`
|
||||
|
||||
Rename a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> current repository name (required)
|
||||
--name <new-name> new repository name (required)
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke repo rename -o heavy-duty -r old-name --name new-name
|
||||
```
|
||||
|
||||
Calls `PATCH /api/v1/repos/{owner}/{repo}`.
|
||||
|
||||
### `stoke repo transfer`
|
||||
|
||||
Transfer a repository to a new owner (a user or an organization). The authenticated user must have admin rights on the repository and permission to create repositories under the new owner (e.g. be an organization owner), in which case the transfer completes immediately.
|
||||
|
|
@ -297,6 +330,26 @@ stoke issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
|||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
|
||||
|
||||
### `stoke issue create`
|
||||
|
||||
Create an issue in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-t, --title <title> issue title (required)
|
||||
-b, --body <body> issue body (markdown)
|
||||
--body-file <path> read the issue body from a file
|
||||
--assignee <username...> assign the issue to one or more users
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
|
||||
|
||||
### `stoke pr list`
|
||||
|
||||
List pull requests in a repository.
|
||||
|
|
@ -315,6 +368,28 @@ stoke pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
|||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
|
||||
|
||||
### `stoke pr create`
|
||||
|
||||
Create a pull request in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-t, --title <title> pull request title (required)
|
||||
--head <branch> source branch (required; for cross-repo PRs use user:branch)
|
||||
--base <branch> target branch (default: main)
|
||||
-b, --body <body> pull request body (markdown)
|
||||
--body-file <path> read the pull request body from a file
|
||||
```
|
||||
|
||||
```bash
|
||||
stoke pr create -o heavy-duty -r stoke -t "Fix config handling" \
|
||||
--head fix/config --base main --body-file pr-body.md
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/{owner}/{repo}/pulls`.
|
||||
|
||||
### `stoke branch list`
|
||||
|
||||
List branches in a repository.
|
||||
|
|
@ -522,16 +597,28 @@ src/
|
|||
├── cli.js # Commander program, commands and user I/O
|
||||
├── api.js # Forgejo API client (fetch wrapper)
|
||||
└── config.js # Secure filesystem-based config storage
|
||||
test/
|
||||
├── cli.test.js # end-to-end CLI behavior (spawned processes)
|
||||
├── api.test.js # API client with a mocked fetch
|
||||
└── config.test.js # config path resolution and persistence
|
||||
```
|
||||
|
||||
- `cli.js` defines commands and options, handles prompts and prints results.
|
||||
- `api.js` encapsulates all HTTP calls to Forgejo. It supports both Basic auth (for token generation) and token auth (for all other calls).
|
||||
- `config.js` reads/writes JSON config and enforces restrictive file permissions.
|
||||
- `api.js` encapsulates all HTTP calls to Forgejo. It supports both Basic auth (for the token endpoints, which reject token auth) and token auth (for all other calls).
|
||||
- `config.js` reads/writes JSON config and enforces restrictive file permissions. Paths are resolved lazily so the global `--config` flag works.
|
||||
|
||||
## Testing
|
||||
|
||||
The test suite uses the Node.js built-in test runner — no extra dependencies:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Tokens are stored on disk with `0600` permissions.
|
||||
- Passwords are never persisted; they are only used to generate a token.
|
||||
- Passwords are never persisted; they are only used to generate (and revoke) a token, and interactive password prompts do not echo to the terminal.
|
||||
- Prefer `--password-file` or `STOKE_PASSWORD` over `-p` to keep passwords out of shell history and avoid `!` history-expansion issues.
|
||||
- The generated token name includes the hostname and a timestamp to avoid collisions.
|
||||
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -1,18 +1,21 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stoke",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"stoke": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,16 +1,28 @@
|
|||
{
|
||||
"name": "stoke",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||
"main": "src/cli.js",
|
||||
"scripts": {
|
||||
"test": "node --test test/**/*.test.js",
|
||||
"test": "node --test",
|
||||
"start": "node src/cli.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"keywords": [
|
||||
"forgejo",
|
||||
"cli",
|
||||
"heavy-duty",
|
||||
"forge"
|
||||
],
|
||||
"author": "Heavy Duty Builders",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://forgejo.heavyduty.builders/heavy-duty/stoke.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
|
|
|
|||
53
src/api.js
53
src/api.js
|
|
@ -1,11 +1,23 @@
|
|||
/**
|
||||
* Forgejo API client.
|
||||
*
|
||||
* Endpoints used:
|
||||
* GET /api/v1/user -> verify credentials / obtain login name
|
||||
* POST /api/v1/users/{login}/tokens -> create a personal access token
|
||||
* DELETE /api/v1/users/{login}/tokens/{id} -> revoke a personal access token
|
||||
* A thin wrapper around the Forgejo REST API (`/api/v1`). It supports two
|
||||
* authentication modes:
|
||||
* - Basic auth (username/password) — required by Forgejo for the personal
|
||||
* access token endpoints (create/delete).
|
||||
* - Token auth — used for every other call.
|
||||
*
|
||||
* Each public method maps to a single endpoint; see the README for the
|
||||
* command-to-endpoint mapping.
|
||||
*/
|
||||
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
// Repository migrations clone the full source repository and can legitimately
|
||||
// take minutes, so they get a much longer budget.
|
||||
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
class ForgejoClient {
|
||||
constructor(baseUrl, token = null) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
|
|
@ -14,13 +26,13 @@ class ForgejoClient {
|
|||
|
||||
static fromConfig(config) {
|
||||
if (!config || !config.url || !config.token) {
|
||||
throw new Error('Not authenticated. Run: forgejo auth login');
|
||||
throw new Error('Not authenticated. Run: stoke auth login');
|
||||
}
|
||||
return new ForgejoClient(config.url, config.token);
|
||||
}
|
||||
|
||||
withBasicAuth(username, password) {
|
||||
const clone = new ForgejoClient(this.baseUrl, this.token);
|
||||
const clone = new ForgejoClient(this.baseUrl);
|
||||
clone.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
return clone;
|
||||
}
|
||||
|
|
@ -29,7 +41,7 @@ class ForgejoClient {
|
|||
const h = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'forgejo-cli/1.0.0',
|
||||
'User-Agent': `stoke/${pkg.version}`,
|
||||
...extra,
|
||||
};
|
||||
if (this.token) {
|
||||
|
|
@ -40,11 +52,12 @@ class ForgejoClient {
|
|||
return h;
|
||||
}
|
||||
|
||||
async request(method, endpoint, body = null) {
|
||||
async request(method, endpoint, body = null, { timeout = REQUEST_TIMEOUT_MS } = {}) {
|
||||
const url = `${this.baseUrl}/api/v1${endpoint}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
};
|
||||
if (body !== null) {
|
||||
opts.body = JSON.stringify(body);
|
||||
|
|
@ -54,6 +67,9 @@ class ForgejoClient {
|
|||
try {
|
||||
res = await fetch(url, opts);
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') {
|
||||
throw new Error(`Request to ${this.baseUrl} timed out after ${timeout / 1000}s`);
|
||||
}
|
||||
throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`);
|
||||
}
|
||||
|
||||
|
|
@ -82,8 +98,8 @@ class ForgejoClient {
|
|||
return this.request('GET', endpoint);
|
||||
}
|
||||
|
||||
post(endpoint, body) {
|
||||
return this.request('POST', endpoint, body);
|
||||
post(endpoint, body, opts) {
|
||||
return this.request('POST', endpoint, body, opts);
|
||||
}
|
||||
|
||||
del(endpoint) {
|
||||
|
|
@ -100,8 +116,11 @@ class ForgejoClient {
|
|||
return client.post(`/users/${encodeURIComponent(username)}/tokens`, { name, scopes });
|
||||
}
|
||||
|
||||
async deleteToken(login, id) {
|
||||
return this.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
||||
// Forgejo requires Basic auth for token management endpoints; a token
|
||||
// cannot be used to revoke itself (the API answers 401).
|
||||
async deleteToken(username, password, login, id) {
|
||||
const client = this.withBasicAuth(username, password);
|
||||
return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
||||
}
|
||||
|
||||
async createRepo(payload) {
|
||||
|
|
@ -109,7 +128,7 @@ class ForgejoClient {
|
|||
}
|
||||
|
||||
async migrateRepo(payload) {
|
||||
return this.post('/repos/migrate', payload);
|
||||
return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async getAll(endpoint, params = {}) {
|
||||
|
|
@ -136,10 +155,18 @@ class ForgejoClient {
|
|||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, params);
|
||||
}
|
||||
|
||||
async createIssue(owner, repo, payload) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload);
|
||||
}
|
||||
|
||||
async listPullRequests(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
|
||||
}
|
||||
|
||||
async createPullRequest(owner, repo, payload) {
|
||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, payload);
|
||||
}
|
||||
|
||||
async listBranches(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
|
||||
}
|
||||
|
|
|
|||
236
src/cli.js
236
src/cli.js
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const { Command } = require('commander');
|
||||
const readline = require('node:readline/promises');
|
||||
const { Command, InvalidArgumentError } = require('commander');
|
||||
const readline = require('node:readline');
|
||||
const fs = require('node:fs');
|
||||
const { execSync } = require('node:child_process');
|
||||
const { stdin: input, stdout: output } = require('node:process');
|
||||
const { loadConfig, saveConfig, clearConfig } = require('./config');
|
||||
const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config');
|
||||
const { ForgejoClient } = require('./api');
|
||||
|
||||
const pkg = require('../package.json');
|
||||
|
|
@ -25,22 +25,28 @@ program
|
|||
}
|
||||
});
|
||||
|
||||
async function prompt(question, silent = false) {
|
||||
const rl = readline.createInterface({ input, output });
|
||||
if (silent) {
|
||||
// Suppress echo for passwords
|
||||
const originalWrite = rl.write.bind(rl);
|
||||
rl.write = () => {};
|
||||
output.write(question);
|
||||
const answer = await rl.question('');
|
||||
rl.write = originalWrite;
|
||||
output.write('\n');
|
||||
rl.close();
|
||||
return answer;
|
||||
}
|
||||
const answer = await rl.question(question);
|
||||
rl.close();
|
||||
return answer;
|
||||
// Uses the callback readline module: it echoes keystrokes through the
|
||||
// overridable _writeToOutput hook, which the readline/promises interface
|
||||
// does not honor, so muting that hook is what actually keeps passwords
|
||||
// off the terminal.
|
||||
function prompt(question, silent = false) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input, output });
|
||||
if (silent) {
|
||||
rl._writeToOutput = () => {};
|
||||
output.write(question);
|
||||
rl.question('', (answer) => {
|
||||
output.write('\n');
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
} else {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readSecretFile(filePath, label) {
|
||||
|
|
@ -51,6 +57,33 @@ function readSecretFile(filePath, label) {
|
|||
}
|
||||
}
|
||||
|
||||
function readBodyOption(options) {
|
||||
if (options.bodyFile) {
|
||||
try {
|
||||
return fs.readFileSync(options.bodyFile, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error(`Could not read body file ${options.bodyFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return options.body;
|
||||
}
|
||||
|
||||
function parseLimit(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new InvalidArgumentError('Limit must be a non-negative integer (0 means all).');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseId(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new InvalidArgumentError('Id must be a positive integer.');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function makeTokenName() {
|
||||
const host = require('node:os').hostname() || 'unknown';
|
||||
return `stoke-${host}-${Date.now()}`;
|
||||
|
|
@ -143,7 +176,7 @@ auth
|
|||
}
|
||||
|
||||
saveConfig(config);
|
||||
console.log(`Credentials stored in ${require('./config').CONFIG_PATH}`);
|
||||
console.log(`Credentials stored in ${getConfigPath()}`);
|
||||
} catch (err) {
|
||||
printErrorAndExit(err);
|
||||
}
|
||||
|
|
@ -152,7 +185,10 @@ auth
|
|||
auth
|
||||
.command('logout')
|
||||
.description('Revoke the stored access token and remove local configuration')
|
||||
.action(async () => {
|
||||
.option('-p, --password <password>', 'account password, needed to revoke the token remotely', process.env.STOKE_PASSWORD || process.env.FORGEJO_PASSWORD)
|
||||
.option('--password-file <path>', 'read account password from a file')
|
||||
.option('--local-only', 'skip remote token revocation and only delete the local config', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
|
|
@ -160,13 +196,25 @@ auth
|
|||
return;
|
||||
}
|
||||
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
if (config.tokenId) {
|
||||
try {
|
||||
await client.deleteToken(config.login, config.tokenId);
|
||||
console.log(`Revoked token ${config.tokenId} on ${config.url}.`);
|
||||
} catch (err) {
|
||||
console.error(`Warning: could not revoke remote token: ${err.message}`);
|
||||
// Forgejo only accepts Basic auth on the token endpoints, so remote
|
||||
// revocation needs the account password (a token cannot revoke itself).
|
||||
if (config.tokenId && !options.localOnly) {
|
||||
let password = options.password;
|
||||
if (options.passwordFile) password = readSecretFile(options.passwordFile, 'password');
|
||||
if (!password && input.isTTY) {
|
||||
password = await prompt(`Password for ${config.login} (empty to skip revocation): `, true);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const client = new ForgejoClient(config.url);
|
||||
try {
|
||||
await client.deleteToken(config.username || config.login, password, config.login, config.tokenId);
|
||||
console.log(`Revoked token ${config.tokenId} on ${config.url}.`);
|
||||
} catch (err) {
|
||||
console.error(`Warning: could not revoke remote token: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,20 +243,27 @@ auth
|
|||
console.log('Login: ', me.login);
|
||||
console.log('Username: ', me.username);
|
||||
console.log('Email: ', me.email);
|
||||
console.log('Token path: ', require('./config').CONFIG_PATH);
|
||||
console.log('Token path: ', getConfigPath());
|
||||
} catch (err) {
|
||||
console.error(`Status check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function resolveSourceToken(tokenOption, command) {
|
||||
// Resolve the auth token to send to the source service of a migration.
|
||||
// GitHub needs one in practice (rate limits, private repos); for any other
|
||||
// service a token is optional and only used when explicitly provided.
|
||||
let cachedGhToken;
|
||||
function resolveSourceToken(tokenOption, service) {
|
||||
if (tokenOption) return tokenOption;
|
||||
if (service !== 'github') return undefined;
|
||||
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
||||
if (cachedGhToken) return cachedGhToken;
|
||||
try {
|
||||
return execSync('gh auth token', { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
cachedGhToken = execSync('gh auth token', { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
return cachedGhToken;
|
||||
} catch {
|
||||
throw new Error(`No source token provided. Set --${command}-token, GITHUB_TOKEN, or ensure 'gh auth token' works.`);
|
||||
throw new Error("No GitHub token found. Set --github-token, GITHUB_TOKEN, or ensure 'gh auth token' works.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,14 +278,13 @@ const repo = program
|
|||
repo
|
||||
.command('list')
|
||||
.description('List repositories for the authenticated user')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return', '50')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listRepos();
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? repos.slice(0, limit) : repos;
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
return;
|
||||
|
|
@ -255,7 +309,8 @@ repo
|
|||
.option('-d, --description <description>', 'repository description', '')
|
||||
.option('--private', 'make the repository private', false)
|
||||
.option('--public', 'make the repository public')
|
||||
.option('--auto-init', 'initialize with a README', true)
|
||||
.option('--auto-init', 'initialize with a README (default)')
|
||||
.option('--no-auto-init', 'create an empty repository without a README')
|
||||
.option('--default-branch <branch>', 'default branch name', 'main')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
|
|
@ -267,7 +322,7 @@ repo
|
|||
name: options.name,
|
||||
description: options.description,
|
||||
private: isPrivate,
|
||||
auto_init: options.autoInit,
|
||||
auto_init: normalizeBool(options.autoInit, true),
|
||||
default_branch: options.defaultBranch,
|
||||
};
|
||||
|
||||
|
|
@ -306,14 +361,14 @@ repo
|
|||
.option('--wiki', 'migrate wiki', true)
|
||||
.option('--no-wiki', 'skip migrating wiki')
|
||||
.option('--lfs', 'migrate LFS objects', false)
|
||||
.option('--github-token <token>', 'GitHub personal access token (defaults to GITHUB_TOKEN or "gh auth token")')
|
||||
.option('--github-token <token>', 'source service token (for GitHub defaults to GITHUB_TOKEN or "gh auth token")')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
|
||||
const isPrivate = options.public ? false : options.private;
|
||||
const token = resolveSourceToken(options.githubToken, 'github');
|
||||
const token = resolveSourceToken(options.githubToken, options.service);
|
||||
|
||||
const payload = {
|
||||
clone_addr: options.from,
|
||||
|
|
@ -390,7 +445,6 @@ repo
|
|||
return;
|
||||
}
|
||||
|
||||
const token = resolveSourceToken(undefined, 'github');
|
||||
const results = [];
|
||||
|
||||
for (const item of manifest) {
|
||||
|
|
@ -401,12 +455,13 @@ repo
|
|||
continue;
|
||||
}
|
||||
|
||||
const service = item.service || 'github';
|
||||
const isPrivate = item.public ? false : Boolean(item.private);
|
||||
const payload = {
|
||||
clone_addr: from,
|
||||
repo_name: name,
|
||||
repo_owner: item.owner || item.repo_owner || config.login,
|
||||
service: item.service || 'github',
|
||||
service,
|
||||
description: item.description || undefined,
|
||||
private: isPrivate,
|
||||
issues: normalizeBool(item.issues, true),
|
||||
|
|
@ -416,7 +471,7 @@ repo
|
|||
releases: normalizeBool(item.releases, true),
|
||||
wiki: normalizeBool(item.wiki, true),
|
||||
lfs: normalizeBool(item.lfs, false),
|
||||
auth_token: item.github_token || token,
|
||||
auth_token: resolveSourceToken(item.github_token, service),
|
||||
};
|
||||
|
||||
Object.keys(payload).forEach((key) => {
|
||||
|
|
@ -473,7 +528,7 @@ issue
|
|||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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', '50')
|
||||
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -482,8 +537,7 @@ issue
|
|||
state: options.state,
|
||||
type: options.type,
|
||||
});
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? issues.slice(0, limit) : issues;
|
||||
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
|
||||
if (!display.length) {
|
||||
console.log('No issues found.');
|
||||
return;
|
||||
|
|
@ -500,6 +554,36 @@ issue
|
|||
}
|
||||
});
|
||||
|
||||
issue
|
||||
.command('create')
|
||||
.description('Create an issue in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-t, --title <title>', 'issue title')
|
||||
.option('-b, --body <body>', 'issue body (markdown)')
|
||||
.option('--body-file <path>', 'read the issue body from a file')
|
||||
.option('--assignee <username...>', 'assign the issue to one or more users')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const payload = {
|
||||
title: options.title,
|
||||
body: readBodyOption(options) || '',
|
||||
};
|
||||
if (options.assignee && options.assignee.length) {
|
||||
payload.assignees = options.assignee;
|
||||
}
|
||||
const result = await client.createIssue(options.owner, options.repo, payload);
|
||||
console.log(`Issue created: #${result.number} ${result.title}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
} catch (err) {
|
||||
console.error(`Issue creation failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const pr = program
|
||||
.command('pr')
|
||||
.description('Manage pull requests');
|
||||
|
|
@ -510,7 +594,7 @@ pr
|
|||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.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', '50')
|
||||
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
|
@ -518,8 +602,7 @@ pr
|
|||
const pulls = await client.listPullRequests(options.owner, options.repo, {
|
||||
state: options.state,
|
||||
});
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? pulls.slice(0, limit) : pulls;
|
||||
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
|
||||
if (!display.length) {
|
||||
console.log('No pull requests found.');
|
||||
return;
|
||||
|
|
@ -536,6 +619,36 @@ pr
|
|||
}
|
||||
});
|
||||
|
||||
pr
|
||||
.command('create')
|
||||
.description('Create a pull request in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-t, --title <title>', 'pull request title')
|
||||
.requiredOption('--head <branch>', 'source branch (for cross-repo PRs use user:branch)')
|
||||
.option('--base <branch>', 'target branch', 'main')
|
||||
.option('-b, --body <body>', 'pull request body (markdown)')
|
||||
.option('--body-file <path>', 'read the pull request body from a file')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const payload = {
|
||||
title: options.title,
|
||||
head: options.head,
|
||||
base: options.base,
|
||||
body: readBodyOption(options) || '',
|
||||
};
|
||||
const result = await client.createPullRequest(options.owner, options.repo, payload);
|
||||
console.log(`Pull request created: !${result.number} ${result.title}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
} catch (err) {
|
||||
console.error(`Pull request creation failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const branchCmd = program
|
||||
.command('branch')
|
||||
.description('Manage branches');
|
||||
|
|
@ -545,14 +658,13 @@ branchCmd
|
|||
.description('List branches in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.option('-l, --limit <number>', 'maximum branches to return', '50')
|
||||
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const branches = await client.listBranches(options.owner, options.repo);
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? branches.slice(0, limit) : branches;
|
||||
const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
|
||||
if (!display.length) {
|
||||
console.log('No branches found.');
|
||||
return;
|
||||
|
|
@ -633,14 +745,13 @@ org
|
|||
.command('repos')
|
||||
.description('List repositories owned by an organization')
|
||||
.requiredOption('-o, --org <org>', 'organization name')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return', '50')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const repos = await client.listOrgRepos(options.org);
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? repos.slice(0, limit) : repos;
|
||||
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
return;
|
||||
|
|
@ -742,12 +853,12 @@ team
|
|||
team
|
||||
.command('member-list')
|
||||
.description('List members of a team')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const members = await client.listTeamMembers(Number(options.teamId));
|
||||
const members = await client.listTeamMembers(options.teamId);
|
||||
if (!members.length) {
|
||||
console.log('No members found.');
|
||||
return;
|
||||
|
|
@ -765,13 +876,13 @@ team
|
|||
team
|
||||
.command('member-add')
|
||||
.description('Add a user to a team')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
||||
.requiredOption('-u, --user <username>', 'username to add')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
await client.addTeamMember(Number(options.teamId), options.user);
|
||||
await client.addTeamMember(options.teamId, options.user);
|
||||
console.log(`Added ${options.user} to team #${options.teamId}.`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to add team member: ${err.message}`);
|
||||
|
|
@ -783,13 +894,13 @@ team
|
|||
team
|
||||
.command('member-remove')
|
||||
.description('Remove a user from a team')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)')
|
||||
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
|
||||
.requiredOption('-u, --user <username>', 'username to remove')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
await client.removeTeamMember(Number(options.teamId), options.user);
|
||||
await client.removeTeamMember(options.teamId, options.user);
|
||||
console.log(`Removed ${options.user} from team #${options.teamId}.`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to remove team member: ${err.message}`);
|
||||
|
|
@ -806,14 +917,13 @@ user
|
|||
.command('list')
|
||||
.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', '50')
|
||||
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const users = await client.searchUsers(options.query);
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? users.slice(0, limit) : users;
|
||||
const display = options.limit > 0 ? users.slice(0, options.limit) : users;
|
||||
if (!display.length) {
|
||||
console.log('No users found.');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,36 +2,50 @@ const path = require('node:path');
|
|||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const CONFIG_DIR = process.env.STOKE_CONFIG_DIR
|
||||
|| process.env.FORGEJO_CONFIG_DIR
|
||||
|| path.join(process.env.XDG_CONFIG_HOME || os.homedir(), '.config', 'stoke');
|
||||
const CONFIG_PATH = process.env.STOKE_CONFIG_FILE
|
||||
|| process.env.FORGEJO_CONFIG_FILE
|
||||
|| path.join(CONFIG_DIR, 'config.json');
|
||||
const CONFIG_MODE = 0o600;
|
||||
const DIR_MODE = 0o700;
|
||||
|
||||
// Paths are resolved lazily (on every call) so that the global `--config`
|
||||
// flag — which sets STOKE_CONFIG_FILE from a preAction hook — takes effect
|
||||
// even though this module is required before the CLI parses its arguments.
|
||||
function getConfigDir() {
|
||||
if (process.env.STOKE_CONFIG_DIR) return process.env.STOKE_CONFIG_DIR;
|
||||
if (process.env.FORGEJO_CONFIG_DIR) return process.env.FORGEJO_CONFIG_DIR;
|
||||
// Per the XDG spec, $XDG_CONFIG_HOME already points at the config root
|
||||
// (it replaces ~/.config, it does not live inside it).
|
||||
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'stoke');
|
||||
return path.join(os.homedir(), '.config', 'stoke');
|
||||
}
|
||||
|
||||
function getConfigPath() {
|
||||
return process.env.STOKE_CONFIG_FILE
|
||||
|| process.env.FORGEJO_CONFIG_FILE
|
||||
|| path.join(getConfigDir(), 'config.json');
|
||||
}
|
||||
|
||||
function ensureConfigDir() {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE });
|
||||
fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true, mode: DIR_MODE });
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = getConfigPath();
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw new Error(`Failed to read config at ${CONFIG_PATH}: ${err.message}`);
|
||||
throw new Error(`Failed to read config at ${configPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
ensureConfigDir();
|
||||
const tmp = `${CONFIG_PATH}.tmp`;
|
||||
const configPath = getConfigPath();
|
||||
const tmp = `${configPath}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: CONFIG_MODE });
|
||||
fs.renameSync(tmp, CONFIG_PATH);
|
||||
fs.renameSync(tmp, configPath);
|
||||
try {
|
||||
fs.chmodSync(CONFIG_PATH, CONFIG_MODE);
|
||||
fs.chmodSync(configPath, CONFIG_MODE);
|
||||
} catch {
|
||||
// ignore on platforms where chmod is unsupported
|
||||
}
|
||||
|
|
@ -39,15 +53,15 @@ function saveConfig(config) {
|
|||
|
||||
function clearConfig() {
|
||||
try {
|
||||
fs.unlinkSync(CONFIG_PATH);
|
||||
fs.unlinkSync(getConfigPath());
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_DIR,
|
||||
CONFIG_PATH,
|
||||
getConfigDir,
|
||||
getConfigPath,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
clearConfig,
|
||||
|
|
|
|||
134
test/api.test.js
Normal file
134
test/api.test.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
const { test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { ForgejoClient } = require('../src/api');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const realFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
function mockFetch(handler) {
|
||||
const calls = [];
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
return handler(url, opts, calls.length);
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
test('fromConfig rejects missing credentials with a stoke-branded hint', () => {
|
||||
assert.throws(() => ForgejoClient.fromConfig(null), /stoke auth login/);
|
||||
assert.throws(() => ForgejoClient.fromConfig({ url: 'https://x' }), /stoke auth login/);
|
||||
});
|
||||
|
||||
test('trailing slash in base URL is normalized', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ ok: true }));
|
||||
const client = new ForgejoClient('https://forge.test/', 'tok');
|
||||
await client.get('/user');
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/user');
|
||||
});
|
||||
|
||||
test('token auth wins and User-Agent matches the package', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({}));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.get('/user');
|
||||
const headers = calls[0].opts.headers;
|
||||
assert.equal(headers.Authorization, 'token tok');
|
||||
assert.equal(headers['User-Agent'], `stoke/${pkg.version}`);
|
||||
});
|
||||
|
||||
test('withBasicAuth sends Basic credentials and drops the token', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({}));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok').withBasicAuth('user', 'pass');
|
||||
await client.get('/user');
|
||||
const expected = `Basic ${Buffer.from('user:pass').toString('base64')}`;
|
||||
assert.equal(calls[0].opts.headers.Authorization, expected);
|
||||
});
|
||||
|
||||
test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints)', async () => {
|
||||
const calls = mockFetch(() => jsonResponse(null, 204));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.deleteToken('user@example.test', 'pass', 'user', 42);
|
||||
const { url, opts } = calls[0];
|
||||
assert.equal(url, 'https://forge.test/api/v1/users/user/tokens/42');
|
||||
assert.equal(opts.method, 'DELETE');
|
||||
assert.match(opts.headers.Authorization, /^Basic /);
|
||||
});
|
||||
|
||||
test('API errors carry message, status and body', async () => {
|
||||
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await assert.rejects(() => client.get('/users/ghost'), (err) => {
|
||||
assert.equal(err.message, 'user does not exist');
|
||||
assert.equal(err.status, 404);
|
||||
assert.equal(err.body.url, 'https://forge.test/api/swagger');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('non-JSON error bodies are surfaced raw', async () => {
|
||||
mockFetch(() => ({ ok: false, status: 502, text: async () => 'Bad Gateway' }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await assert.rejects(() => client.get('/user'), /Bad Gateway/);
|
||||
});
|
||||
|
||||
test('network failures are wrapped with the base URL', async () => {
|
||||
global.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await assert.rejects(() => client.get('/user'), /Network error reaching https:\/\/forge\.test/);
|
||||
});
|
||||
|
||||
test('getAll paginates until a short page', async () => {
|
||||
const pageOf = (n, count) => Array.from({ length: count }, (_, i) => ({ id: (n - 1) * 50 + i }));
|
||||
mockFetch((url) => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
if (page === 1) return jsonResponse(pageOf(1, 50));
|
||||
if (page === 2) return jsonResponse(pageOf(2, 3));
|
||||
throw new Error('should not fetch beyond a short page');
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const all = await client.getAll('/user/repos');
|
||||
assert.equal(all.length, 53);
|
||||
});
|
||||
|
||||
test('getAll stops on an empty first page', async () => {
|
||||
const calls = mockFetch(() => jsonResponse([]));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const all = await client.getAll('/user/repos');
|
||||
assert.deepEqual(all, []);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('searchUsers unwraps the {data: []} envelope and paginates', async () => {
|
||||
mockFetch((url) => {
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
if (page === 1) return jsonResponse({ data: Array.from({ length: 50 }, (_, i) => ({ login: `u${i}` })) });
|
||||
return jsonResponse({ data: [{ login: 'last' }] });
|
||||
});
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
const users = await client.searchUsers('u');
|
||||
assert.equal(users.length, 51);
|
||||
assert.equal(users.at(-1).login, 'last');
|
||||
});
|
||||
|
||||
test('createIssue and createPullRequest hit the expected endpoints', async () => {
|
||||
const calls = mockFetch(() => jsonResponse({ number: 1 }));
|
||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||
await client.createIssue('own/er', 'repo', { title: 't' });
|
||||
await client.createPullRequest('owner', 're po', { title: 't', head: 'h', base: 'b' });
|
||||
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/own%2Fer/repo/issues');
|
||||
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/re%20po/pulls');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
assert.equal(JSON.parse(calls[1].opts.body).head, 'h');
|
||||
});
|
||||
71
test/cli.test.js
Normal file
71
test/cli.test.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync, spawnSync } = 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 pkg = require('../package.json');
|
||||
|
||||
function run(args, env = {}) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test('--version matches package.json', () => {
|
||||
const out = execFileSync(process.execPath, [CLI, '--version'], { encoding: 'utf8' });
|
||||
assert.equal(out.trim(), pkg.version);
|
||||
});
|
||||
|
||||
test('--help lists every top-level command', () => {
|
||||
const out = execFileSync(process.execPath, [CLI, '--help'], { encoding: 'utf8' });
|
||||
for (const cmd of ['auth', 'repo', 'issue', 'pr', 'branch', 'collaborator', 'org', 'user']) {
|
||||
assert.match(out, new RegExp(`^\\s+${cmd}`, 'm'), `missing command: ${cmd}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('unauthenticated commands fail with a login hint', () => {
|
||||
const missing = path.join(os.tmpdir(), `stoke-none-${process.pid}.json`);
|
||||
const res = run(['repo', 'list'], { STOKE_CONFIG_FILE: missing });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /stoke auth login/);
|
||||
});
|
||||
|
||||
test('global --config flag overrides the config location', () => {
|
||||
// Point --config at a nonexistent file: auth status must report
|
||||
// "Not authenticated" instead of silently using the default config.
|
||||
const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`);
|
||||
const res = run(['--config', missing, 'auth', 'status']);
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /Not authenticated/);
|
||||
});
|
||||
|
||||
test('invalid --limit is rejected before any network call', () => {
|
||||
const res = run(['repo', 'list', '-l', 'abc']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Limit must be a non-negative integer/);
|
||||
});
|
||||
|
||||
test('invalid --team-id is rejected before any network call', () => {
|
||||
const res = run(['org', 'team', 'member-add', '--team-id', 'zero', '-u', 'x']);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Id must be a positive integer/);
|
||||
});
|
||||
|
||||
test('issue create --body-file reports unreadable files cleanly', () => {
|
||||
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', 'create', '-o', 'o', '-r', 'r', '-t', 't', '--body-file', '/nonexistent/body.md'],
|
||||
{ STOKE_CONFIG_FILE: cfg },
|
||||
);
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Could not read body file/);
|
||||
} finally {
|
||||
fs.unlinkSync(cfg);
|
||||
}
|
||||
});
|
||||
83
test/config.test.js
Normal file
83
test/config.test.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
const { test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const config = require('../src/config');
|
||||
|
||||
const ENV_KEYS = [
|
||||
'STOKE_CONFIG_DIR', 'STOKE_CONFIG_FILE',
|
||||
'FORGEJO_CONFIG_DIR', 'FORGEJO_CONFIG_FILE',
|
||||
'XDG_CONFIG_HOME',
|
||||
];
|
||||
|
||||
let savedEnv;
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = {};
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('default config path lives under ~/.config/stoke', () => {
|
||||
assert.equal(config.getConfigPath(), path.join(os.homedir(), '.config', 'stoke', 'config.json'));
|
||||
});
|
||||
|
||||
test('XDG_CONFIG_HOME replaces ~/.config entirely', () => {
|
||||
process.env.XDG_CONFIG_HOME = tmpDir;
|
||||
assert.equal(config.getConfigPath(), path.join(tmpDir, 'stoke', 'config.json'));
|
||||
});
|
||||
|
||||
test('STOKE_CONFIG_FILE overrides everything', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/elsewhere';
|
||||
process.env.STOKE_CONFIG_FILE = path.join(tmpDir, 'custom.json');
|
||||
assert.equal(config.getConfigPath(), path.join(tmpDir, 'custom.json'));
|
||||
});
|
||||
|
||||
test('config path is resolved lazily (STOKE_CONFIG_FILE set after require)', () => {
|
||||
const first = path.join(tmpDir, 'a.json');
|
||||
const second = path.join(tmpDir, 'b.json');
|
||||
process.env.STOKE_CONFIG_FILE = first;
|
||||
assert.equal(config.getConfigPath(), first);
|
||||
process.env.STOKE_CONFIG_FILE = second;
|
||||
assert.equal(config.getConfigPath(), second);
|
||||
});
|
||||
|
||||
test('save/load/clear round-trip with restrictive permissions', () => {
|
||||
process.env.STOKE_CONFIG_FILE = path.join(tmpDir, 'nested', 'config.json');
|
||||
|
||||
assert.equal(config.loadConfig(), null);
|
||||
|
||||
const data = { url: 'https://example.test', token: 'abc', tokenId: 1 };
|
||||
config.saveConfig(data);
|
||||
assert.deepEqual(config.loadConfig(), data);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = fs.statSync(config.getConfigPath()).mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
}
|
||||
|
||||
config.clearConfig();
|
||||
assert.equal(config.loadConfig(), null);
|
||||
// clearing twice must not throw
|
||||
config.clearConfig();
|
||||
});
|
||||
|
||||
test('loadConfig surfaces corrupt config files as errors', () => {
|
||||
process.env.STOKE_CONFIG_FILE = path.join(tmpDir, 'corrupt.json');
|
||||
fs.writeFileSync(process.env.STOKE_CONFIG_FILE, 'not-json');
|
||||
assert.throws(() => config.loadConfig(), /Failed to read config/);
|
||||
});
|
||||
Loading…
Reference in a new issue