feat(skills): add dry-run installer planning surface
This commit is contained in:
@@ -10,6 +10,8 @@ export type {
|
||||
DownloadInstallerSpec,
|
||||
} from './types.js';
|
||||
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
||||
export { buildInstallerPlan } from './planner.js';
|
||||
export type { InstallerPlan, InstallerPlanStep, InstallerPlanSkip, InstallerPlanningOptions } from './planner.js';
|
||||
export { SkillRegistry } from './registry.js';
|
||||
export { SkillInstaller } from './installer.js';
|
||||
export { SkillsWatcher } from './watcher.js';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildInstallerPlan } from './planner.js';
|
||||
|
||||
describe('buildInstallerPlan', () => {
|
||||
it('plans brew and node installers when required binaries are present', () => {
|
||||
const plan = buildInstallerPlan(
|
||||
[
|
||||
{ type: 'brew', packages: ['jq'] },
|
||||
{ type: 'node', packages: ['typescript'] },
|
||||
],
|
||||
{
|
||||
hasBinary: (name) => name === 'brew' || name === 'pnpm',
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.mode).toBe('dry-run');
|
||||
expect(plan.steps).toEqual([
|
||||
{ installerType: 'brew', command: 'brew install jq' },
|
||||
{ installerType: 'node', command: 'pnpm add -g typescript' },
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips installers when required package managers are missing', () => {
|
||||
const plan = buildInstallerPlan(
|
||||
[
|
||||
{ type: 'brew', packages: ['wget'] },
|
||||
{ type: 'node', packages: ['tsx'] },
|
||||
{ type: 'go', packages: ['golang.org/x/tools/cmd/stringer'] },
|
||||
],
|
||||
{
|
||||
hasBinary: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.steps).toEqual([]);
|
||||
expect(plan.skipped).toEqual([
|
||||
{ installerType: 'brew', reason: 'brew not available in PATH' },
|
||||
{ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' },
|
||||
{ installerType: 'go', reason: 'go not available in PATH' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands go packages and includes download installers', () => {
|
||||
const plan = buildInstallerPlan(
|
||||
[
|
||||
{ type: 'go', packages: ['example.com/tool/a', 'example.com/tool/b'] },
|
||||
{ type: 'download', url: 'https://example.com/tool.tgz', destination: '/tmp/tool.tgz' },
|
||||
],
|
||||
{
|
||||
hasBinary: (name) => name === 'go',
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.steps).toEqual([
|
||||
{ installerType: 'go', command: 'go install example.com/tool/a@latest' },
|
||||
{ installerType: 'go', command: 'go install example.com/tool/b@latest' },
|
||||
{ installerType: 'download', command: 'download https://example.com/tool.tgz -> /tmp/tool.tgz' },
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { execSync } from 'child_process';
|
||||
import type { SkillInstallerSpec } from './types.js';
|
||||
|
||||
export interface InstallerPlanStep {
|
||||
installerType: SkillInstallerSpec['type'];
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface InstallerPlanSkip {
|
||||
installerType: SkillInstallerSpec['type'];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface InstallerPlan {
|
||||
mode: 'dry-run';
|
||||
steps: InstallerPlanStep[];
|
||||
skipped: InstallerPlanSkip[];
|
||||
}
|
||||
|
||||
export interface InstallerPlanningOptions {
|
||||
hasBinary?: (name: string) => boolean;
|
||||
}
|
||||
|
||||
function hasBinaryOnPath(name: string): boolean {
|
||||
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
try {
|
||||
execSync(`${cmd} ${name}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInstallerPlan(
|
||||
installers: SkillInstallerSpec[] | undefined,
|
||||
opts: InstallerPlanningOptions = {},
|
||||
): InstallerPlan {
|
||||
const hasBinary = opts.hasBinary ?? hasBinaryOnPath;
|
||||
const steps: InstallerPlanStep[] = [];
|
||||
const skipped: InstallerPlanSkip[] = [];
|
||||
|
||||
for (const installer of installers ?? []) {
|
||||
if (installer.type === 'brew') {
|
||||
if (!hasBinary('brew')) {
|
||||
skipped.push({ installerType: 'brew', reason: 'brew not available in PATH' });
|
||||
continue;
|
||||
}
|
||||
steps.push({ installerType: 'brew', command: `brew install ${installer.packages.join(' ')}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installer.type === 'node') {
|
||||
if (hasBinary('pnpm')) {
|
||||
steps.push({ installerType: 'node', command: `pnpm add -g ${installer.packages.join(' ')}` });
|
||||
continue;
|
||||
}
|
||||
if (hasBinary('npm')) {
|
||||
steps.push({ installerType: 'node', command: `npm install -g ${installer.packages.join(' ')}` });
|
||||
continue;
|
||||
}
|
||||
skipped.push({ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installer.type === 'go') {
|
||||
if (!hasBinary('go')) {
|
||||
skipped.push({ installerType: 'go', reason: 'go not available in PATH' });
|
||||
continue;
|
||||
}
|
||||
for (const pkg of installer.packages) {
|
||||
steps.push({ installerType: 'go', command: `go install ${pkg}@latest` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const destination = installer.destination ?? '<default destination>';
|
||||
steps.push({
|
||||
installerType: 'download',
|
||||
command: `download ${installer.url} -> ${destination}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'dry-run',
|
||||
steps,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user