162 lines
5.3 KiB
JavaScript
162 lines
5.3 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { EXIT, BmadModuleError } from './exit.mjs';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
|
|
// Shared clone cache for community modules — mirrors
|
|
// tools/installer/modules/custom-module-manager.js (getCacheDir + cloneRepo) so
|
|
// a skill-driven install reuses the same on-disk cache the CLI installer
|
|
// maintains. node:-only (execFile, not execSync+fs-extra); npm deps are NOT
|
|
// installed here — the skill installs them in _bmad/<code>/ after the copy.
|
|
|
|
export function getCacheDir() {
|
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
|
}
|
|
|
|
// A ref must be a tag/branch name git can take as a positional argument. Reject
|
|
// option-like values so a crafted `--upload-pack=…` ref can't reach git.
|
|
function assertSafeRef(ref) {
|
|
if (!/^[\w.+/][\w.\-+/]*$/.test(ref)) {
|
|
throw new BmadModuleError(EXIT.USAGE, `unsafe git ref: ${ref}`);
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
async function pathExists(p) {
|
|
try {
|
|
await fs.access(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJsonSafe(p) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(p, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function git(args, cwd) {
|
|
return execFileP('git', args, { cwd, env: GIT_ENV, timeout: 120_000 });
|
|
}
|
|
|
|
async function revParseHead(cwd) {
|
|
try {
|
|
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd });
|
|
return stdout.trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function defaultBranch(cwd) {
|
|
try {
|
|
const { stdout } = await execFileP('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd });
|
|
return stdout.trim().replace(/^origin\//, '') || 'main';
|
|
} catch {
|
|
return 'main';
|
|
}
|
|
}
|
|
|
|
// Ensure the repo behind `descriptor` is cloned/refreshed in the shared cache at
|
|
// the requested `ref` (a tag/branch, or null for the default branch). Returns
|
|
// { repoDir, sha, ref }. Reuses an existing clone when its recorded version
|
|
// matches; re-clones on a version change; on a fetch failure against an existing
|
|
// clone, keeps the stale copy and warns (so installs work offline).
|
|
export async function ensureCachedRepo(descriptor, ref = null) {
|
|
if (descriptor.kind !== 'git') throw new BmadModuleError(EXIT.USAGE, `ensureCachedRepo requires a git source`);
|
|
if (!descriptor.cacheKey) throw new BmadModuleError(EXIT.USAGE, `git source has no cacheKey: ${descriptor.rawInput}`);
|
|
const effectiveRef = ref;
|
|
if (effectiveRef) assertSafeRef(effectiveRef);
|
|
|
|
const repoDir = path.join(getCacheDir(), ...descriptor.cacheKey.split('/'));
|
|
await fs.mkdir(path.dirname(repoDir), { recursive: true });
|
|
|
|
// Existing cache at a different version → re-clone from scratch.
|
|
if (await pathExists(repoDir)) {
|
|
const meta = await readJsonSafe(path.join(repoDir, '.bmad-source.json'));
|
|
const cachedVersion = meta?.version || null;
|
|
if (effectiveRef !== cachedVersion) {
|
|
await fs.rm(repoDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (await pathExists(repoDir)) {
|
|
// Refresh the existing clone (same version as before).
|
|
try {
|
|
await git(['fetch', 'origin', '--depth', '1'], repoDir);
|
|
if (effectiveRef) {
|
|
await git(['fetch', '--depth', '1', 'origin', effectiveRef, '--no-tags'], repoDir);
|
|
await git(['checkout', '--quiet', 'FETCH_HEAD'], repoDir);
|
|
} else {
|
|
const branch = await defaultBranch(repoDir);
|
|
assertSafeRef(branch);
|
|
await git(['fetch', '--depth', '1', 'origin', branch], repoDir);
|
|
await git(['reset', '--hard', `origin/${branch}`], repoDir);
|
|
}
|
|
} catch (e) {
|
|
// Remote unreachable — keep the cached copy so the install still works.
|
|
process.stderr.write(
|
|
`[bmad-module] warning: could not refresh ${descriptor.displayName} (${e.stderr || e.message}). Using cached copy.\n`,
|
|
);
|
|
}
|
|
} else {
|
|
// Fresh clone.
|
|
const args = ['clone', '--depth', '1'];
|
|
if (effectiveRef) args.push('--branch', effectiveRef);
|
|
args.push(descriptor.url, repoDir);
|
|
try {
|
|
await git(args);
|
|
} catch (e) {
|
|
await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {});
|
|
const refSuffix = effectiveRef ? `@${effectiveRef}` : '';
|
|
throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed for ${descriptor.url}${refSuffix}: ${e.stderr || e.message}`);
|
|
}
|
|
}
|
|
|
|
const sha = await revParseHead(repoDir);
|
|
const branchForMeta = effectiveRef ? null : await defaultBranch(repoDir);
|
|
const now = new Date().toISOString();
|
|
await fs.writeFile(
|
|
path.join(repoDir, '.bmad-source.json'),
|
|
JSON.stringify(
|
|
{
|
|
cloneUrl: descriptor.url,
|
|
cacheKey: descriptor.cacheKey,
|
|
displayName: descriptor.displayName,
|
|
version: effectiveRef || null,
|
|
rawInput: descriptor.rawInput,
|
|
sha,
|
|
clonedAt: now,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(repoDir, '.bmad-channel.json'),
|
|
JSON.stringify(
|
|
{
|
|
channel: effectiveRef ? 'pinned' : 'next',
|
|
version: effectiveRef || branchForMeta || 'main',
|
|
sha,
|
|
writtenAt: now,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
);
|
|
|
|
return { repoDir, sha, ref: effectiveRef };
|
|
}
|