forked from heavy-duty/stoke
feat: forgejo-cli with auth, repo, issue, pr and branch commands
This commit is contained in:
commit
c981a1258d
8 changed files with 1206 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
349
README.md
Normal file
349
README.md
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
# forgejo-cli
|
||||
|
||||
A command-line interface for [Forgejo](https://forgejo.org/), built with [Commander.js](https://github.com/tj/commander.js/). It targets the instance at `https://forgejo.heavyduty.builders` and is designed so that every real operation performed against Forgejo becomes a new CLI command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18 (uses the global `fetch` API)
|
||||
- npm
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd forgejo-cli
|
||||
npm install
|
||||
npm link # makes the `forgejo` binary available globally
|
||||
```
|
||||
|
||||
Or run it directly without linking:
|
||||
|
||||
```bash
|
||||
node src/cli.js <command>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Authentication state is stored in a JSON file:
|
||||
|
||||
- Default: `~/.config/forgejo-cli/config.json`
|
||||
- Override with `--config <path>` or `FORGEJO_CONFIG_FILE`
|
||||
|
||||
The configuration directory is created with permissions `0700` and the file with `0600` so only the owner can read the token.
|
||||
|
||||
Example stored config:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://forgejo.heavyduty.builders",
|
||||
"login": "kimi-reviewer-andresmgsl",
|
||||
"username": "kimi-reviewer-andresmgsl",
|
||||
"email": "andres+4@heavyduty.builders",
|
||||
"token": "<sha1>",
|
||||
"tokenId": 42
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `FORGEJO_URL` | Default Forgejo base URL |
|
||||
| `FORGEJO_USERNAME` | Default username/email for `auth login` |
|
||||
| `FORGEJO_PASSWORD` | Default password for `auth login` |
|
||||
| `FORGEJO_CONFIG_FILE` | Path to the config file |
|
||||
| `FORGEJO_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 |
|
||||
|
||||
## Commands
|
||||
|
||||
### Global options
|
||||
|
||||
```text
|
||||
-c, --config <path> path to configuration file
|
||||
-h, --help display help
|
||||
-V, --version display version
|
||||
```
|
||||
|
||||
### `forgejo auth login`
|
||||
|
||||
Authenticate and persist an access token.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-u, --url <url> Forgejo base URL (default: https://forgejo.heavyduty.builders)
|
||||
-n, --username <username> account username or email
|
||||
-p, --password <password> account password
|
||||
--password-file <path> read account password from a file
|
||||
-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
|
||||
```
|
||||
|
||||
Interactive example:
|
||||
|
||||
```bash
|
||||
forgejo auth login
|
||||
# prompts for username and password
|
||||
```
|
||||
|
||||
Non-interactive example using environment variables:
|
||||
|
||||
```bash
|
||||
export FORGEJO_USERNAME='kimi-reviewer-andresmgsl'
|
||||
export FORGEJO_PASSWORD='...'
|
||||
forgejo auth login
|
||||
```
|
||||
|
||||
Password file example (avoids shell history and special-character issues):
|
||||
|
||||
```bash
|
||||
chmod 600 /run/secrets/forgejo-password
|
||||
forgejo auth login -n kimi-reviewer-andresmgsl --password-file /run/secrets/forgejo-password
|
||||
```
|
||||
|
||||
Existing token example:
|
||||
|
||||
```bash
|
||||
forgejo auth login -t <personal-access-token>
|
||||
```
|
||||
|
||||
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`.
|
||||
4. Writes the token, token id, user details and URL to the config file.
|
||||
|
||||
### `forgejo auth logout`
|
||||
|
||||
Revoke the stored token remotely and delete the local config.
|
||||
|
||||
```bash
|
||||
forgejo auth logout
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
1. Calls `DELETE /api/v1/users/{login}/tokens/{id}` using the stored token.
|
||||
2. Removes `~/.config/forgejo-cli/config.json`.
|
||||
|
||||
### `forgejo auth status`
|
||||
|
||||
Display the currently authenticated user.
|
||||
|
||||
```bash
|
||||
forgejo auth status
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/user` with the stored token.
|
||||
|
||||
### `forgejo repo create`
|
||||
|
||||
Create a new repository for the authenticated user.
|
||||
|
||||
```text
|
||||
Options:
|
||||
--name <name> repository name (required)
|
||||
-d, --description <description> repository description
|
||||
--private make the repository private
|
||||
--public make the repository public
|
||||
--auto-init initialize with a README (default: true)
|
||||
--default-branch <branch> default branch name (default: "main")
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
forgejo repo create --name forgejo-cli-test --private \
|
||||
-d "Test repository created via forgejo-cli"
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/user/repos`.
|
||||
|
||||
### `forgejo repo list`
|
||||
|
||||
List repositories for the authenticated user.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-l, --limit <number> maximum repositories to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
forgejo repo list
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/user/repos` and auto-paginates.
|
||||
|
||||
### `forgejo repo import`
|
||||
|
||||
Import a remote repository into Forgejo, including git history, issues, pull requests, labels, milestones, releases and wiki.
|
||||
|
||||
```text
|
||||
Options:
|
||||
--from <clone-addr> source clone URL (required)
|
||||
--name <name> repository name in Forgejo (required)
|
||||
--service <service> source service type: git, github, gitea, gitlab, ... (default: github)
|
||||
--owner <owner> Forgejo owner for the imported repo (default: current user)
|
||||
-d, --description <description> repository description
|
||||
--private / --public visibility
|
||||
--issues migrate issues (default)
|
||||
--no-issues skip issues
|
||||
--labels migrate labels (default)
|
||||
--no-labels skip labels
|
||||
--milestones migrate milestones (default)
|
||||
--no-milestones skip milestones
|
||||
--pull-requests migrate pull requests (default)
|
||||
--no-pull-requests skip pull requests
|
||||
--releases migrate releases (default)
|
||||
--no-releases skip releases
|
||||
--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`)
|
||||
```
|
||||
|
||||
Example used to mirror `heavy-duty/box`:
|
||||
|
||||
```bash
|
||||
forgejo repo import \
|
||||
--from https://github.com/heavy-duty/box.git \
|
||||
--name box \
|
||||
--service github \
|
||||
--public \
|
||||
--issues --labels --milestones --pull-requests --releases --wiki
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/migrate`.
|
||||
|
||||
### `forgejo repo import-batch`
|
||||
|
||||
Import multiple repositories from a JSON manifest.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-f, --file <path> path to JSON manifest (required)
|
||||
--dry-run print the manifest without importing
|
||||
```
|
||||
|
||||
Manifest format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "rig",
|
||||
"from": "https://github.com/heavy-duty/rig.git",
|
||||
"service": "github",
|
||||
"private": false,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
forgejo repo import-batch -f repos.json
|
||||
forgejo repo import-batch -f repos.json --dry-run
|
||||
```
|
||||
|
||||
Calls `POST /api/v1/repos/migrate` once per entry.
|
||||
|
||||
### `forgejo issue list`
|
||||
|
||||
List issues in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-s, --state <state> open, closed, all (default: open)
|
||||
-t, --type <type> issues or pulls (default: issues)
|
||||
-l, --limit <number> maximum issues to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
forgejo issue list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/issues` and auto-paginates.
|
||||
|
||||
### `forgejo pr list`
|
||||
|
||||
List pull requests in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-s, --state <state> open, closed, all (default: open)
|
||||
-l, --limit <number> maximum PRs to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
forgejo pr list -o kimi-reviewer-andresmgsl -r box -s all -l 0
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/pulls` and auto-paginates.
|
||||
|
||||
### `forgejo branch list`
|
||||
|
||||
List branches in a repository.
|
||||
|
||||
```text
|
||||
Options:
|
||||
-o, --owner <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
-l, --limit <number> maximum branches to display (default: 50; use 0 for all)
|
||||
```
|
||||
|
||||
```bash
|
||||
forgejo branch list -o kimi-reviewer-andresmgsl -r box
|
||||
```
|
||||
|
||||
Calls `GET /api/v1/repos/{owner}/{repo}/branches` and auto-paginates.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
src/
|
||||
├── cli.js # Commander program, commands and user I/O
|
||||
├── api.js # Forgejo API client (fetch wrapper)
|
||||
└── config.js # Secure filesystem-based config storage
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Tokens are stored on disk with `0600` permissions.
|
||||
- Passwords are never persisted; they are only used to generate a token.
|
||||
- Prefer `--password-file` or `FORGEJO_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.
|
||||
|
||||
## Verification: heavy-duty repository imports
|
||||
|
||||
The heavy-duty repositories were imported into Forgejo under `https://forgejo.heavyduty.builders/kimi-reviewer-andresmgsl`.
|
||||
|
||||
| Repository | Visibility | Branches | Commits | Open issues | Total issues | PRs | Labels | Milestones | Releases |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `box` | public | 1 | 306 | 11 | 65 | 92 | 21 | 0 | 4 |
|
||||
| `rig` | public | 1 | 198 | 10 | 49 | 59 | 21 | 0 | 3 |
|
||||
| `cast` | public | 1 | 194 | 7 | 67 | 75 | 21 | 0 | 3 |
|
||||
| `infra` | private | 1 | 86 | 2 | 8 | 22 | 9 | 0 | 0 |
|
||||
| `handbook` | private | 1 | 29 | 28 | 28 | 13 | 9 | 5 | 0 |
|
||||
| `incubator` | private | 1 | 1,326 | 1 | 1 | 23 | 9 | 0 | 0 |
|
||||
|
||||
Counts match GitHub for all repositories. Git history, issues, pull requests, labels, milestones and releases are included. Discussions are not enabled on any of the source repositories. The `cast` wiki is enabled on GitHub but contains no pages, so nothing was migrated.
|
||||
|
||||
## Next steps
|
||||
|
||||
The CLI is authenticated and the first full repository import is complete. Every subsequent Forgejo task you need will be added as a new command under `forgejo`.
|
||||
74
manifests/heavy-duty-repos.json
Normal file
74
manifests/heavy-duty-repos.json
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
[
|
||||
{
|
||||
"name": "box",
|
||||
"from": "https://github.com/heavy-duty/box.git",
|
||||
"service": "github",
|
||||
"private": false,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
},
|
||||
{
|
||||
"name": "rig",
|
||||
"from": "https://github.com/heavy-duty/rig.git",
|
||||
"service": "github",
|
||||
"private": false,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
},
|
||||
{
|
||||
"name": "cast",
|
||||
"from": "https://github.com/heavy-duty/cast.git",
|
||||
"service": "github",
|
||||
"private": false,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
},
|
||||
{
|
||||
"name": "infra",
|
||||
"from": "https://github.com/heavy-duty/infra.git",
|
||||
"service": "github",
|
||||
"private": true,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
},
|
||||
{
|
||||
"name": "handbook",
|
||||
"from": "https://github.com/heavy-duty/handbook.git",
|
||||
"service": "github",
|
||||
"private": true,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
},
|
||||
{
|
||||
"name": "incubator",
|
||||
"from": "https://github.com/heavy-duty/incubator.git",
|
||||
"service": "github",
|
||||
"private": true,
|
||||
"issues": true,
|
||||
"labels": true,
|
||||
"milestones": true,
|
||||
"pull_requests": true,
|
||||
"releases": true,
|
||||
"wiki": true
|
||||
}
|
||||
]
|
||||
25
package-lock.json
generated
Normal file
25
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "forgejo-cli",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgejo-cli",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "forgejo-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test test/**/*.test.js",
|
||||
"start": "node src/cli.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"forgejo": "src/cli.js"
|
||||
}
|
||||
}
|
||||
148
src/api.js
Normal file
148
src/api.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
class ForgejoClient {
|
||||
constructor(baseUrl, token = null) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static fromConfig(config) {
|
||||
if (!config || !config.url || !config.token) {
|
||||
throw new Error('Not authenticated. Run: forgejo auth login');
|
||||
}
|
||||
return new ForgejoClient(config.url, config.token);
|
||||
}
|
||||
|
||||
withBasicAuth(username, password) {
|
||||
const clone = new ForgejoClient(this.baseUrl, this.token);
|
||||
clone.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
return clone;
|
||||
}
|
||||
|
||||
headers(extra = {}) {
|
||||
const h = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'forgejo-cli/1.0.0',
|
||||
...extra,
|
||||
};
|
||||
if (this.token) {
|
||||
h.Authorization = `token ${this.token}`;
|
||||
} else if (this.basicAuth) {
|
||||
h.Authorization = `Basic ${this.basicAuth}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async request(method, endpoint, body = null) {
|
||||
const url = `${this.baseUrl}/api/v1${endpoint}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
};
|
||||
if (body !== null) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, opts);
|
||||
} catch (err) {
|
||||
throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data?.message || data?.raw || `HTTP ${res.status}`;
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
get(endpoint) {
|
||||
return this.request('GET', endpoint);
|
||||
}
|
||||
|
||||
post(endpoint, body) {
|
||||
return this.request('POST', endpoint, body);
|
||||
}
|
||||
|
||||
del(endpoint) {
|
||||
return this.request('DELETE', endpoint);
|
||||
}
|
||||
|
||||
async verifyBasicAuth(username, password) {
|
||||
const client = this.withBasicAuth(username, password);
|
||||
return client.get('/user');
|
||||
}
|
||||
|
||||
async createToken(username, password, name, scopes) {
|
||||
const client = this.withBasicAuth(username, password);
|
||||
return client.post(`/users/${encodeURIComponent(username)}/tokens`, { name, scopes });
|
||||
}
|
||||
|
||||
async deleteToken(login, id) {
|
||||
return this.del(`/users/${encodeURIComponent(login)}/tokens/${id}`);
|
||||
}
|
||||
|
||||
async createRepo(payload) {
|
||||
return this.post('/user/repos', payload);
|
||||
}
|
||||
|
||||
async migrateRepo(payload) {
|
||||
return this.post('/repos/migrate', payload);
|
||||
}
|
||||
|
||||
async getAll(endpoint, params = {}) {
|
||||
const pageSize = 50;
|
||||
const all = [];
|
||||
for (let page = 1; page <= 1000; page += 1) {
|
||||
const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString();
|
||||
const items = await this.get(`${endpoint}?${query}`);
|
||||
if (!Array.isArray(items) || items.length === 0) break;
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async listRepos(opts = {}) {
|
||||
return this.getAll('/user/repos', opts);
|
||||
}
|
||||
|
||||
async listIssues(owner, repo, opts = {}) {
|
||||
const params = {};
|
||||
if (opts.state) params.state = opts.state;
|
||||
if (opts.type) params.type = opts.type;
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, params);
|
||||
}
|
||||
|
||||
async listPullRequests(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts);
|
||||
}
|
||||
|
||||
async listBranches(owner, repo, opts = {}) {
|
||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ForgejoClient };
|
||||
535
src/cli.js
Executable file
535
src/cli.js
Executable file
|
|
@ -0,0 +1,535 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const { Command } = require('commander');
|
||||
const readline = require('node:readline/promises');
|
||||
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 { ForgejoClient } = require('./api');
|
||||
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.name('forgejo')
|
||||
.description('CLI for Forgejo (https://forgejo.heavyduty.builders)')
|
||||
.version(pkg.version)
|
||||
.configureOutput({ outputError: (str, write) => write(`Error: ${str}`) });
|
||||
|
||||
program
|
||||
.option('-c, --config <path>', 'path to configuration file')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
if (thisCommand.opts().config) {
|
||||
process.env.FORGEJO_CONFIG_FILE = thisCommand.opts().config;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function readSecretFile(filePath, label) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8').replace(/\r?\n$/, '');
|
||||
} catch (err) {
|
||||
throw new Error(`Could not read ${label} file ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeTokenName() {
|
||||
const host = require('node:os').hostname() || 'unknown';
|
||||
return `forgejo-cli-${host}-${Date.now()}`;
|
||||
}
|
||||
|
||||
const DEFAULT_TOKEN_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',
|
||||
];
|
||||
|
||||
function printErrorAndExit(err) {
|
||||
console.error(`Authentication failed: ${err.message}`);
|
||||
if (err.status) {
|
||||
console.error(`HTTP status: ${err.status}`);
|
||||
}
|
||||
if (err.body && err.body.url) {
|
||||
console.error(`URL: ${err.body.url}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const auth = program
|
||||
.command('auth')
|
||||
.description('Manage Forgejo authentication');
|
||||
|
||||
auth
|
||||
.command('login')
|
||||
.description('Authenticate against a Forgejo instance and store an access token')
|
||||
.option('-u, --url <url>', 'Forgejo base URL', process.env.FORGEJO_URL || 'https://forgejo.heavyduty.builders')
|
||||
.option('-n, --username <username>', 'account username or email', process.env.FORGEJO_USERNAME)
|
||||
.option('-p, --password <password>', 'account password', process.env.FORGEJO_PASSWORD)
|
||||
.option('--password-file <path>', 'read account password from a file')
|
||||
.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())
|
||||
.action(async (options) => {
|
||||
try {
|
||||
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
|
||||
|
||||
if (tokenFile) token = readSecretFile(tokenFile, 'token');
|
||||
if (passwordFile) password = readSecretFile(passwordFile, 'password');
|
||||
|
||||
if (!username && !token) {
|
||||
username = await prompt('Username or email: ');
|
||||
}
|
||||
if (!password && !token) {
|
||||
password = await prompt('Password: ', true);
|
||||
}
|
||||
|
||||
const client = new ForgejoClient(url);
|
||||
let config = { url };
|
||||
|
||||
if (token) {
|
||||
// Validate the supplied token and resolve the login name.
|
||||
const tokenClient = new ForgejoClient(url, token);
|
||||
const me = await tokenClient.get('/user');
|
||||
config = {
|
||||
url,
|
||||
login: me.login,
|
||||
username: me.username || me.login,
|
||||
email: me.email,
|
||||
token,
|
||||
tokenId: null,
|
||||
};
|
||||
console.log(`Authenticated as ${me.login} using provided token.`);
|
||||
} else {
|
||||
// Verify username/password and get the canonical login name.
|
||||
const me = await client.verifyBasicAuth(username, password);
|
||||
const login = me.login;
|
||||
|
||||
const tokenRes = await client.createToken(login, password, tokenName, DEFAULT_TOKEN_SCOPES);
|
||||
if (!tokenRes.sha1) {
|
||||
throw new Error('Token generation succeeded but no token value was returned.');
|
||||
}
|
||||
|
||||
config = {
|
||||
url,
|
||||
login,
|
||||
username: me.username || login,
|
||||
email: me.email,
|
||||
token: tokenRes.sha1,
|
||||
tokenId: tokenRes.id,
|
||||
};
|
||||
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
console.log(`Credentials stored in ${require('./config').CONFIG_PATH}`);
|
||||
} catch (err) {
|
||||
printErrorAndExit(err);
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command('logout')
|
||||
.description('Revoke the stored access token and remove local configuration')
|
||||
.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
console.log('No active session.');
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
clearConfig();
|
||||
console.log('Local credentials removed.');
|
||||
} catch (err) {
|
||||
console.error(`Logout failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
auth
|
||||
.command('status')
|
||||
.description('Show the current authentication status')
|
||||
.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config || !config.token) {
|
||||
console.log('Not authenticated.');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const me = await client.get('/user');
|
||||
console.log('Instance: ', config.url);
|
||||
console.log('Login: ', me.login);
|
||||
console.log('Username: ', me.username);
|
||||
console.log('Email: ', me.email);
|
||||
console.log('Token path: ', require('./config').CONFIG_PATH);
|
||||
} catch (err) {
|
||||
console.error(`Status check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function resolveSourceToken(tokenOption, command) {
|
||||
if (tokenOption) return tokenOption;
|
||||
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
||||
try {
|
||||
return execSync('gh auth token', { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
} catch {
|
||||
throw new Error(`No source token provided. Set --${command}-token, GITHUB_TOKEN, or ensure 'gh auth token' works.`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBool(value, defaultValue) {
|
||||
return value === undefined ? defaultValue : Boolean(value);
|
||||
}
|
||||
|
||||
const repo = program
|
||||
.command('repo')
|
||||
.description('Manage repositories');
|
||||
|
||||
repo
|
||||
.command('list')
|
||||
.description('List repositories for the authenticated user')
|
||||
.option('-l, --limit <number>', 'maximum repositories to return', '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;
|
||||
if (!display.length) {
|
||||
console.log('No repositories found.');
|
||||
return;
|
||||
}
|
||||
for (const r of display) {
|
||||
const vis = r.private ? 'private' : 'public';
|
||||
console.log(`${r.full_name} [${vis}] ${r.html_url}`);
|
||||
}
|
||||
if (repos.length > display.length) {
|
||||
console.log(`...and ${repos.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list repositories: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('create')
|
||||
.description('Create a new repository for the authenticated user')
|
||||
.requiredOption('--name <name>', 'repository name')
|
||||
.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('--default-branch <branch>', 'default branch name', 'main')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
|
||||
const isPrivate = options.public ? false : options.private;
|
||||
const payload = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
private: isPrivate,
|
||||
auto_init: options.autoInit,
|
||||
default_branch: options.defaultBranch,
|
||||
};
|
||||
|
||||
const result = await client.createRepo(payload);
|
||||
console.log(`Repository created: ${result.full_name}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
console.log(`Clone (SSH): ${result.ssh_url}`);
|
||||
console.log(`Clone (HTTP): ${result.clone_url}`);
|
||||
} catch (err) {
|
||||
console.error(`Repository creation failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('import')
|
||||
.description('Import a remote repository (GitHub, GitLab, plain git, etc.)')
|
||||
.requiredOption('--from <clone-addr>', 'source clone URL, e.g. https://github.com/owner/repo.git')
|
||||
.requiredOption('--name <name>', 'name for the imported repository')
|
||||
.option('--service <service>', 'source service type', 'github')
|
||||
.option('--owner <owner>', 'Forgejo owner for the imported repository')
|
||||
.option('-d, --description <description>', 'repository description')
|
||||
.option('--private', 'make the repository private', false)
|
||||
.option('--public', 'make the repository public')
|
||||
.option('--issues', 'migrate issues', true)
|
||||
.option('--no-issues', 'skip migrating issues')
|
||||
.option('--labels', 'migrate labels', true)
|
||||
.option('--no-labels', 'skip migrating labels')
|
||||
.option('--milestones', 'migrate milestones', true)
|
||||
.option('--no-milestones', 'skip migrating milestones')
|
||||
.option('--pull-requests', 'migrate pull requests', true)
|
||||
.option('--no-pull-requests', 'skip migrating pull requests')
|
||||
.option('--releases', 'migrate releases', true)
|
||||
.option('--no-releases', 'skip migrating releases')
|
||||
.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")')
|
||||
.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 payload = {
|
||||
clone_addr: options.from,
|
||||
repo_name: options.name,
|
||||
repo_owner: options.owner || config.login,
|
||||
service: options.service,
|
||||
description: options.description || undefined,
|
||||
private: isPrivate,
|
||||
issues: normalizeBool(options.issues, true),
|
||||
labels: normalizeBool(options.labels, true),
|
||||
milestones: normalizeBool(options.milestones, true),
|
||||
pull_requests: normalizeBool(options.pullRequests, true),
|
||||
releases: normalizeBool(options.releases, true),
|
||||
wiki: normalizeBool(options.wiki, true),
|
||||
lfs: normalizeBool(options.lfs, false),
|
||||
auth_token: token,
|
||||
};
|
||||
|
||||
// Remove undefined fields
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key] === undefined) delete payload[key];
|
||||
});
|
||||
|
||||
const result = await client.migrateRepo(payload);
|
||||
console.log(`Repository imported: ${result.full_name}`);
|
||||
console.log(`URL: ${result.html_url}`);
|
||||
console.log(`Clone (SSH): ${result.ssh_url}`);
|
||||
console.log(`Clone (HTTP): ${result.clone_url}`);
|
||||
console.log(`Empty: ${result.empty}`);
|
||||
} catch (err) {
|
||||
console.error(`Repository import failed: ${err.message}`);
|
||||
if (err.status) console.error(`HTTP status: ${err.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('import-batch')
|
||||
.description('Import multiple repositories from a JSON manifest')
|
||||
.requiredOption('-f, --file <path>', 'path to JSON manifest')
|
||||
.option('--dry-run', 'print the manifest without importing', false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const raw = fs.readFileSync(options.file, 'utf8');
|
||||
const manifest = JSON.parse(raw);
|
||||
if (!Array.isArray(manifest)) {
|
||||
throw new Error('Manifest must be a JSON array');
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const token = resolveSourceToken(undefined, 'github');
|
||||
const results = [];
|
||||
|
||||
for (const item of manifest) {
|
||||
const name = item.name || item.repo_name;
|
||||
const from = item.from || item.clone_addr;
|
||||
if (!name || !from) {
|
||||
console.error(`Skipping invalid manifest entry: ${JSON.stringify(item)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
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',
|
||||
description: item.description || undefined,
|
||||
private: isPrivate,
|
||||
issues: normalizeBool(item.issues, true),
|
||||
labels: normalizeBool(item.labels, true),
|
||||
milestones: normalizeBool(item.milestones, true),
|
||||
pull_requests: normalizeBool(item.pull_requests, true),
|
||||
releases: normalizeBool(item.releases, true),
|
||||
wiki: normalizeBool(item.wiki, true),
|
||||
lfs: normalizeBool(item.lfs, false),
|
||||
auth_token: item.github_token || token,
|
||||
};
|
||||
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key] === undefined) delete payload[key];
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.migrateRepo(payload);
|
||||
console.log(`Imported: ${result.full_name} -> ${result.html_url}`);
|
||||
results.push({ name, status: 'ok', url: result.html_url });
|
||||
} catch (err) {
|
||||
console.error(`Failed to import ${name}: ${err.message}`);
|
||||
results.push({ name, status: 'failed', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const ok = results.filter((r) => r.status === 'ok').length;
|
||||
console.log(`\nBatch complete: ${ok}/${results.length} imported.`);
|
||||
if (ok < results.length) process.exit(1);
|
||||
} catch (err) {
|
||||
console.error(`Batch import failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const issue = program
|
||||
.command('issue')
|
||||
.description('Manage issues');
|
||||
|
||||
issue
|
||||
.command('list')
|
||||
.description('List issues in a repository')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.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')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
const issues = await client.listIssues(options.owner, options.repo, {
|
||||
state: options.state,
|
||||
type: options.type,
|
||||
});
|
||||
const limit = Number(options.limit);
|
||||
const display = limit > 0 ? issues.slice(0, limit) : issues;
|
||||
if (!display.length) {
|
||||
console.log('No issues found.');
|
||||
return;
|
||||
}
|
||||
for (const i of display) {
|
||||
console.log(`#${i.number} [${i.state}] ${i.title}`);
|
||||
}
|
||||
if (issues.length > display.length) {
|
||||
console.log(`...and ${issues.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list issues: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const pr = program
|
||||
.command('pr')
|
||||
.description('Manage pull requests');
|
||||
|
||||
pr
|
||||
.command('list')
|
||||
.description('List pull requests in a repository')
|
||||
.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')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const client = ForgejoClient.fromConfig(config);
|
||||
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;
|
||||
if (!display.length) {
|
||||
console.log('No pull requests found.');
|
||||
return;
|
||||
}
|
||||
for (const p of display) {
|
||||
console.log(`!${p.number} [${p.state}] ${p.title} (${p.head.ref} -> ${p.base.ref})`);
|
||||
}
|
||||
if (pulls.length > display.length) {
|
||||
console.log(`...and ${pulls.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list pull requests: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const branchCmd = program
|
||||
.command('branch')
|
||||
.description('Manage branches');
|
||||
|
||||
branchCmd
|
||||
.command('list')
|
||||
.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')
|
||||
.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;
|
||||
if (!display.length) {
|
||||
console.log('No branches found.');
|
||||
return;
|
||||
}
|
||||
for (const b of display) {
|
||||
console.log(b.name);
|
||||
}
|
||||
if (branches.length > display.length) {
|
||||
console.log(`...and ${branches.length - display.length} more (use -l 0 for all).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to list branches: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
52
src/config.js
Normal file
52
src/config.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const CONFIG_DIR = process.env.FORGEJO_CONFIG_DIR
|
||||
|| path.join(process.env.XDG_CONFIG_HOME || os.homedir(), '.config', 'forgejo-cli');
|
||||
const CONFIG_PATH = process.env.FORGEJO_CONFIG_FILE
|
||||
|| path.join(CONFIG_DIR, 'config.json');
|
||||
const CONFIG_MODE = 0o600;
|
||||
const DIR_MODE = 0o700;
|
||||
|
||||
function ensureConfigDir() {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE });
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, '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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
ensureConfigDir();
|
||||
const tmp = `${CONFIG_PATH}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: CONFIG_MODE });
|
||||
fs.renameSync(tmp, CONFIG_PATH);
|
||||
try {
|
||||
fs.chmodSync(CONFIG_PATH, CONFIG_MODE);
|
||||
} catch {
|
||||
// ignore on platforms where chmod is unsupported
|
||||
}
|
||||
}
|
||||
|
||||
function clearConfig() {
|
||||
try {
|
||||
fs.unlinkSync(CONFIG_PATH);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_DIR,
|
||||
CONFIG_PATH,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
clearConfig,
|
||||
};
|
||||
Loading…
Reference in a new issue