#!/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('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; } }); 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 `stoke-${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.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()) .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('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 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); } }); const collaborator = program .command('collaborator') .description('Manage repository collaborators'); collaborator .command('add') .description('Add a collaborator to a repository') .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('-u, --user ', 'username of the collaborator') .option('--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); } }); program.parseAsync(process.argv).catch((err) => { console.error(err); process.exit(1); });