diff --git a/README.md b/README.md index 4da74e0..a44efc8 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,29 @@ stoke auth status Calls `GET /api/v1/user` with the stored token. +### `stoke repo clone` + +Clone a repository from the configured Forgejo instance using the stored credentials. + +```text +Arguments: + [directory] destination directory (default: repository name) + +Options: + -o, --owner repository owner (required) + -r, --repo repository name (required) + --branch checkout this branch instead of the default branch + --depth create a shallow clone with the given history depth + --origin name for the created remote (default: origin) +``` + +```bash +stoke repo clone -o heavy-duty -r stoke +stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1 +``` + +The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`. + ### `stoke repo create` Create a new repository for the authenticated user. diff --git a/src/cli.js b/src/cli.js index e7357fa..ef65e9e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ const { Command, InvalidArgumentError } = require('commander'); const readline = require('node:readline'); const fs = require('node:fs'); -const { execSync } = require('node:child_process'); +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'); @@ -84,6 +84,14 @@ function parseId(value) { 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 makeTokenName() { const host = require('node:os').hostname() || 'unknown'; return `stoke-${host}-${Date.now()}`; @@ -302,6 +310,67 @@ repo } }); +// 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('create') .description('Create a new repository for the authenticated user') diff --git a/test/clone.test.js b/test/clone.test.js new file mode 100644 index 0000000..9abde32 --- /dev/null +++ b/test/clone.test.js @@ -0,0 +1,109 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync, execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const CLI = path.join(__dirname, '..', 'src', 'cli.js'); +const TOKEN = 'stoke-secret-token-for-clone-tests'; + +// A local stand-in for the forge: a directory holding bare repositories laid +// out as /.git, so config.url can point at it with a file:// URL +// and `repo clone` exercises real git clones without any network. +let root; +let remote; +let work; +let cfg; + +function git(args, cwd) { + return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' }); +} + +function run(args, cwd) { + return spawnSync(process.execPath, [CLI, ...args], { + cwd: cwd || work, + encoding: 'utf8', + env: { ...process.env, STOKE_CONFIG_FILE: cfg }, + }); +} + +before(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-')); + remote = path.join(root, 'remote'); + work = path.join(root, 'work'); + const seed = path.join(root, 'seed'); + fs.mkdirSync(path.join(remote, 'o'), { recursive: true }); + fs.mkdirSync(work); + + git(['init', '-b', 'main', seed]); + fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n'); + git(['-C', seed, 'add', 'README.md']); + git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']); + git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]); + + cfg = path.join(root, 'config.json'); + fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' })); +}); + +after(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('repo clone rejects an invalid --depth before running git', () => { + const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']); + assert.equal(res.status, 1); + assert.match(res.stderr, /Depth must be a positive integer/); +}); + +test('repo clone defaults the destination to the repository name', () => { + const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']); + assert.equal(res.status, 0, res.stderr); + const dest = path.join(work, 'r'); + assert.ok(fs.existsSync(path.join(dest, '.git'))); + assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n'); +}); + +test('repo clone honors an explicit destination directory', () => { + const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']); + assert.equal(res.status, 0, res.stderr); + assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git'))); +}); + +test('repo clone fails with git\'s status when the destination is not empty', () => { + const dest = path.join(work, 'occupied'); + fs.mkdirSync(dest); + fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n'); + const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']); + assert.equal(res.status, 128); + assert.match(res.stderr, /already exists and is not an empty directory/); +}); + +test('repo clone propagates git\'s failure for a missing repository', () => { + const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']); + assert.equal(res.status, 128); + assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i); +}); + +test('repo clone --origin sets the remote name', () => { + const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']); + assert.equal(res.status, 0, res.stderr); + const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']); + assert.ok(url.trim().endsWith('/o/r.git')); +}); + +test('repo clone never exposes the token in output or repository config', () => { + const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']); + assert.equal(ok.status, 0, ok.stderr); + const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']); + + for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) { + assert.ok(!output.includes(TOKEN), 'token leaked into CLI output'); + } + + const dest = path.join(work, 'redacted'); + const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8'); + assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config'); + const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']); + assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL'); +});