#!/usr/bin/env node const { Command, InvalidArgumentError } = require('commander'); const readline = require('node:readline'); const fs = require('node:fs'); const path = require('node:path'); const { execSync, spawnSync } = require('node:child_process'); const { stdin: input, stdout: output } = require('node:process'); const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config'); const { ForgejoClient } = require('./api'); const { syncRepository } = require('./repo-sync'); const pkg = require('../package.json'); const program = new Command(); program .name('stoke') .description('CLI for the heavy-duty forge (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.STOKE_CONFIG_FILE = thisCommand.opts().config; } }); // 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) { try { return fs.readFileSync(filePath, 'utf8').replace(/\r?\n$/, ''); } catch (err) { throw new Error(`Could not read ${label} file ${filePath}: ${err.message}`); } } 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 parseDepth(value) { const n = Number(value); if (!Number.isInteger(n) || n <= 0) { throw new InvalidArgumentError('Depth must be a positive integer.'); } return n; } function collectOption(value, previous) { return previous.concat(value); } function validateAssetOptions(options, { requireAsset = false } = {}) { if (requireAsset && options.asset.length === 0) { throw new Error('At least one --asset is required.'); } if (options.assetName && options.asset.length !== 1) { throw new Error('--asset-name requires exactly one --asset.'); } } async function uploadAssets(client, owner, repo, releaseId, assetPaths, assetName) { const uploaded = []; const failed = []; for (const assetPath of assetPaths) { const name = assetName || path.basename(assetPath); try { await client.uploadReleaseAsset(owner, repo, releaseId, assetPath, name); uploaded.push(name); console.log(`Asset uploaded: ${name}`); } catch (err) { failed.push({ name, error: err }); console.error(`Asset failed: ${name}: ${err.message}`); } } return { uploaded, failed }; } // Read commands share a --json flag that prints the raw API response // (pretty-printed) instead of the human-readable format. function printJson(data) { console.log(JSON.stringify(data, null, 2)); } function makeTokenName() { const host = require('node:os').hostname() || 'unknown'; return `stoke-${host}-${Date.now()}`; } // Least-privilege default: enough for the daily issue/PR/repository commands. // Org administration (org create/avatar, team create/member-*) and anything // else outside this set needs --full-scopes or an explicit --scopes list. const DEFAULT_TOKEN_SCOPES = [ 'read:issue', 'write:issue', 'read:repository', 'write:repository', 'read:user', 'read:organization', ]; // The previous behavior: full read/write on every non-admin scope. const FULL_TOKEN_SCOPES = [ 'read:activitypub', 'write:activitypub', 'read:issue', 'write:issue', 'read:misc', 'write:misc', 'read:organization', 'write:organization', 'read:package', 'write:package', 'read:repository', 'write:repository', 'read:user', 'write:user', ]; function parseScopesOption(csv) { return csv.split(',').map((s) => s.trim()).filter(Boolean); } function printErrorAndExit(err) { console.error(`Authentication failed: ${err.message}`); if (err.status) { 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.STOKE_URL || process.env.FORGEJO_URL || 'https://forgejo.heavyduty.builders') .option('-n, --username ', 'account username or email', process.env.STOKE_USERNAME || process.env.FORGEJO_USERNAME) .option('-p, --password ', 'account password', process.env.STOKE_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()) .option('--full-scopes', 'grant full read/write access on all non-admin scopes', false) .option('--scopes ', 'comma-separated list of scopes for the generated token') .action(async (options) => { try { let { url, username, password, passwordFile, token, tokenFile, tokenName } = options; if (options.fullScopes && options.scopes) { console.error('Use either --full-scopes or --scopes, not both.'); process.exit(1); } let scopes = DEFAULT_TOKEN_SCOPES; if (options.fullScopes) { scopes = FULL_TOKEN_SCOPES; } else if (options.scopes) { scopes = parseScopesOption(options.scopes); if (!scopes.length) { console.error('--scopes produced an empty scope list.'); process.exit(1); } } if (tokenFile) token = readSecretFile(tokenFile, 'token'); if (passwordFile) password = readSecretFile(passwordFile, 'password'); 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, 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.`); console.log(`Scopes: ${scopes.join(', ')}`); } saveConfig(config); console.log(`Credentials stored in ${getConfigPath()}`); } catch (err) { printErrorAndExit(err); } }); auth .command('logout') .description('Revoke the stored access token and remove local configuration') .option('-p, --password ', 'account password, needed to revoke the token remotely', process.env.STOKE_PASSWORD || process.env.FORGEJO_PASSWORD) .option('--password-file ', '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) { console.log('No active session.'); return; } // 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.`); } } else if (!config.tokenId && !options.localOnly) { console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`); } 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') .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); if (!config || !config.token) { if (options.json) { console.log('{"authenticated": false}'); } else { console.log('Not authenticated.'); } process.exit(1); } const client = ForgejoClient.fromConfig(config); const me = await client.get('/user'); if (options.json) { printJson(me); return; } console.log('Instance: ', config.url); console.log('Login: ', me.login); console.log('Username: ', me.username); console.log('Email: ', me.email); console.log('Token path: ', getConfigPath()); } catch (err) { console.error(`Status check failed: ${err.message}`); process.exit(1); } }); // 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 { cachedGhToken = execSync('gh auth token', { encoding: 'utf8', timeout: 10000 }).trim(); return cachedGhToken; } catch { throw new Error("No GitHub token found. Set --github-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 (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const repos = await client.listRepos(); if (options.json) { printJson(repos); return; } const display = options.limit > 0 ? repos.slice(0, options.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); } }); // Hand the stored token to git through environment-based config instead of // the remote URL or `git clone -c`: GIT_CONFIG_* variables only live for the // duration of this process, so the token never reaches the command line, // the remote URL, or the cloned repository's .git/config (which `-c` would // write into). GIT_TERMINAL_PROMPT=0 keeps git from interactively asking // for credentials the session already owns. function gitAuthEnv(config) { const username = config.username || config.login || 'stoke'; const basic = Buffer.from(`${username}:${config.token}`).toString('base64'); return { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${config.url}.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`, }; } repo .command('clone') .description('Clone a repository using the stored Forgejo credentials') .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .argument('[directory]', 'destination directory (defaults to the repository name)') .option('--branch ', 'checkout this branch instead of the default branch') .option('--depth ', 'create a shallow clone with the given history depth', parseDepth) .option('--origin ', 'name for the created remote', 'origin') .action((directory, options) => { try { const config = loadConfig(); if (!config || !config.url || !config.token) { throw new Error('Not authenticated. Run: stoke auth login'); } const base = config.url.replace(/\/+$/, ''); const cloneUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`; const args = ['clone', '--origin', options.origin]; if (options.branch) args.push('--branch', options.branch); if (options.depth) args.push('--depth', String(options.depth)); args.push(cloneUrl); if (directory) args.push(directory); const res = spawnSync('git', args, { stdio: 'inherit', env: gitAuthEnv({ ...config, url: base }), }); if (res.error) { throw new Error(`Failed to run git: ${res.error.message}`); } if (res.status !== 0) { // Git's error output already went to stderr; forward its exit status // so scripts see the same failure a plain `git clone` would produce. process.exit(res.status == null ? 1 : res.status); } console.log(`Cloned ${options.owner}/${options.repo} into ${directory || options.repo}.`); } catch (err) { console.error(`Repository clone failed: ${err.message}`); process.exit(1); } }); repo .command('sync') .description('Fast-forward an imported repository from its upstream') .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('--from ', 'upstream Git URL') .option('--branch ', 'branch to synchronize') .option('--tags', 'also create new upstream tags', false) .option('--dry-run', 'report changes without pushing', false) .action((options) => { try { const config = loadConfig(); if (!config || !config.url || !config.token) { throw new Error('Not authenticated. Run: stoke auth login'); } const base = config.url.replace(/\/+$/, ''); const forgeUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`; const result = syncRepository({ forgeUrl, upstreamUrl: options.from, branch: options.branch, includeTags: options.tags, dryRun: options.dryRun, env: gitAuthEnv({ ...config, url: base }), }); if (result.changed) { console.log(`${result.branch} ${result.oldSha}..${result.newSha}`); } else { console.log(`${result.branch} is up to date at ${result.newSha}`); } for (const tag of result.newTags) { console.log(`tag ${tag.name} create ${tag.sha}`); } for (const tag of result.movedTags) { console.error(`tag ${tag.name} moved upstream: forge ${tag.forgeSha}, upstream ${tag.upstreamSha}; skipped`); } if (result.movedTags.length > 0) process.exitCode = 1; } catch (err) { console.error(`Repository sync failed: ${err.message}`); process.exit(1); } }); repo .command('create') .description('Create a new repository for the authenticated user or an organization') .requiredOption('--name ', 'repository name') .option('-o, --owner ', 'repository owner (authenticated user or organization)') .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 (default)') .option('--no-auto-init', 'create an empty repository without a README') .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: normalizeBool(options.autoInit, true), default_branch: options.defaultBranch, }; const result = await client.createRepo(payload, options.owner); 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 ', '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, options.service); 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('rename') .description('Rename a repository') .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'current repository name') .requiredOption('--name ', 'new repository name') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const result = await client.renameRepo(options.owner, options.repo, options.name); console.log(`Repository renamed to ${result.full_name}`); console.log(`URL: ${result.html_url}`); } catch (err) { console.error(`Repository rename 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 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; } try { 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, 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: resolveSourceToken(item.github_token, service), }; Object.keys(payload).forEach((key) => { if (payload[key] === undefined) delete payload[key]; }); 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); } }); repo .command('transfer') .description('Transfer a repository to a new owner (user or organization)') .requiredOption('-o, --owner ', 'current repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('--to ', 'new owner (username or organization name)') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const result = await client.transferRepo(options.owner, options.repo, options.to); console.log(`Repository transferred: ${result.full_name}`); console.log(`URL: ${result.html_url}`); } catch (err) { console.error(`Repository transfer failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); 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 (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .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, }); if (options.json) { printJson(issues); return; } const display = options.limit > 0 ? issues.slice(0, options.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); } }); issue .command('create') .description('Create an issue in a repository') .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('-t, --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') .option('--label <name...>', 'apply one or more labels by name') .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; } if (options.label && options.label.length) { payload.labels = await resolveLabelIds(client, options.owner, options.repo, options.label); } 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); } }); issue .command('show') .description('Show details of an issue') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'issue number', parseId) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const issueData = await client.getIssue(options.owner, options.repo, options.number); if (options.json) { printJson(issueData); return; } const author = issueData.user?.login || '(unknown)'; console.log(`#${issueData.number} [${issueData.state}] ${issueData.title}`); console.log(`URL: ${issueData.html_url}`); console.log(`Author: ${author}`); console.log(`Created: ${issueData.created_at}`); if (issueData.body) { console.log('\n' + issueData.body); } } catch (err) { console.error(`Failed to show issue: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); issue .command('comment') .description('Add a comment to an issue') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'issue number', parseId) .option('-b, --body <body>', 'comment body (markdown; required unless --body-file)') .option('--body-file <path>', 'read the comment body from a file (wins over -b)') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const rawBody = readBodyOption(options) || ''; if (rawBody.trim().length === 0) { console.error('Comment body is required. Use -b/--body or --body-file.'); process.exit(1); } const result = await client.createIssueComment(options.owner, options.repo, options.number, rawBody); console.log(`Comment added to #${options.number}.`); console.log(`URL: ${result.html_url}`); } catch (err) { console.error(`Failed to comment on issue: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); 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 (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const pulls = await client.listPullRequests(options.owner, options.repo, { state: options.state, }); if (options.json) { printJson(pulls); return; } const display = options.limit > 0 ? pulls.slice(0, options.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); } }); 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); } }); pr .command('merge') .description('Merge a pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'pull request number', parseId) .option('--method <method>', 'merge method: merge, rebase, rebase-merge, squash', 'merge') .option('--title <title>', 'custom merge commit title') .option('--message <message>', 'custom merge commit message') .option('--delete-branch', 'delete the source branch after merging', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const payload = { Do: options.method, delete_branch_after_merge: options.deleteBranch, }; if (options.title) payload.MergeTitleField = options.title; if (options.message) payload.MergeMessageField = options.message; await client.mergePullRequest(options.owner, options.repo, options.number, payload); console.log(`Merged !${options.number} in ${options.owner}/${options.repo} (${options.method}).`); if (options.deleteBranch) console.log('Source branch deleted.'); } catch (err) { console.error(`Pull request merge failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); // Map CLI review event names (and common Forgejo ReviewStateType tokens) to // the values this forge accepts on POST .../pulls/{n}/reviews. const REVIEW_EVENT_MAP = { approve: 'APPROVED', approved: 'APPROVED', 'request-changes': 'REQUEST_CHANGES', request_changes: 'REQUEST_CHANGES', comment: 'COMMENT', }; function resolveReviewEvent(raw) { return REVIEW_EVENT_MAP[String(raw).toLowerCase()] || null; } pr .command('show') .description('Show details of a pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'pull request number', parseId) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const prData = await client.getPullRequest(options.owner, options.repo, options.number); if (options.json) { printJson(prData); return; } const author = prData.user?.login || '(unknown)'; const headRef = prData.head?.ref || '?'; const baseRef = prData.base?.ref || '?'; const mergeable = prData.mergeable == null ? 'unknown' : String(prData.mergeable); console.log(`!${prData.number} [${prData.state}] ${prData.title}`); console.log(`URL: ${prData.html_url}`); console.log(`Author: ${author}`); console.log(`Branch: ${headRef} -> ${baseRef}`); console.log(`Mergeable: ${mergeable}`); console.log(`Created: ${prData.created_at}`); if (prData.body) { console.log('\n' + prData.body); } } catch (err) { console.error(`Failed to show pull request: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); pr .command('comment') .description('Add a comment to a pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'pull request number', parseId) .option('-b, --body <body>', 'comment body (markdown; required unless --body-file)') .option('--body-file <path>', 'read the comment body from a file (wins over -b)') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const rawBody = readBodyOption(options) || ''; if (rawBody.trim().length === 0) { console.error('Comment body is required. Use -b/--body or --body-file.'); process.exit(1); } const result = await client.createPullRequestComment(options.owner, options.repo, options.number, rawBody); console.log(`Comment added to !${options.number}.`); console.log(`URL: ${result.html_url}`); } catch (err) { console.error(`Failed to comment on pull request: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); pr .command('review') .description('Submit a review on a pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'pull request number', parseId) .requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment') .option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)') .option('--body-file <path>', 'read the review body from a file (wins over -b)') .option('--commit <sha>', 'commit SHA the review applies to (sent as commit_id)') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const event = resolveReviewEvent(options.event); if (!event) { console.error(`Invalid review event: ${options.event}. Must be approve (or approved), request-changes (or request_changes), or comment.`); process.exit(1); } const rawBody = readBodyOption(options) || ''; if (event !== 'APPROVED' && rawBody.trim().length === 0) { console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`); process.exit(1); } const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody, { commitId: options.commit }); console.log(`Review submitted on !${options.number}: ${event}.`); if (result && result.html_url) { console.log(`URL: ${result.html_url}`); } } catch (err) { console.error(`Failed to submit review: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); const release = program .command('release') .description('Manage releases'); release .command('list') .description('List releases in a repository') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const releases = await client.listReleases(options.owner, options.repo); if (options.json) { printJson(releases); return; } const display = options.limit > 0 ? releases.slice(0, options.limit) : releases; if (!display.length) { console.log('No releases found.'); return; } for (const rel of display) { const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|'); const tag = flags ? `${rel.tag_name} [${flags}]` : rel.tag_name; console.log(`${tag} ${rel.name || ''}`.trimEnd()); } if (releases.length > display.length) { console.log(`...and ${releases.length - display.length} more (use -l 0 for all).`); } } catch (err) { console.error(`Failed to list releases: ${err.message}`); process.exit(1); } }); release .command('view') .description('Show the release for a tag') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('--tag <tag>', 'tag name of the release') .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag); if (options.json) { printJson(rel); return; } const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|'); console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd()); console.log(`URL: ${rel.html_url}`); console.log(`Target: ${rel.target_commitish}`); console.log(`Author: ${rel.author?.login || '(unknown)'}`); console.log(`Published: ${rel.published_at}`); if (rel.assets?.length) { console.log('\nAssets:'); for (const asset of rel.assets) { console.log(` ${asset.name} (${asset.size} bytes) ${asset.browser_download_url}`); } } if (rel.body) { console.log('\n' + rel.body); } } catch (err) { console.error(`Failed to show release: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); release .command('create') .description('Create a release (creates the tag too if it does not exist)') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('--tag <tag>', 'tag name for the release') .option('--target <ref>', 'branch or commit the tag is created from (default: repository default branch)') .option('-t, --title <title>', 'release title (default: the tag name)') .option('-b, --body <body>', 'release notes (markdown)') .option('--body-file <path>', 'read the release notes from a file (wins over -b)') .option('--asset <path>', 'attach an asset (repeatable)', collectOption, []) .option('--asset-name <name>', 'override the uploaded filename (exactly one asset)') .option('--draft', 'create as a draft release', false) .option('--prerelease', 'mark as a prerelease', false) .action(async (options) => { try { validateAssetOptions(options); const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const payload = { tag_name: options.tag, name: options.title || options.tag, body: readBodyOption(options) || '', draft: options.draft, prerelease: options.prerelease, }; if (options.target) payload.target_commitish = options.target; const result = await client.createRelease(options.owner, options.repo, payload); console.log(`Release created: ${result.tag_name} ${result.name || ''}`.trimEnd()); console.log(`Release id: ${result.id}`); console.log(`URL: ${result.html_url}`); const uploads = await uploadAssets( client, options.owner, options.repo, result.id, options.asset, options.assetName, ); if (uploads.failed.length) { console.error( `${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed. The release was kept.`, ); process.exitCode = 1; } } catch (err) { console.error(`Release creation failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); release .command('upload') .description('Attach assets to an existing release') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('--tag <tag>', 'tag name of the release') .requiredOption('--asset <path>', 'asset to upload (repeatable)', collectOption, []) .option('--asset-name <name>', 'override the uploaded filename (exactly one asset)') .action(async (options) => { try { validateAssetOptions(options, { requireAsset: true }); const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const releaseResult = await client.getReleaseByTag(options.owner, options.repo, options.tag); const uploads = await uploadAssets( client, options.owner, options.repo, releaseResult.id, options.asset, options.assetName, ); if (uploads.failed.length) { console.error(`${uploads.uploaded.length} asset(s) uploaded; ${uploads.failed.length} failed.`); process.exitCode = 1; } } catch (err) { console.error(`Release upload failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); function parseColor(value) { const hex = value.replace(/^#/, ''); if (!/^[0-9a-fA-F]{6}$/.test(hex)) { throw new InvalidArgumentError('Color must be 6 hex digits (with or without a leading #).'); } return hex.toLowerCase(); } // Label add/remove/lookup go through names on the CLI but ids on the wire, // so every caller resolves against the repo's label list first. async function resolveLabelIds(client, owner, repo, names) { const labels = await client.listLabels(owner, repo); const byName = new Map(labels.map((l) => [l.name, l.id])); return names.map((name) => { const id = byName.get(name); if (id === undefined) { throw new Error(`Label not found in ${owner}/${repo}: ${name}`); } return id; }); } const label = program .command('label') .description('Manage repository labels'); label .command('list') .description('List labels in a repository') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const labels = await client.listLabels(options.owner, options.repo); if (options.json) { printJson(labels); return; } const display = options.limit > 0 ? labels.slice(0, options.limit) : labels; if (!display.length) { console.log('No labels found.'); return; } for (const l of display) { console.log(`#${l.id} ${l.name} #${l.color}${l.description ? ` — ${l.description}` : ''}`); } if (labels.length > display.length) { console.log(`...and ${labels.length - display.length} more (use -l 0 for all).`); } } catch (err) { console.error(`Failed to list labels: ${err.message}`); process.exit(1); } }); label .command('create') .description('Create a label in a repository') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('--name <name>', 'label name') .requiredOption('--color <color>', 'label color, 6 hex digits (with or without #)', parseColor) .option('-d, --description <description>', 'label description', '') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const result = await client.createLabel(options.owner, options.repo, { name: options.name, color: options.color, description: options.description, }); console.log(`Label created: #${result.id} ${result.name} #${result.color}`); } catch (err) { console.error(`Label creation failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); label .command('delete') .description('Delete a label from a repository (by --id or --name)') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .option('--id <id>', 'label id', parseId) .option('--name <name>', 'label name') .action(async (options) => { try { if (!options.id && !options.name) { console.error('One of --id or --name is required.'); process.exit(1); } if (options.id && options.name) { console.error('Use either --id or --name, not both.'); process.exit(1); } const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const ids = options.id ? [options.id] : await resolveLabelIds(client, options.owner, options.repo, [options.name]); await client.deleteLabel(options.owner, options.repo, ids[0]); console.log(`Label deleted: ${options.name || `#${options.id}`} from ${options.owner}/${options.repo}.`); } catch (err) { console.error(`Label deletion failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); label .command('add') .description('Add labels to an issue or pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'issue or pull request number', parseId) .requiredOption('--name <name...>', 'one or more label names') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const ids = await resolveLabelIds(client, options.owner, options.repo, options.name); await client.addIssueLabels(options.owner, options.repo, options.number, ids); console.log(`Labels added to #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`); } catch (err) { console.error(`Failed to add labels: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); label .command('remove') .description('Remove labels from an issue or pull request') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-n, --number <number>', 'issue or pull request number', parseId) .requiredOption('--name <name...>', 'one or more label names') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const ids = await resolveLabelIds(client, options.owner, options.repo, options.name); for (const id of ids) { await client.removeIssueLabel(options.owner, options.repo, options.number, id); } console.log(`Labels removed from #${options.number} in ${options.owner}/${options.repo}: ${options.name.join(', ')}`); } catch (err) { console.error(`Failed to remove labels: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); 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 (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const branches = await client.listBranches(options.owner, options.repo); if (options.json) { printJson(branches); return; } const display = options.limit > 0 ? branches.slice(0, options.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); } }); const collaborator = program .command('collaborator') .description('Manage repository collaborators'); collaborator .command('add') .description('Add a collaborator to a repository') .requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-u, --user <username>', 'username of the collaborator') .option('--permission <permission>', 'permission level: read, write, admin', 'write') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); await client.addCollaborator(options.owner, options.repo, options.user, options.permission); console.log(`Added ${options.user} as ${options.permission} collaborator to ${options.owner}/${options.repo}.`); } catch (err) { console.error(`Failed to add collaborator: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); const org = program .command('org') .description('Manage organizations'); org .command('create') .description('Create a new organization') .requiredOption('--name <name>', 'organization username (short name used in URLs)') .option('--full-name <full-name>', 'display name of the organization', '') .option('-d, --description <description>', 'organization description', '') .option('--website <website>', 'organization website', '') .option('--location <location>', 'organization location', '') .option('--visibility <visibility>', 'visibility: public, limited, private', 'public') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const payload = { username: options.name, full_name: options.fullName, description: options.description, website: options.website, location: options.location, visibility: options.visibility, }; const result = await client.createOrg(payload); console.log(`Organization created: ${result.username}`); if (result.full_name) console.log(`Full name: ${result.full_name}`); console.log(`URL: ${config.url}/${result.username}`); } catch (err) { console.error(`Organization creation failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); org .command('repos') .description('List repositories owned by an organization') .requiredOption('-o, --org <org>', 'organization name') .option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const repos = await client.listOrgRepos(options.org); if (options.json) { printJson(repos); return; } const display = options.limit > 0 ? repos.slice(0, options.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 organization repositories: ${err.message}`); process.exit(1); } }); org .command('avatar') .description('Set the avatar (logo) of an organization from an image file') .requiredOption('-o, --org <org>', 'organization name') .requiredOption('-f, --file <path>', 'path to the image file (png, jpeg, gif, ...)') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); let image; try { image = fs.readFileSync(options.file); } catch (err) { throw new Error(`Could not read image file ${options.file}: ${err.message}`); } await client.updateOrgAvatar(options.org, image.toString('base64')); console.log(`Avatar updated for organization ${options.org}.`); } catch (err) { console.error(`Failed to update organization avatar: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); const team = org .command('team') .description('Manage organization teams'); team .command('list') .description('List teams in an organization') .requiredOption('-o, --org <org>', 'organization name') .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const teams = await client.listOrgTeams(options.org); if (options.json) { printJson(teams); return; } if (!teams.length) { console.log('No teams found.'); return; } for (const t of teams) { console.log(`#${t.id} ${t.name} [${t.permission}]`); } } catch (err) { console.error(`Failed to list teams: ${err.message}`); process.exit(1); } }); team .command('create') .description('Create a team in an organization') .requiredOption('-o, --org <org>', 'organization name') .requiredOption('--name <name>', 'team name') .option('-d, --description <description>', 'team description', '') .option('--permission <permission>', 'permission level: read, write, admin', 'read') .option('--all-repos', 'grant access to all current and future organization repositories', false) .option('--can-create-repo', 'allow members to create repositories in the organization', false) .option('--units <units>', 'comma-separated team units', 'repo.code,repo.issues,repo.pulls,repo.releases,repo.wiki,repo.projects') .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const payload = { name: options.name, description: options.description, permission: options.permission, includes_all_repositories: options.allRepos, can_create_org_repo: options.canCreateRepo, units: options.units.split(',').map((u) => u.trim()).filter(Boolean), }; const result = await client.createTeam(options.org, payload); console.log(`Team created: #${result.id} ${result.name} [${result.permission}]`); } catch (err) { console.error(`Team creation failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); team .command('member-list') .description('List members of a team') .requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const members = await client.listTeamMembers(options.teamId); if (options.json) { printJson(members); return; } if (!members.length) { console.log('No members found.'); return; } for (const m of members) { const name = m.full_name ? ` (${m.full_name})` : ''; console.log(`${m.login}${name}`); } } catch (err) { console.error(`Failed to list team members: ${err.message}`); process.exit(1); } }); team .command('member-add') .description('Add a user to a team') .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(options.teamId, options.user); console.log(`Added ${options.user} to team #${options.teamId}.`); } catch (err) { console.error(`Failed to add team member: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); team .command('member-remove') .description('Remove a user from a team') .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(options.teamId, options.user); console.log(`Removed ${options.user} from team #${options.teamId}.`); } catch (err) { console.error(`Failed to remove team member: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); const user = program .command('user') .description('Manage users'); 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 (0 for all)', parseLimit, 50) .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const users = await client.searchUsers(options.query); if (options.json) { printJson(users); return; } const display = options.limit > 0 ? users.slice(0, options.limit) : users; if (!display.length) { console.log('No users found.'); return; } for (const u of display) { const name = u.full_name ? ` (${u.full_name})` : ''; console.log(`${u.login}${name}`); } if (users.length > display.length) { console.log(`...and ${users.length - display.length} more (use -l 0 for all).`); } } catch (err) { console.error(`Failed to list users: ${err.message}`); process.exit(1); } }); user .command('show') .description('Show a single user profile') .requiredOption('-u, --user <username>', 'username to look up') .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const u = await client.getUser(options.user); if (options.json) { printJson(u); return; } console.log(`Login: ${u.login}`); console.log(`Full name: ${u.full_name || '-'}`); console.log(`Email: ${u.email || '-'}`); console.log(`URL: ${u.html_url}`); } catch (err) { console.error(`Failed to show user: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); program .command('api') .description('Make an authenticated request to any Forgejo API endpoint and print the JSON response') .argument('<endpoint>', 'endpoint path starting with / (the /api/v1 prefix is added for you)') .option('-X, --method <method>', 'HTTP method (default: GET, or POST when --input is given)') .option('--input <json>', 'JSON request body, inline or @path to read it from a file') .option('--paginate', 'fetch all pages (GET endpoints returning a JSON array)', false) .action(async (endpoint, options) => { try { if (!endpoint.startsWith('/')) { console.error('Endpoint must start with / (e.g. /repos/owner/repo/pulls?state=closed).'); process.exit(1); } const method = (options.method || (options.input ? 'POST' : 'GET')).toUpperCase(); if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { console.error(`Unsupported method: ${method}. Use GET, POST, PUT, PATCH or DELETE.`); process.exit(1); } if (options.paginate && method !== 'GET') { console.error('--paginate only works with GET.'); process.exit(1); } if (method === 'GET' && options.input !== undefined) { console.error('GET requests cannot carry a body. Drop --input, or use -X POST/PUT/PATCH/DELETE.'); process.exit(1); } let body = null; if (options.input !== undefined) { const raw = options.input.startsWith('@') ? (() => { try { return fs.readFileSync(options.input.slice(1), 'utf8'); } catch (err) { throw new Error(`Could not read input file ${options.input.slice(1)}: ${err.message}`); } })() : options.input; try { body = JSON.parse(raw); } catch (err) { console.error(`--input is not valid JSON: ${err.message}`); process.exit(1); } } const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const data = options.paginate ? await client.getAll(endpoint) : await client.request(method, endpoint, body); console.log(JSON.stringify(data, null, 2)); } catch (err) { console.error(`API request failed: ${err.message}`); if (err.status) console.error(`HTTP status: ${err.status}`); process.exit(1); } }); program.parseAsync(process.argv).catch((err) => { console.error(err); process.exit(1); });