diff --git a/Makefile b/Makefile index d133d13..90a50db 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ -.PHONY: install install-user test test-node clean +.PHONY: install link test test-node clean install: bun install -install-user: - bun link +link: + bun install && node bin/vit.js link test: test-node bun test diff --git a/hack b/hack new file mode 100755 index 0000000..77b260e --- /dev/null +++ b/hack @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +SELF="solpbc/vit" + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +echo "installing vit from source ($SELF)..." + +if ! command_exists git; then + echo "error: git is required. install git and try again." + exit 1 +fi + +if ! command_exists bun; then + echo "bun not found, installing..." + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" +fi + +DIR_NAME=$(echo "$SELF" | sed 's|.*/||') +if [ -d "$DIR_NAME" ]; then + echo "$DIR_NAME/ already exists, skipping clone" +else + if command_exists gh; then + gh repo clone "$SELF" "$DIR_NAME" + else + git clone "https://github.com/$SELF.git" "$DIR_NAME" + fi +fi + +cd "$DIR_NAME" + +bun install +node bin/vit.js link + +echo "" +echo "vit installed from source" +echo "run: cd $DIR_NAME" diff --git a/src/cli.js b/src/cli.js index a741665..e7e3541 100644 --- a/src/cli.js +++ b/src/cli.js @@ -17,6 +17,8 @@ import registerVet from './cmd/vet.js'; import registerVouch from './cmd/vouch.js'; import registerFollow from './cmd/follow.js'; import registerSetup from './cmd/setup.js'; +import registerHack from './cmd/hack.js'; +import registerLink from './cmd/link.js'; const program = new Command(); program @@ -38,5 +40,7 @@ registerVet(program); registerVouch(program); registerFollow(program); registerSetup(program); +registerHack(program); +registerLink(program); export { program }; diff --git a/src/cmd/doctor.js b/src/cmd/doctor.js index bceabac..21400cb 100644 --- a/src/cmd/doctor.js +++ b/src/cmd/doctor.js @@ -4,9 +4,10 @@ import { loadConfig } from '../lib/config.js'; import { restoreAgent } from '../lib/oauth.js'; import { readProjectConfig } from '../lib/vit-dir.js'; -import { existsSync } from 'node:fs'; +import { existsSync, lstatSync } from 'node:fs'; import { join } from 'node:path'; import { mark, name } from '../lib/brand.js'; +import { which } from '../lib/compat.js'; export default function register(program) { async function checkHealth() { @@ -19,6 +20,23 @@ export default function register(program) { console.log(`${mark} setup: not done (run ${name} setup)`); } + const vitPath = which(name); + if (!vitPath) { + console.log(`${mark} install: not on PATH`); + } else { + try { + if (lstatSync(vitPath).isSymbolicLink()) { + console.log(`${mark} install: linked (${vitPath})`); + } else if (vitPath.includes('node_modules')) { + console.log(`${mark} install: global`); + } else { + console.log(`${mark} install: source (${vitPath})`); + } + } catch { + console.log(`${mark} install: source (${vitPath})`); + } + } + const projConfig = readProjectConfig(); if (projConfig.beacon) { console.log(`${mark} beacon: ${projConfig.beacon}`); diff --git a/src/cmd/hack.js b/src/cmd/hack.js new file mode 100644 index 0000000..eb0186e --- /dev/null +++ b/src/cmd/hack.js @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { which } from '../lib/compat.js'; +import { mark, name } from '../lib/brand.js'; + +export default function register(program) { + program + .command('hack') + .description('Fork and install vit from source') + .option('--from ', 'Clone an existing fork instead of creating a new one (e.g. user/vit)') + .action(async (opts) => { + try { + const ghPath = which('gh'); + if (!opts.from && !ghPath) { + console.error(`${name} hack requires gh (GitHub CLI). Install it from https://cli.github.com`); + process.exitCode = 1; + return; + } + + const gitPath = which('git'); + if (opts.from && !gitPath) { + console.error('missing required tool: git'); + process.exitCode = 1; + return; + } + + const repo = opts.from || 'solpbc/vit'; + const dirName = repo.split('/').pop(); + const dirPath = resolve(process.cwd(), dirName); + const cloned = !existsSync(dirPath); + + if (!cloned) { + console.log(`${mark} ${dirName}/ already exists, skipping clone`); + } else if (opts.from) { + execFileSync('git', ['clone', 'https://github.com/' + opts.from + '.git', dirName], { stdio: 'inherit' }); + } else { + execFileSync('gh', ['repo', 'fork', 'solpbc/vit', '--clone'], { stdio: 'inherit' }); + } + + process.chdir(dirName); + + const bunPath = which('bun'); + if (!bunPath) { + console.error('missing required tool: bun'); + process.exitCode = 1; + return; + } + + execFileSync('bun', ['install'], { stdio: 'inherit' }); + execFileSync('node', [join(process.cwd(), 'bin', 'vit.js'), 'link'], { stdio: 'inherit' }); + + if (!opts.from) { + const remote = execFileSync( + 'gh', + ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], + { encoding: 'utf-8' } + ).trim(); + const hackPath = join(process.cwd(), 'hack'); + if (existsSync(hackPath)) { + const current = readFileSync(hackPath, 'utf-8'); + writeFileSync(hackPath, current.replace('SELF="solpbc/vit"', `SELF="${remote}"`)); + } + } + + if (opts.from && cloned) { + try { + execFileSync('git', ['remote', 'add', 'upstream', 'https://github.com/solpbc/vit.git'], { stdio: 'inherit' }); + } catch {} + } + + console.log(''); + console.log(`${mark} ${name} installed from source`); + console.log(`run: cd ${dirName}`); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + } + }); +} diff --git a/src/cmd/link.js b/src/cmd/link.js new file mode 100644 index 0000000..b81a16e --- /dev/null +++ b/src/cmd/link.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +import { existsSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { mark, name } from '../lib/brand.js'; + +export default function register(program) { + program + .command('link') + .description('Link vit binary into ~/.local/bin (or create .cmd shim on Windows)') + .action(async () => { + try { + const vitBin = resolve(import.meta.dirname, '..', '..', 'bin', 'vit.js'); + if (!existsSync(vitBin)) { + console.error(`${name} link could not find ${vitBin}`); + process.exitCode = 1; + return; + } + + let binDir; + if (process.platform === 'win32') { + const home = process.env.USERPROFILE || process.env.HOME; + binDir = join(home, '.local', 'bin'); + mkdirSync(binDir, { recursive: true }); + const target = join(binDir, 'vit.cmd'); + writeFileSync(target, `@node "${vitBin}" %*\n`); + console.log(`${mark} link: ${target} -> ${vitBin}`); + } else { + binDir = join(process.env.HOME, '.local', 'bin'); + mkdirSync(binDir, { recursive: true }); + const target = join(binDir, 'vit'); + if (existsSync(target)) { + try { + const current = readlinkSync(target); + if (current === vitBin) { + console.log(`${mark} link: already linked`); + } else { + unlinkSync(target); + symlinkSync(vitBin, target); + console.log(`${mark} link: ${target} -> ${vitBin}`); + } + } catch { + unlinkSync(target); + symlinkSync(vitBin, target); + console.log(`${mark} link: ${target} -> ${vitBin}`); + } + } else { + symlinkSync(vitBin, target); + console.log(`${mark} link: ${target} -> ${vitBin}`); + } + } + + const pathDirs = (process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':'); + if (!pathDirs.includes(binDir)) { + const shell = (process.env.SHELL || '').split('/').pop(); + let instruction = '[Environment]::SetEnvironmentVariable("PATH", "$env:USERPROFILE\\.local\\bin;" + $env:PATH, "User")'; + if (shell === 'fish') { + instruction = 'set -Ua fish_user_paths ~/.local/bin'; + } else if (shell === 'zsh') { + instruction = 'echo \'export PATH="$HOME/.local/bin:$PATH"\' >> ~/.zshrc'; + } else if (shell === 'bash') { + instruction = 'echo \'export PATH="$HOME/.local/bin:$PATH"\' >> ~/.bashrc'; + } + console.log(`${mark} note: add ~/.local/bin to your PATH:`); + console.log(instruction); + } else { + console.log(`${mark} PATH: ok`); + } + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + } + }); +} diff --git a/test/doctor.test.js b/test/doctor.test.js index f81c915..c782c19 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -25,6 +25,11 @@ describe('vit doctor', () => { expect(result.stdout).toMatch(/bluesky:/); }); + test('reports install type', () => { + const result = run('doctor'); + expect(result.stdout).toMatch(/install:/); + }); + test('vit status is an alias for doctor', () => { const result = run('status'); expect(result.stdout).toMatch(/setup:/); diff --git a/test/hack.test.js b/test/hack.test.js new file mode 100644 index 0000000..de8b430 --- /dev/null +++ b/test/hack.test.js @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +import { describe, test, expect } from 'bun:test'; +import { run } from './helpers.js'; + +describe('vit hack', () => { + test('--help shows usage', () => { + const result = run('hack --help'); + expect(result.stdout).toContain('Fork'); + expect(result.exitCode).toBe(0); + }); + + test('--help shows --from option', () => { + const result = run('hack --help'); + expect(result.stdout).toContain('--from'); + }); +}); diff --git a/test/link.test.js b/test/link.test.js new file mode 100644 index 0000000..e3f8ddd --- /dev/null +++ b/test/link.test.js @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +import { describe, test, expect } from 'bun:test'; +import { run } from './helpers.js'; + +describe('vit link', () => { + test('--help shows usage', () => { + const result = run('link --help'); + expect(result.stdout).toContain('Link'); + expect(result.exitCode).toBe(0); + }); + + test('creates symlink without error', () => { + const result = run('link'); + expect(result.exitCode).toBe(0); + }); + + test('is idempotent', () => { + run('link'); + const result = run('link'); + expect(result.exitCode).toBe(0); + }); +});