diff --git a/COMMANDS.md b/COMMANDS.md index 43677af..7d3af90 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -38,7 +38,7 @@ verify system environment and project configuration. vit doctor ``` -checks Node.js version, git availability, login status, and (if in a repo) `.vit/` configuration. also available as `vit status`. +checks Node.js version, git availability, login status, and (if in a repo) `.vit/` configuration. also available as `vit status` and `vit setup`. --- diff --git a/skills/vit/COMMANDS.md b/skills/vit/COMMANDS.md index 0d2d396..24bae97 100644 --- a/skills/vit/COMMANDS.md +++ b/skills/vit/COMMANDS.md @@ -57,7 +57,7 @@ Options: - None. Output format: -- Text diagnostics for install and beacon status. +- Text diagnostics for install and beacon status; self-installs the using-vit skill into `~/.claude/skills` and `~/.agents/skills` if missing. Error conditions: - Config read/parse failures. diff --git a/skills/vit/SKILL.md b/skills/vit/SKILL.md index e14b4c2..5853f28 100644 --- a/skills/vit/SKILL.md +++ b/skills/vit/SKILL.md @@ -67,7 +67,7 @@ Handoffs: ### Agent-usable commands ### `vit doctor` -- Description: Read-only diagnostic for install and beacon status. +- Description: Verify install and beacon status, and self-install the using-vit skill into `~/.claude/skills` and `~/.agents/skills` if missing. - Usage: `vit doctor` - Key flags: none. - Output: text status lines for install and beacon. diff --git a/src/cli.js b/src/cli.js index be8be15..b5efda7 100644 --- a/src/cli.js +++ b/src/cli.js @@ -2,6 +2,7 @@ // Copyright (c) 2026 sol pbc import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; import { brand } from './lib/brand.js'; import registerAdopt from './cmd/adopt.js'; import registerBeacon from './cmd/beacon.js'; @@ -23,11 +24,13 @@ import registerHack from './cmd/hack.js'; import registerLink from './cmd/link.js'; import registerInbox from './cmd/inbox.js'; +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); + const program = new Command(); program .name('vit') .description(`${brand} \u2014 social open source`) - .version('0.1.0'); + .version(pkg.version); registerAdopt(program); registerBeacon(program); diff --git a/src/cmd/doctor.js b/src/cmd/doctor.js index 96d89ae..a6e3b0b 100644 --- a/src/cmd/doctor.js +++ b/src/cmd/doctor.js @@ -12,6 +12,9 @@ import { which } from '../lib/compat.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { configPath } from '../lib/paths.js'; import { errorMessage, formatError } from '../lib/error-format.js'; +import { ensureSkill, skillInstallReason } from '../lib/skill-install.js'; + +const USING_VIT_SKILL = 'using-vit'; function scanSkillDir(dir) { const skills = []; @@ -42,6 +45,18 @@ function scanSkillDir(dir) { return skills; } +function formatSkillLine(skillResult) { + if (skillResult.ok) { + const paths = skillResult.results.map(result => result.path).join(', '); + if (skillResult.results.every(result => result.status === 'already-present')) { + return `${mark} skill: ok (${USING_VIT_SKILL})`; + } + return `${mark} skill: installed (${USING_VIT_SKILL}) → ${paths}`; + } + + return `${mark} skill: install failed — ${skillInstallReason(skillResult)}; check write permissions on ~/.claude and ~/.agents, or reinstall vit`; +} + export default function register(program) { async function checkHealth(opts) { try { @@ -86,19 +101,23 @@ export default function register(program) { if (!opts.json) console.log(`${mark} beacon: not set (run vit init)`); } - const projectSkillPath = join(process.cwd(), '.claude', 'skills', 'using-vit', 'SKILL.md'); - const userSkillPath = join(homedir(), '.claude', 'skills', 'using-vit', 'SKILL.md'); - skillInstalled = existsSync(projectSkillPath) || existsSync(userSkillPath); - if (skillInstalled) { - if (!opts.json) console.log(`${mark} skill: ok (using-vit)`); + const skillResult = ensureSkill(); + const home = homedir(); + const projectClaude = join(process.cwd(), '.claude', 'skills', USING_VIT_SKILL, 'SKILL.md'); + const userClaude = join(home, '.claude', 'skills', USING_VIT_SKILL, 'SKILL.md'); + const userAgents = join(home, '.agents', 'skills', USING_VIT_SKILL, 'SKILL.md'); + skillInstalled = existsSync(projectClaude) || existsSync(userClaude) || existsSync(userAgents); + const skillLine = formatSkillLine(skillResult); + if (opts.json) { + console.error(skillLine); } else { - if (!opts.json) console.log(`${mark} skill: not installed (reinstall vit)`); + console.log(skillLine); } // Report installed skills const projectSkillDir = join(process.cwd(), '.claude', 'skills'); projectSkills = scanSkillDir(projectSkillDir); - const userSkillDir = join(homedir(), '.claude', 'skills'); + const userSkillDir = join(home, '.claude', 'skills'); userSkills = scanSkillDir(userSkillDir); if (!opts.json && projectSkills.length > 0) { @@ -169,6 +188,7 @@ export default function register(program) { install: { type: installType, path: installPath }, beacon, skill: skillInstalled, + skillInstall: { ok: skillResult.ok, results: skillResult.results }, projectSkills, userSkills, bluesky: { ok: blueskyOk, did: effectiveDid || null, pds, source: identitySource, authType }, @@ -192,4 +212,8 @@ export default function register(program) { .description('Alias for doctor') .option('--json', 'Output as JSON') .action(checkHealth); + program.command('setup') + .description('Alias for doctor') + .option('--json', 'Output as JSON') + .action(checkHealth); } diff --git a/src/cmd/login.js b/src/cmd/login.js index f85f380..f02ab14 100644 --- a/src/cmd/login.js +++ b/src/cmd/login.js @@ -12,6 +12,8 @@ import { createOAuthClient, createSessionStore, createStore, checkSession } from import { configDir, configPath } from '../lib/paths.js'; import { vitDir } from '../lib/vit-dir.js'; import { errorMessage, formatError } from '../lib/error-format.js'; +import { mark } from '../lib/brand.js'; +import { ensureSkill, skillInstallReason } from '../lib/skill-install.js'; export const LOGIN_COMMON_ISSUES_FOOTER = `Common issues: - make sure the handle is correct and resolves on Bluesky @@ -65,6 +67,15 @@ export function printLoginFailure(err, { verbose = false, includeFooter = false } } +function printSkillReadiness(skillResult) { + if (skillResult.ok) { + console.log(`${mark} skill: ready (using-vit)`); + return; + } + + console.log(`${mark} skill: install failed — ${skillInstallReason(skillResult)}; check write permissions on ~/.claude and ~/.agents, or reinstall vit`); +} + export default function register(program) { program .command('login') @@ -80,6 +91,8 @@ export default function register(program) { const { verbose, force, remote, browser, appPassword, local: localLogin } = opts; const isRemote = remote || !!(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT); handle = handle.replace(/^@/, ''); + const skillResult = ensureSkill(); + printSkillReadiness(skillResult); if (localLogin) { const dir = vitDir(); diff --git a/src/lib/skill-install.js b/src/lib/skill-install.js new file mode 100644 index 0000000..c7c31f3 --- /dev/null +++ b/src/lib/skill-install.js @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_SKILL_NAME = 'using-vit'; +const SOURCE_MISSING_ERROR = 'source skill files missing or empty'; + +function defaultSourceDir() { + return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'vit'); +} + +function errorMessage(err) { + if (err instanceof Error) return err.message || String(err); + return String(err); +} + +function readSkillName(sourceDir) { + try { + const content = readFileSync(join(sourceDir, 'SKILL.md'), 'utf-8'); + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return DEFAULT_SKILL_NAME; + const nameMatch = match[1].match(/^name:\s*(.+)$/m); + return nameMatch ? nameMatch[1].trim() : DEFAULT_SKILL_NAME; + } catch { + return DEFAULT_SKILL_NAME; + } +} + +function sourceExists(sourceDir) { + try { + return existsSync(sourceDir) && readdirSync(sourceDir).length > 0; + } catch { + return false; + } +} + +export function skillInstallReason(skillResult) { + if (skillResult.results.some(result => result.status === 'source-missing')) { + return 'source skill files missing'; + } + + const failed = skillResult.results.filter(result => result.status === 'failed'); + if (failed.length > 0) { + return failed + .map(result => `${result.label} failed: ${result.error || 'unknown error'}`) + .join('; '); + } + + return 'skill install failed'; +} + +// sourceDir exists only as a test seam; production callers pass nothing. +export function ensureSkill({ sourceDir = defaultSourceDir() } = {}) { + const name = readSkillName(sourceDir); + const home = homedir(); + const targets = [ + { label: 'claude', path: join(home, '.claude', 'skills', name) }, + { label: 'agents', path: join(home, '.agents', 'skills', name) }, + ]; + + if (!sourceExists(sourceDir)) { + return { + name, + source: null, + ok: false, + results: targets.map(target => ({ + ...target, + status: 'source-missing', + error: SOURCE_MISSING_ERROR, + })), + }; + } + + const results = targets.map(target => { + try { + const existed = existsSync(join(target.path, 'SKILL.md')); + mkdirSync(target.path, { recursive: true }); + cpSync(sourceDir, target.path, { recursive: true, force: true }); + return { + ...target, + status: existed ? 'already-present' : 'created', + error: null, + }; + } catch (err) { + return { + ...target, + status: 'failed', + error: errorMessage(err), + }; + } + }); + + return { + name, + source: sourceDir, + ok: results.every(result => result.status === 'created' || result.status === 'already-present'), + results, + }; +} diff --git a/src/postinstall.js b/src/postinstall.js index f827b03..2755049 100644 --- a/src/postinstall.js +++ b/src/postinstall.js @@ -1,27 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 sol pbc -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { ensureSkill } from './lib/skill-install.js'; try { - const currentFile = fileURLToPath(import.meta.url); - const currentDir = dirname(currentFile); - const skillDir = join(currentDir, '..', 'skills', 'vit'); - const result = spawnSync( - 'npx', - ['--yes', 'skills', 'add', skillDir, '-g', '-a', 'claude-code', '-y'], - { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, CI: 'true' }, - } - ); - - if (result.status === 0) { - console.log('vit: skill installed (using-vit)'); - } + const r = ensureSkill(); + if (r.ok) console.log('vit: using-vit skill installed'); } catch { - process.exit(0); } + +process.exit(0); diff --git a/test/doctor.test.js b/test/doctor.test.js index 8879506..c29d6b8 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -3,7 +3,7 @@ import { describe, test, expect, afterEach } from 'bun:test'; import { run } from './helpers.js'; -import { mkdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -44,4 +44,51 @@ describe('vit doctor', () => { const result = run('status', undefined, doctorEnv()); expect(result.stdout).toMatch(/install:/); }); + + test('vit setup is byte-identical to vit doctor', () => { + const env = doctorEnv(); + run('doctor', undefined, env); + + const doctor = run('doctor', undefined, env); + const setup = run('setup', undefined, env); + + expect(setup.stdout).toBe(doctor.stdout); + }); + + test('doctor self-installs using-vit skill when absent', () => { + const env = doctorEnv(); + const result = run('doctor', undefined, env); + + expect(result.stdout).toMatch(/skill: installed|skill: ok/); + expect(result.stdout).not.toMatch(/reinstall vit/); + expect(result.stdout).not.toMatch(/not installed/); + expect(existsSync(join(tmpHome, '.claude', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + expect(existsSync(join(tmpHome, '.agents', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + }); + + test('doctor JSON detects skill present only under agents', () => { + const env = doctorEnv(); + const agentsSkill = join(tmpHome, '.agents', 'skills', 'using-vit'); + mkdirSync(agentsSkill, { recursive: true }); + writeFileSync(join(agentsSkill, 'SKILL.md'), '---\nname: using-vit\n---\n'); + writeFileSync(join(tmpHome, '.claude'), 'not a directory\n'); + + const result = run('doctor --json', undefined, env); + const parsed = JSON.parse(result.stdout); + + expect(parsed.skill).toBe(true); + expect(parsed.skillInstall.ok).toBe(false); + }); + + test('doctor JSON keeps stdout pure and reports skillInstall', () => { + const result = run('doctor --json', undefined, doctorEnv()); + const parsed = JSON.parse(result.stdout); + + expect(parsed.skill).toBe(true); + for (const key of ['install', 'beacon', 'skill', 'projectSkills', 'userSkills', 'bluesky']) { + expect(parsed).toHaveProperty(key); + } + expect(parsed.skillInstall.ok).toBe(true); + expect(Array.isArray(parsed.skillInstall.results)).toBe(true); + }); }); diff --git a/test/login-success.test.js b/test/login-success.test.js new file mode 100644 index 0000000..0810895 --- /dev/null +++ b/test/login-success.test.js @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'; +import { Command } from 'commander'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import * as os from 'node:os'; +import { join } from 'node:path'; + +let tmpHome; +let oldHome; +let oldXdgConfigHome; +let registerLogin; +let currentHome; +let savedConfig = {}; +const realHomedir = os.homedir; +const realTmpdir = os.tmpdir; +const fallbackConfigHome = join(realTmpdir(), '.test-login-config-' + process.pid); + +mock.module('node:os', () => ({ + ...os, + homedir: () => currentHome || realHomedir(), +})); + +mock.module('@atproto/api', () => ({ + AtpAgent: class { + async login({ identifier }) { + if (globalThis.__vitLoginShouldReject) throw new Error('mock login failed'); + return { + data: { + did: 'did:plc:test', + handle: identifier, + accessJwt: 'a', + refreshJwt: 'r', + }, + }; + } + }, + Agent: class {}, +})); + +mock.module('../src/lib/config.js', () => ({ + loadConfig: () => ({ ...savedConfig }), + saveConfig: (config) => { + savedConfig = { ...config }; + globalThis.__vitSavedConfig = { ...config }; + }, +})); + +mock.module('../src/lib/paths.js', () => ({ + configDir: join(fallbackConfigHome, 'vit'), + configPath: (filename) => join(fallbackConfigHome, 'vit', filename), +})); + +mock.module('../src/lib/oauth.js', () => ({ + checkSession: () => null, + createOAuthClient: () => { + throw new Error('OAuth path should not run in login success tests'); + }, + createSessionStore: () => ({}), + createStore: () => ({}), +})); + +async function runLogin(args) { + const program = new Command(); + program.exitOverride(); + registerLogin(program); + + const oldExitCode = process.exitCode; + const oldLog = console.log; + const oldError = console.error; + const logs = []; + const errors = []; + process.exitCode = undefined; + console.log = (...line) => logs.push(line.join(' ')); + console.error = (...line) => errors.push(line.join(' ')); + try { + await program.parseAsync(args); + return { logs, errors, exitCode: process.exitCode ?? 0 }; + } finally { + console.log = oldLog; + console.error = oldError; + process.exitCode = oldExitCode ?? 0; + } +} + +describe('login success path', () => { + beforeAll(async () => { + tmpHome = join(realTmpdir(), '.test-login-success-' + Math.random().toString(36).slice(2)); + mkdirSync(tmpHome, { recursive: true }); + savedConfig = {}; + currentHome = tmpHome; + globalThis.__vitTestHome = tmpHome; + globalThis.__vitTestConfigHome = join(tmpHome, '.config'); + globalThis.__vitSavedConfig = {}; + globalThis.__vitLoginShouldReject = false; + oldHome = process.env.HOME; + oldXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.HOME = tmpHome; + process.env.XDG_CONFIG_HOME = join(tmpHome, '.config'); + ({ default: registerLogin } = await import('../src/cmd/login.js?login-success')); + }); + + afterAll(() => { + if (oldHome === undefined) delete process.env.HOME; + else process.env.HOME = oldHome; + if (oldXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = oldXdgConfigHome; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(fallbackConfigHome, { recursive: true, force: true }); + currentHome = undefined; + delete globalThis.__vitTestHome; + delete globalThis.__vitTestConfigHome; + delete globalThis.__vitSavedConfig; + delete globalThis.__vitLoginShouldReject; + mock.restore(); + }); + + test('login app-password installs skill and prints readiness', async () => { + globalThis.__vitLoginShouldReject = false; + + const result = await runLogin(['node', 'vit', 'login', 'alice.test', '--force', '--app-password', 'x']); + + expect(result.exitCode).toBe(0); + expect(result.logs.join('\n')).toContain('skill: ready (using-vit)'); + expect(result.logs.join('\n')).toContain('Logged in as did:plc:test'); + expect(existsSync(join(tmpHome, '.claude', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + expect(existsSync(join(tmpHome, '.agents', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + }); + + test('login prints readiness before failed auth', async () => { + globalThis.__vitLoginShouldReject = true; + + const result = await runLogin(['node', 'vit', 'login', 'alice.test', '--force', '--app-password', 'x']); + + expect(result.exitCode).toBe(1); + expect(result.logs.join('\n')).toContain('skill: ready (using-vit)'); + }); +}); diff --git a/test/login.test.js b/test/login.test.js index fda2f78..ffa205f 100644 --- a/test/login.test.js +++ b/test/login.test.js @@ -1,14 +1,66 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 sol pbc -import { describe, test, expect, spyOn } from 'bun:test'; +import { afterAll, describe, test, expect, mock, spyOn } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import * as os from 'node:os'; import { join } from 'node:path'; import { run } from './helpers.js'; -import { cancelLogin, printLoginFailure, LOGIN_COMMON_ISSUES_FOOTER } from '../src/cmd/login.js'; + +const realHomedir = os.homedir; +const realTmpdir = os.tmpdir; +const fallbackConfigHome = join(realTmpdir(), '.test-login-config-' + process.pid); + +mock.module('node:os', () => ({ + ...os, + homedir: () => globalThis.__vitTestHome || realHomedir(), +})); + +mock.module('@atproto/api', () => ({ + AtpAgent: class { + async login({ identifier }) { + if (globalThis.__vitLoginShouldReject) throw new Error('mock login failed'); + return { + data: { + did: 'did:plc:test', + handle: identifier, + accessJwt: 'a', + refreshJwt: 'r', + }, + }; + } + }, + Agent: class {}, +})); + +mock.module('../src/lib/config.js', () => ({ + loadConfig: () => ({ ...(globalThis.__vitSavedConfig || {}) }), + saveConfig: (config) => { + globalThis.__vitSavedConfig = { ...config }; + }, +})); + +mock.module('../src/lib/paths.js', () => ({ + configDir: join(fallbackConfigHome, 'vit'), + configPath: (filename) => join(fallbackConfigHome, 'vit', filename), +})); + +mock.module('../src/lib/oauth.js', () => ({ + checkSession: () => null, + createOAuthClient: () => { + throw new Error('OAuth path should not run in login tests'); + }, + createSessionStore: () => ({}), + createStore: () => ({}), +})); + +const { cancelLogin, printLoginFailure, LOGIN_COMMON_ISSUES_FOOTER } = await import('../src/cmd/login.js?login-test'); describe('login', () => { + afterAll(() => { + rmSync(fallbackConfigHome, { recursive: true, force: true }); + }); + test('--help shows --remote and --browser options', () => { const { stdout, exitCode } = run('login --help'); expect(exitCode).toBe(0); @@ -35,7 +87,7 @@ describe('login', () => { }); test('--local without .vit/ directory fails', () => { - const tmp = mkdtempSync(join(tmpdir(), 'vit-test-')); + const tmp = mkdtempSync(join(realTmpdir(), 'vit-test-')); try { const result = run('login testhandle --local', tmp); expect(result.exitCode).not.toBe(0); diff --git a/test/postinstall.test.js b/test/postinstall.test.js new file mode 100644 index 0000000..0cc7c76 --- /dev/null +++ b/test/postinstall.test.js @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { afterEach, describe, expect, test } from 'bun:test'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '..'); + +function runPostinstall(home) { + try { + return { + stdout: execSync('node src/postinstall.js', { + cwd: repoRoot, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config') }, + }), + exitCode: 0, + }; + } catch (err) { + return { + stdout: err.stdout || '', + stderr: err.stderr || '', + exitCode: err.status, + }; + } +} + +describe('postinstall', () => { + let cleanupPaths = []; + + afterEach(() => { + for (const path of cleanupPaths) { + rmSync(path, { recursive: true, force: true }); + } + cleanupPaths = []; + }); + + test('exits zero and stays quiet when install fails', () => { + const homeFile = join(tmpdir(), '.test-postinstall-home-' + Math.random().toString(36).slice(2)); + writeFileSync(homeFile, 'not a directory\n'); + cleanupPaths.push(homeFile); + + const result = runPostinstall(homeFile); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('installed'); + }); + + test('installs using-vit skill from a writable home', () => { + const home = join(tmpdir(), '.test-postinstall-home-' + Math.random().toString(36).slice(2)); + mkdirSync(home, { recursive: true }); + cleanupPaths.push(home); + + const result = runPostinstall(home); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('vit: using-vit skill installed'); + expect(existsSync(join(home, '.claude', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + }); +}); diff --git a/test/skill-install.test.js b/test/skill-install.test.js new file mode 100644 index 0000000..0394b51 --- /dev/null +++ b/test/skill-install.test.js @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import * as os from 'node:os'; +import { join } from 'node:path'; + +const sourceDir = join(import.meta.dir, '..', 'skills', 'vit'); +let currentHome; +const realHomedir = os.homedir; +const realTmpdir = os.tmpdir; + +mock.module('node:os', () => ({ + ...os, + homedir: () => currentHome || realHomedir(), +})); + +const { ensureSkill } = await import('../src/lib/skill-install.js?skill-install-test'); + +function sortedEntries(dir) { + return readdirSync(dir).sort(); +} + +describe('ensureSkill', () => { + let tmpHome; + let tmpSource; + let oldHome; + let oldXdgConfigHome; + + beforeEach(() => { + tmpHome = join(realTmpdir(), '.test-skill-install-' + Math.random().toString(36).slice(2)); + mkdirSync(tmpHome, { recursive: true }); + currentHome = tmpHome; + oldHome = process.env.HOME; + oldXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.HOME = tmpHome; + process.env.XDG_CONFIG_HOME = join(tmpHome, '.config'); + }); + + afterEach(() => { + if (oldHome === undefined) delete process.env.HOME; + else process.env.HOME = oldHome; + if (oldXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = oldXdgConfigHome; + rmSync(tmpHome, { recursive: true, force: true }); + if (tmpSource) rmSync(tmpSource, { recursive: true, force: true }); + tmpSource = null; + currentHome = undefined; + }); + + afterAll(() => { + mock.restore(); + }); + + test('installs the vendored using-vit skill into claude and agents homes', () => { + const result = ensureSkill(); + + expect(result.name).toBe('using-vit'); + expect(result.ok).toBe(true); + expect(result.results.map(r => r.status)).toEqual(['created', 'created']); + + const expectedEntries = sortedEntries(sourceDir); + for (const label of ['.claude', '.agents']) { + const target = join(tmpHome, label, 'skills', 'using-vit'); + expect(existsSync(join(target, 'SKILL.md'))).toBe(true); + expect(existsSync(join(target, 'COMMANDS.md'))).toBe(true); + expect(sortedEntries(target)).toEqual(expectedEntries); + } + }); + + test('reports already-present and refreshes existing files', () => { + ensureSkill(); + const targetFile = join(tmpHome, '.claude', 'skills', 'using-vit', 'COMMANDS.md'); + writeFileSync(targetFile, 'junk\n'); + + const result = ensureSkill(); + + expect(result.ok).toBe(true); + expect(result.results.map(r => r.status)).toEqual(['already-present', 'already-present']); + expect(readFileSync(targetFile, 'utf-8')).toBe(readFileSync(join(sourceDir, 'COMMANDS.md'), 'utf-8')); + }); + + test('reports one failed target while installing the other', () => { + writeFileSync(join(tmpHome, '.claude'), 'not a directory\n'); + + const result = ensureSkill(); + + expect(result.ok).toBe(false); + const claude = result.results.find(r => r.label === 'claude'); + const agents = result.results.find(r => r.label === 'agents'); + expect(claude.status).toBe('failed'); + expect(claude.error).toBeTruthy(); + expect(agents.status).toBe('created'); + expect(existsSync(join(tmpHome, '.agents', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + }); + + test('reports source-missing when vendored source is unavailable', () => { + tmpSource = join(realTmpdir(), '.test-skill-source-' + Math.random().toString(36).slice(2)); + mkdirSync(tmpSource, { recursive: true }); + + const result = ensureSkill({ sourceDir: tmpSource }); + + expect(result.source).toBe(null); + expect(result.ok).toBe(false); + expect(result.results.map(r => r.status)).toEqual(['source-missing', 'source-missing']); + expect(existsSync(join(tmpHome, '.claude'))).toBe(false); + expect(existsSync(join(tmpHome, '.agents'))).toBe(false); + }); + + test('falls back to using-vit when frontmatter has no name', () => { + tmpSource = join(realTmpdir(), '.test-skill-source-' + Math.random().toString(36).slice(2)); + mkdirSync(tmpSource, { recursive: true }); + writeFileSync(join(tmpSource, 'SKILL.md'), '---\ndescription: test skill\n---\n# Test\n'); + writeFileSync(join(tmpSource, 'EXTRA.md'), 'extra\n'); + + const result = ensureSkill({ sourceDir: tmpSource }); + + expect(result.name).toBe('using-vit'); + expect(result.ok).toBe(true); + expect(existsSync(join(tmpHome, '.claude', 'skills', 'using-vit', 'SKILL.md'))).toBe(true); + expect(existsSync(join(tmpHome, '.claude', 'skills', 'using-vit', 'EXTRA.md'))).toBe(true); + }); +}); diff --git a/test/version.test.js b/test/version.test.js new file mode 100644 index 0000000..7748547 --- /dev/null +++ b/test/version.test.js @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { describe, expect, test } from 'bun:test'; +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '..'); + +describe('vit --version', () => { + test('matches package.json under node and bun', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); + const nodeOutput = execSync('node bin/vit.js --version', { + cwd: repoRoot, + encoding: 'utf-8', + }).trim(); + const bunOutput = execSync('bun bin/vit.js --version', { + cwd: repoRoot, + encoding: 'utf-8', + }).trim(); + + expect(nodeOutput).toBe(pkg.version); + expect(bunOutput).toBe(pkg.version); + }); +});