From c981a1258def2eed52dfb74e149877291fefa7f5 Mon Sep 17 00:00:00 2001 From: kimi-reviewer-andresmgsl Date: Wed, 22 Jul 2026 15:03:32 +0000 Subject: [PATCH] feat: forgejo-cli with auth, repo, issue, pr and branch commands --- .gitignore | 3 + README.md | 349 +++++++++++++++++++++ manifests/heavy-duty-repos.json | 74 +++++ package-lock.json | 25 ++ package.json | 20 ++ src/api.js | 148 +++++++++ src/cli.js | 535 ++++++++++++++++++++++++++++++++ src/config.js | 52 ++++ 8 files changed, 1206 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 manifests/heavy-duty-repos.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api.js create mode 100755 src/cli.js create mode 100644 src/config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb61197 --- /dev/null +++ b/README.md @@ -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 +``` + +## Configuration + +Authentication state is stored in a JSON file: + +- Default: `~/.config/forgejo-cli/config.json` +- Override with `--config ` 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": "", + "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 to configuration file +-h, --help display help +-V, --version display version +``` + +### `forgejo auth login` + +Authenticate and persist an access token. + +```text +Options: + -u, --url Forgejo base URL (default: https://forgejo.heavyduty.builders) + -n, --username account username or email + -p, --password account password + --password-file read account password from a file + -t, --token use an existing personal access token instead of generating one + --token-file read an existing personal access token from a file + --token-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 +``` + +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 repository name (required) + -d, --description repository description + --private make the repository private + --public make the repository public + --auto-init initialize with a README (default: true) + --default-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 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 source clone URL (required) + --name repository name in Forgejo (required) + --service source service type: git, github, gitea, gitlab, ... (default: github) + --owner Forgejo owner for the imported repo (default: current user) + -d, --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 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 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 repository owner (required) + -r, --repo repository name (required) + -s, --state open, closed, all (default: open) + -t, --type issues or pulls (default: issues) + -l, --limit 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 repository owner (required) + -r, --repo repository name (required) + -s, --state open, closed, all (default: open) + -l, --limit 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 repository owner (required) + -r, --repo repository name (required) + -l, --limit 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`. diff --git a/manifests/heavy-duty-repos.json b/manifests/heavy-duty-repos.json new file mode 100644 index 0000000..5f4578b --- /dev/null +++ b/manifests/heavy-duty-repos.json @@ -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 + } +] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..56aa173 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba3d1ba --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..9a3f66d --- /dev/null +++ b/src/api.js @@ -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 }; diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..f4b096b --- /dev/null +++ b/src/cli.js @@ -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 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 ', 'Forgejo base URL', process.env.FORGEJO_URL || 'https://forgejo.heavyduty.builders') + .option('-n, --username ', 'account username or email', process.env.FORGEJO_USERNAME) + .option('-p, --password ', 'account password', process.env.FORGEJO_PASSWORD) + .option('--password-file ', 'read account password from a file') + .option('-t, --token ', 'use an existing personal access token instead of generating one') + .option('--token-file ', 'read an existing personal access token from a file') + .option('--token-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 ', '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 ', 'repository name') + .option('-d, --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 ', '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 ', 'source clone URL, e.g. https://github.com/owner/repo.git') + .requiredOption('--name ', 'name for the imported repository') + .option('--service ', 'source service type', 'github') + .option('--owner ', 'Forgejo owner for the imported repository') + .option('-d, --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 ', '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 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 ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .option('-s, --state ', 'issue state: open, closed, all', 'open') + .option('-t, --type ', 'issue type filter: issues, pulls', 'issues') + .option('-l, --limit ', '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 ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .option('-s, --state ', 'PR state: open, closed, all', 'open') + .option('-l, --limit ', '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 ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .option('-l, --limit ', '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); +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..2852a8b --- /dev/null +++ b/src/config.js @@ -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, +};