forked from heavy-duty/stoke
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:
commit
3e93b20ae6
3 changed files with 202 additions and 1 deletions
23
README.md
23
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 <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.
|
||||
|
|
|
|||
71
src/cli.js
71
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 <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
109
test/clone.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue