const { test } = require('node:test'); const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const os = require('node:os'); const path = require('node:path'); const CLI = path.join(__dirname, '..', 'src', 'cli.js'); function run(args, env = {}) { return new Promise((resolve, reject) => { const childEnv = { ...process.env, ...env }; childEnv.NODE_OPTIONS = [ childEnv.NODE_OPTIONS, '--disable-warning=ExperimentalWarning', ].filter(Boolean).join(' '); const child = spawn(process.execPath, [CLI, ...args], { env: childEnv, }); let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', reject); child.on('close', (status) => resolve({ status, stdout, stderr })); }); } async function startMigrationServer() { const requests = []; const server = http.createServer((req, res) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { const payload = JSON.parse(body); requests.push({ method: req.method, url: req.url, body: payload }); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ full_name: `destination/${payload.repo_name}`, html_url: `https://forge.test/destination/${payload.repo_name}`, })); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); return { server, requests }; } test('repo import-batch continues after one item has no source token', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-')); const configFile = path.join(dir, 'config.json'); const manifestFile = path.join(dir, 'manifest.json'); const emptyPath = path.join(dir, 'bin'); const forgeToken = 'forge-token-must-not-be-printed'; const { server, requests } = await startMigrationServer(); fs.mkdirSync(emptyPath); fs.writeFileSync(configFile, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, login: 'destination', token: forgeToken, })); fs.writeFileSync(manifestFile, JSON.stringify([ { name: 'missing-token', from: 'https://github.com/source/first.git', service: 'github' }, { name: 'imported-second', from: 'https://git.example/source/second.git', service: 'git' }, ])); try { const result = await run( ['--config', configFile, 'repo', 'import-batch', '--file', manifestFile], { PATH: emptyPath, GITHUB_TOKEN: undefined }, ); assert.equal(result.status, 1); assert.match(result.stderr, /Failed to import missing-token: No GitHub token found\./); assert.equal(result.stdout, 'Imported: destination/imported-second -> https://forge.test/destination/imported-second\n' + '\nBatch complete: 1/2 imported.\n'); assert.deepEqual(requests, [{ method: 'POST', url: '/api/v1/repos/migrate', body: { clone_addr: 'https://git.example/source/second.git', repo_name: 'imported-second', repo_owner: 'destination', service: 'git', private: false, issues: true, labels: true, milestones: true, pull_requests: true, releases: true, wiki: true, lfs: false, }, }]); assert.doesNotMatch(result.stdout + result.stderr, new RegExp(forgeToken)); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); } }); test('repo import-batch preserves successful batch output', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-success-')); const configFile = path.join(dir, 'config.json'); const manifestFile = path.join(dir, 'manifest.json'); const { server, requests } = await startMigrationServer(); fs.writeFileSync(configFile, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, login: 'destination', token: 'forge-token-must-not-be-printed', })); fs.writeFileSync(manifestFile, JSON.stringify([ { name: 'first', from: 'https://git.example/source/first.git', service: 'git' }, { name: 'second', from: 'https://git.example/source/second.git', service: 'git' }, ])); try { const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ''); assert.equal(result.stdout, 'Imported: destination/first -> https://forge.test/destination/first\n' + 'Imported: destination/second -> https://forge.test/destination/second\n' + '\nBatch complete: 2/2 imported.\n'); assert.deepEqual(requests.map(({ body }) => body.repo_name), ['first', 'second']); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); } }); test('repo import-batch sends an explicit GitHub token without printing it', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-token-')); const configFile = path.join(dir, 'config.json'); const manifestFile = path.join(dir, 'manifest.json'); const emptyPath = path.join(dir, 'bin'); const sourceToken = 'github-token-must-not-be-printed'; const { server, requests } = await startMigrationServer(); fs.mkdirSync(emptyPath); fs.writeFileSync(configFile, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, login: 'destination', token: 'forge-token-must-not-be-printed', })); fs.writeFileSync(manifestFile, JSON.stringify([{ name: 'from-github', from: 'https://github.com/source/repository.git', service: 'github', github_token: sourceToken, }])); try { const result = await run( ['--config', configFile, 'repo', 'import-batch', '--file', manifestFile], { PATH: emptyPath, GITHUB_TOKEN: undefined }, ); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, ''); assert.equal(result.stdout, 'Imported: destination/from-github -> https://forge.test/destination/from-github\n' + '\nBatch complete: 1/1 imported.\n'); assert.deepEqual(requests, [{ method: 'POST', url: '/api/v1/repos/migrate', body: { clone_addr: 'https://github.com/source/repository.git', repo_name: 'from-github', repo_owner: 'destination', service: 'github', private: false, issues: true, labels: true, milestones: true, pull_requests: true, releases: true, wiki: true, lfs: false, auth_token: sourceToken, }, }]); assert.doesNotMatch(result.stdout + result.stderr, new RegExp(sourceToken)); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); } }); test('repo import-batch keeps file and JSON errors at batch level', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-invalid-')); const configFile = path.join(dir, 'config.json'); const missingFile = path.join(dir, 'missing.json'); const malformedFile = path.join(dir, 'malformed.json'); fs.writeFileSync(configFile, JSON.stringify({ url: 'https://forge.test', login: 'destination', token: 'forge-token-must-not-be-printed', })); fs.writeFileSync(malformedFile, '{not json'); try { const missing = await run(['--config', configFile, 'repo', 'import-batch', '--file', missingFile]); const malformed = await run(['--config', configFile, 'repo', 'import-batch', '--file', malformedFile]); assert.equal(missing.status, 1); assert.match(missing.stderr, /^Batch import failed: ENOENT:/); assert.equal(missing.stdout, ''); assert.equal(malformed.status, 1); assert.match(malformed.stderr, /^Batch import failed: /); assert.match(malformed.stderr, /JSON/); assert.equal(malformed.stdout, ''); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('repo import-batch excludes skipped invalid entries from the summary', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-import-batch-skip-')); const configFile = path.join(dir, 'config.json'); const manifestFile = path.join(dir, 'manifest.json'); const { server, requests } = await startMigrationServer(); fs.writeFileSync(configFile, JSON.stringify({ url: `http://127.0.0.1:${server.address().port}`, login: 'destination', token: 'forge-token-must-not-be-printed', })); fs.writeFileSync(manifestFile, JSON.stringify([ { name: 'missing-source' }, { name: 'valid', from: 'https://git.example/source/valid.git', service: 'git' }, ])); try { const result = await run(['--config', configFile, 'repo', 'import-batch', '--file', manifestFile]); assert.equal(result.status, 0, result.stderr); assert.equal(result.stderr, 'Skipping invalid manifest entry: {"name":"missing-source"}\n'); assert.match(result.stdout, /Batch complete: 1\/1 imported\./); assert.deepEqual(requests.map(({ body }) => body.repo_name), ['valid']); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); } });