mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Add a first-class OpenCode command bridge across builds, installs, updates, linked installs, and pinned shortcuts. Preserve current provider behavior while backfilling missing or drifted command files.\n\nAI assistance: contributor and maintainer work used AI tools as disclosed in the PR discussion and commits.
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
/**
|
|
* Tests for copyProviderCommands. Mirrors the PR #417 migration guards for the
|
|
* skills path, applied to <provider>/commands. OpenCode discovers custom
|
|
* commands from {command,commands}/**.md in the active config dir, so a
|
|
* global install must target $OPENCODE_CONFIG_DIR/commands, $XDG_CONFIG_HOME/
|
|
* opencode/commands, or ~/.config/opencode/commands (in that order), never
|
|
* ~/.opencode/commands which OpenCode does not scan.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
existsSync,
|
|
symlinkSync,
|
|
rmSync,
|
|
realpathSync,
|
|
lstatSync,
|
|
} from 'fs';
|
|
import { tmpdir } from 'os';
|
|
|
|
import {
|
|
copyProviderCommands,
|
|
isUpToDate,
|
|
opencodeGlobalConfigDir,
|
|
} from '../cli/bin/commands/skills.mjs';
|
|
|
|
function setupBundleWithCommand(bundleDir, providerName, commandNames) {
|
|
mkdirSync(path.join(bundleDir, providerName, 'commands'), { recursive: true });
|
|
for (const name of commandNames) {
|
|
const file = path.join(bundleDir, providerName, 'commands', `${name}.md`);
|
|
writeFileSync(
|
|
file,
|
|
`description: Impeccable ${name} bridge\nagent: build\nsubtask: true\n\nbody ${name}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env.IMPECCABLE_BUNDLE_PATH = '';
|
|
delete process.env.OPENCODE_CONFIG_DIR;
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.OPENCODE_CONFIG_DIR;
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
});
|
|
|
|
describe('copyProviderCommands', () => {
|
|
test('writes commands to project .opencode/commands by default', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
try {
|
|
const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' });
|
|
expect(written).toBe(1);
|
|
const dest = path.join(project, '.opencode', 'commands', 'impeccable.md');
|
|
expect(existsSync(dest)).toBe(true);
|
|
expect(readFileSync(dest, 'utf8')).toContain('impeccable bridge');
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writes commands to ~/.config/opencode/commands for global scope', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
try {
|
|
const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' });
|
|
expect(written).toBe(1);
|
|
const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md');
|
|
expect(existsSync(dest)).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('honours OPENCODE_CONFIG_DIR for global scope', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
const customDir = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
try {
|
|
process.env.OPENCODE_CONFIG_DIR = customDir;
|
|
const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' });
|
|
expect(written).toBe(1);
|
|
const dest = path.join(customDir, 'commands', 'impeccable.md');
|
|
expect(existsSync(dest)).toBe(true);
|
|
expect(existsSync(path.join(home, '.config', 'opencode', 'commands'))).toBe(false);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
rmSync(customDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('honours XDG_CONFIG_HOME/opencode/commands when OPENCODE_CONFIG_DIR is unset', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
const xdgRoot = mkdtempSync(path.join(tmpdir(), 'imp-cmd-xdg-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
try {
|
|
process.env.XDG_CONFIG_HOME = xdgRoot;
|
|
const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' });
|
|
expect(written).toBe(1);
|
|
const dest = path.join(xdgRoot, 'opencode', 'commands', 'impeccable.md');
|
|
expect(existsSync(dest)).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
rmSync(xdgRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('migrates legacy ~/.opencode/commands entries without disturbing siblings', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
// Pre-seed a legacy copy with both a command we want to replace and a
|
|
// sibling the install must NOT touch.
|
|
const legacyDir = path.join(home, '.opencode', 'commands');
|
|
mkdirSync(legacyDir, { recursive: true });
|
|
writeFileSync(path.join(legacyDir, 'impeccable.md'), 'stale impeccable\n');
|
|
writeFileSync(path.join(legacyDir, 'unrelated-command.md'), 'unrelated\n');
|
|
try {
|
|
const written = copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' });
|
|
expect(written).toBe(1);
|
|
const dest = path.join(home, '.config', 'opencode', 'commands', 'impeccable.md');
|
|
expect(existsSync(dest)).toBe(true);
|
|
expect(existsSync(path.join(legacyDir, 'impeccable.md'))).toBe(false);
|
|
expect(existsSync(path.join(legacyDir, 'unrelated-command.md'))).toBe(true);
|
|
expect(readFileSync(path.join(legacyDir, 'unrelated-command.md'), 'utf8')).toBe('unrelated\n');
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('does not migrate a symlinked legacy dir (shared storage)', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
const shared = mkdtempSync(path.join(tmpdir(), 'imp-cmd-shared-'));
|
|
setupBundleWithCommand(bundle, '.opencode', ['impeccable']);
|
|
mkdirSync(path.join(home, '.opencode'), { recursive: true });
|
|
symlinkSync(shared, path.join(home, '.opencode', 'commands'), 'dir');
|
|
writeFileSync(path.join(shared, 'unrelated-command.md'), 'unrelated\n');
|
|
try {
|
|
copyProviderCommands(bundle, home, ['opencode'], { scope: 'user' });
|
|
expect(existsSync(path.join(shared, 'unrelated-command.md'))).toBe(true);
|
|
expect(lstatSync(path.join(home, '.opencode', 'commands')).isSymbolicLink()).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
rmSync(shared, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('returns 0 when the bundle has no commands dir', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
try {
|
|
const written = copyProviderCommands(bundle, project, ['opencode'], { scope: 'project' });
|
|
expect(written).toBe(0);
|
|
expect(existsSync(path.join(project, '.opencode', 'commands'))).toBe(false);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('ignores providers without a commands directory', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
mkdirSync(path.join(bundle, 'claude'), { recursive: true });
|
|
try {
|
|
const written = copyProviderCommands(bundle, project, ['claude'], { scope: 'project' });
|
|
expect(written).toBe(0);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('isUpToDate command awareness', () => {
|
|
function setupBundleWithSkill(bundleDir, providerName, { withCommands = true } = {}) {
|
|
const skillDir = path.join(bundleDir, providerName, 'skills', 'impeccable');
|
|
mkdirSync(path.join(skillDir, 'scripts'), { recursive: true });
|
|
writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nBundle skill.\n');
|
|
writeFileSync(path.join(skillDir, 'scripts', 'context.mjs'), 'console.log("bundle");\n');
|
|
if (withCommands) setupBundleWithCommand(bundleDir, providerName, ['impeccable']);
|
|
}
|
|
|
|
function mirrorBundleSkills(bundleDir, root, providerName) {
|
|
fs.cpSync(
|
|
path.join(bundleDir, providerName, 'skills'),
|
|
path.join(root, providerName, 'skills'),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
|
|
function mirrorBundleCommands(bundleDir, root, providerName) {
|
|
fs.cpSync(
|
|
path.join(bundleDir, providerName, 'commands'),
|
|
path.join(root, providerName, 'commands'),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
|
|
test('returns false when skills match but the command bridge is missing', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithSkill(bundle, '.opencode');
|
|
mirrorBundleSkills(bundle, project, '.opencode');
|
|
try {
|
|
expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('returns true when skills and commands match the bundle', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithSkill(bundle, '.opencode');
|
|
mirrorBundleSkills(bundle, project, '.opencode');
|
|
mirrorBundleCommands(bundle, project, '.opencode');
|
|
try {
|
|
expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('returns false when the command bridge content drifted', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithSkill(bundle, '.opencode');
|
|
mirrorBundleSkills(bundle, project, '.opencode');
|
|
mirrorBundleCommands(bundle, project, '.opencode');
|
|
writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable.md'), 'user edit drift\n');
|
|
try {
|
|
expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(false);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('ignores local-only command files such as pinned shortcuts', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithSkill(bundle, '.opencode');
|
|
mirrorBundleSkills(bundle, project, '.opencode');
|
|
mirrorBundleCommands(bundle, project, '.opencode');
|
|
writeFileSync(path.join(project, '.opencode', 'commands', 'impeccable-audit.md'), 'pinned\n');
|
|
try {
|
|
expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('ignores providers whose bundle has no commands directory', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const project = mkdtempSync(path.join(tmpdir(), 'imp-cmd-proj-'));
|
|
setupBundleWithSkill(bundle, '.opencode', { withCommands: false });
|
|
mirrorBundleSkills(bundle, project, '.opencode');
|
|
try {
|
|
expect(isUpToDate(project, ['.opencode'], bundle, 'project')).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(project, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('user scope resolves the commands dir via OPENCODE_CONFIG_DIR', () => {
|
|
const bundle = mkdtempSync(path.join(tmpdir(), 'imp-cmd-bundle-'));
|
|
const home = mkdtempSync(path.join(tmpdir(), 'imp-cmd-home-'));
|
|
const custom = mkdtempSync(path.join(tmpdir(), 'imp-cmd-custom-'));
|
|
setupBundleWithSkill(bundle, '.opencode');
|
|
process.env.OPENCODE_CONFIG_DIR = custom;
|
|
// User-scope OpenCode skills live at <config>/skills (HOME_SKILLS_DIR_OVERRIDES).
|
|
fs.cpSync(path.join(bundle, '.opencode', 'skills'), path.join(custom, 'skills'), { recursive: true });
|
|
try {
|
|
expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(false);
|
|
fs.cpSync(path.join(bundle, '.opencode', 'commands'), path.join(custom, 'commands'), { recursive: true });
|
|
expect(isUpToDate(home, ['.opencode'], bundle, 'user')).toBe(true);
|
|
} finally {
|
|
rmSync(bundle, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
rmSync(custom, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|