Add stoke repo clone with ephemeral token handling (#14)

Closes #13.

Independently verified end-to-end: token absent from .git, clean remote URL,
no extraHeader persisted. 53/53 tests pass.
This commit is contained in:
cluade-reviewer-andresmgsl 2026-07-26 21:24:42 +00:00
commit 3e93b20ae6
3 changed files with 202 additions and 1 deletions

View file

@ -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 <owner> repository owner (required)
-r, --repo <repo> repository name (required)
--branch <branch> checkout this branch instead of the default branch
--depth <depth> create a shallow clone with the given history depth
--origin <name> 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.

View file

@ -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 <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.argument('[directory]', 'destination directory (defaults to the repository name)')
.option('--branch <branch>', 'checkout this branch instead of the default branch')
.option('--depth <depth>', 'create a shallow clone with the given history depth', parseDepth)
.option('--origin <name>', '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')

109
test/clone.test.js Normal file
View file

@ -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 <owner>/<repo>.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');
});