diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03f8deb..4604c0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,23 @@ name: CI on: push: - branches: [main] + branches: [main, 'fix/**'] pull_request: jobs: check: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: [20, 22] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: pnpm - uses: oven-sh/setup-bun@v2 with: @@ -22,4 +27,21 @@ jobs: - run: pnpm typecheck - run: pnpm test - name: Bundle for npm and smoke it - run: pnpm build:npm && node packages/cli/bin/vibi.mjs --version + run: pnpm build:npm && node packages/cli/dist/vibi.mjs --version && node packages/cli/dist/vibi.mjs --help + + windows-host-build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version-file: packages/cli/tui/go.mod + - uses: oven-sh/setup-bun@v2 + - run: pnpm install --frozen-lockfile + - run: pnpm build:host + - run: packages/cli/dist/bin/windows-x64/vibi.exe --version diff --git a/package.json b/package.json index 1529ea4..a726ce6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packageManager": "pnpm@12.8.1", "scripts": { "typecheck": "pnpm -r typecheck", - "test": "tsx --test packages/shared/test/*.test.ts packages/cli/test/*.test.ts", + "test": "node scripts/test.mjs", "build:npm": "pnpm --filter vibi build:npm", "build:host": "pnpm --filter vibi build:tui:host && pnpm --filter vibi build:host", "build": "pnpm --filter vibi build:tui && pnpm --filter vibi build" diff --git a/packages/cli/scripts/archive.mjs b/packages/cli/scripts/archive.mjs new file mode 100644 index 0000000..0612a1f --- /dev/null +++ b/packages/cli/scripts/archive.mjs @@ -0,0 +1,14 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +export function archiveRelease(asset, outfile) { + if (asset.endsWith('.zip')) { + if (process.platform === 'win32') { + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + 'Compress-Archive -LiteralPath $env:VIBI_ARCHIVE_SOURCE -DestinationPath $env:VIBI_ARCHIVE_DESTINATION -Force'], { + stdio: 'inherit', + env: {...process.env, VIBI_ARCHIVE_SOURCE: outfile, VIBI_ARCHIVE_DESTINATION: asset} + }); + } else execFileSync('zip', ['-q', '-j', asset, outfile], {stdio: 'inherit'}); + } else execFileSync('tar', ['-czf', asset, '-C', path.dirname(outfile), path.basename(outfile)], {stdio: 'inherit'}); +} diff --git a/packages/cli/scripts/build-cli.mjs b/packages/cli/scripts/build-cli.mjs index 4f6a9fb..8433ccf 100644 --- a/packages/cli/scripts/build-cli.mjs +++ b/packages/cli/scripts/build-cli.mjs @@ -16,6 +16,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { archiveRelease } from './archive.mjs'; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DIST = path.join(CLI_DIR, 'dist'); @@ -79,8 +80,7 @@ if (targets.length) { const asset = path.join(release, `vibi-${t.name}${t.exe ? '.zip' : '.tar.gz'}`); fs.rmSync(asset, { force: true }); - if (t.exe) execFileSync('zip', ['-q', '-j', asset, outfile], { stdio: 'inherit' }); - else execFileSync('tar', ['-czf', asset, '-C', outDir, 'vibi'], { stdio: 'inherit' }); + archiveRelease(asset, outfile); const digest = createHash('sha256').update(fs.readFileSync(asset)).digest('hex'); checksums.push(`${digest} ${path.basename(asset)}`); console.log(` ${path.relative(CLI_DIR, asset)} (${(fs.statSync(asset).size / 1048576).toFixed(1)} MB)`); diff --git a/packages/cli/test/archive.test.ts b/packages/cli/test/archive.test.ts new file mode 100644 index 0000000..8601b7f --- /dev/null +++ b/packages/cli/test/archive.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { archiveRelease } from '../scripts/archive.mjs'; + +test('Windows release archives build without an external zip executable', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'vibi archive spaces ')); + try { + const source = path.join(dir, 'vibi.exe'); + const asset = path.join(dir, 'vibi-windows-x64.zip'); + writeFileSync(source, 'executable fixture'); + archiveRelease(asset, source); + const bytes = readFileSync(asset); + assert.equal(bytes.subarray(0, 2).toString(), 'PK'); + if (process.platform === 'win32') { + const destination = path.join(dir, 'unpacked'); + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Expand-Archive -LiteralPath $env:VIBI_ARCHIVE_SOURCE -DestinationPath $env:VIBI_ARCHIVE_DESTINATION'], {env: {...process.env, VIBI_ARCHIVE_SOURCE: asset, VIBI_ARCHIVE_DESTINATION: destination}}); + assert.equal(readFileSync(path.join(destination, 'vibi.exe'), 'utf8'), 'executable fixture'); + } else { + assert.equal(execFileSync('unzip', ['-p', asset, 'vibi.exe'], {encoding: 'utf8'}), 'executable fixture'); + } + } finally {rmSync(dir, {recursive: true, force: true});} +}); diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 80bfa39..3b2a76b 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -17,10 +17,11 @@ after(async () => { const meta = { title: 't', cwd: '/work/x', model: '', messageCount: 1, sourcePath: '', startedAt: null }; test('Claude Code project directories are encoded the way Claude Code does it', () => { - assert.equal(encodeClaudeProjectDir('/Users/hongyinluo/Desktop/cybermind'), '-Users-hongyinluo-Desktop-cybermind'); - assert.equal(encodeClaudeProjectDir('/Users/me/my_app.v2'), '-Users-me-my-app-v2'); + const prefix = process.platform === 'win32' ? `${path.parse(process.cwd()).root[0]}--` : '-'; + assert.equal(encodeClaudeProjectDir('/Users/hongyinluo/Desktop/cybermind'), `${prefix}Users-hongyinluo-Desktop-cybermind`); + assert.equal(encodeClaudeProjectDir('/Users/me/my_app.v2'), `${prefix}Users-me-my-app-v2`); const file = installPathFor({ harness: 'claude', harnessSessionId: 'abc', harnessUpdatedAt: '2026-10-01T10:00:00Z', metadata: meta, home, projectDir: '/work/x' }); - assert.equal(file, path.join(home, '.claude', 'projects', '-work-x', 'abc.jsonl')); + assert.equal(file, path.join(home, '.claude', 'projects', `${prefix}work-x`, 'abc.jsonl')); }); test('other harnesses keep their original file names and date folders', () => { diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..c1b7768 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,11 @@ +import { spawnSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const files = ['packages/shared/test', 'packages/cli/test'].flatMap((dir) => + readdirSync(new URL(`../${dir}/`, import.meta.url)).filter((name) => name.endsWith('.test.ts')).sort().map((name) => `${dir}/${name}`) +); +const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], {cwd: root, stdio: 'inherit'}); +if (result.error) throw result.error; +process.exit(result.status ?? 1);