From cfd897ce2adae8ed50480fe364d1d814ce5f9f75 Mon Sep 17 00:00:00 2001 From: LemonNeko Date: Tue, 10 Mar 2026 18:39:52 +0800 Subject: [PATCH] refactor(cap-vite): forward options to capacitor cli --- packages/cap-vite/README.md | 8 ++- packages/cap-vite/package.json | 2 + packages/cap-vite/src/bin/run.ts | 15 ++---- packages/cap-vite/src/cli.test.ts | 41 +++++++++++++++ packages/cap-vite/src/cli.ts | 63 +++++++++++++++++++++++ packages/cap-vite/src/index.test.ts | 78 +++++++++++++++++++++++++++++ packages/cap-vite/src/index.ts | 14 ++++-- packages/cap-vite/vitest.config.ts | 13 +++++ pnpm-lock.yaml | 3 ++ vitest.config.ts | 1 + 10 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 packages/cap-vite/src/cli.test.ts create mode 100644 packages/cap-vite/src/cli.ts create mode 100644 packages/cap-vite/src/index.test.ts create mode 100644 packages/cap-vite/vitest.config.ts diff --git a/packages/cap-vite/README.md b/packages/cap-vite/README.md index 97a513e19..d565f02ae 100644 --- a/packages/cap-vite/README.md +++ b/packages/cap-vite/README.md @@ -5,13 +5,17 @@ CLI for [Capacitor](https://capacitorjs.com/) live-reload development using Vite ## Usage ```bash -pnpm cap-vite ios -pnpm cap-vite android +pnpm cap-vite ios --target +pnpm cap-vite android --target +pnpm cap-vite ios --target -- --scheme AIRI +pnpm cap-vite android --target -- --flavor release # Or CAPACITOR_DEVICE_ID= pnpm cap-vite ios CAPACITOR_DEVICE_ID= pnpm cap-vite android ``` +- Arguments after `--` are forwarded to `cap run`, example: `pnpm cap-vite ios --target -- --scheme AIRI` will run `cap run ios --target --scheme AIRI`. + You can see the list of available devices and simulators by running `pnpm exec cap run ios --list` or `pnpm exec cap run android --list`. ## Capacitor Configuration diff --git a/packages/cap-vite/package.json b/packages/cap-vite/package.json index 2b11c9acd..630cf48c8 100644 --- a/packages/cap-vite/package.json +++ b/packages/cap-vite/package.json @@ -32,6 +32,7 @@ ], "scripts": { "build": "tsdown", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "peerDependencies": { @@ -39,6 +40,7 @@ "vite": "^7.0.0 || ^8.0.0-beta.0" }, "dependencies": { + "cac": "^6.7.14", "tinyexec": "^1.0.2" } } diff --git a/packages/cap-vite/src/bin/run.ts b/packages/cap-vite/src/bin/run.ts index 9fc5caa57..4583b29af 100644 --- a/packages/cap-vite/src/bin/run.ts +++ b/packages/cap-vite/src/bin/run.ts @@ -3,20 +3,15 @@ import process from 'node:process' import { runCapVite } from '..' +import { parseCapViteCliArgs } from '../cli' async function main() { - const platform = process.argv[2] - const deviceId = process.env.CAPACITOR_DEVICE_ID || process.argv[3] - if (!deviceId) { - throw new Error('Usage: cap-vite ') + const parsed = parseCapViteCliArgs(process.argv.slice(2)) + if (!parsed) { + return } - if (platform !== 'android' && platform !== 'ios') { - process.stderr.write('Usage: cap-vite \n') - process.exit(1) - } - - await runCapVite(platform, deviceId) + await runCapVite(parsed.platform, parsed.target, { capArgs: parsed.capArgs }) } void main().catch((error) => { diff --git a/packages/cap-vite/src/cli.test.ts b/packages/cap-vite/src/cli.test.ts new file mode 100644 index 000000000..e5c567498 --- /dev/null +++ b/packages/cap-vite/src/cli.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { parseCapViteCliArgs } from './cli' + +describe('parseCapViteCliArgs', () => { + it('parses target and cap args after double dashes', () => { + expect(parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--', '--flavor', 'release'])).toEqual({ + capArgs: ['--flavor', 'release'], + platform: 'ios', + target: 'iPhone 16 Pro', + }) + }) + + it('falls back to CAPACITOR_DEVICE_ID when --target is omitted', () => { + expect(parseCapViteCliArgs(['android'], { CAPACITOR_DEVICE_ID: 'emulator-5554' })).toEqual({ + capArgs: [], + platform: 'android', + target: 'emulator-5554', + }) + }) + + it('keeps cap args untouched even when they look like wrapper flags', () => { + expect(parseCapViteCliArgs(['android', '--target', 'emulator-5554', '--', '--mode', 'release'])).toEqual({ + capArgs: ['--mode', 'release'], + platform: 'android', + target: 'emulator-5554', + }) + }) + + it('rejects unknown wrapper options before double dashes', () => { + expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--mode', 'mobile'])).toThrow(/Unknown option `--mode`/) + }) + + it('allows only one positional argument', () => { + expect(() => parseCapViteCliArgs(['ios', 'iPhone 16 Pro'])).toThrow('cap-vite [--target ] [-- ]') + }) + + it('rejects unsupported platforms', () => { + expect(() => parseCapViteCliArgs(['web', '--target', 'chrome'])).toThrow('cap-vite [--target ] [-- ]') + }) +}) diff --git a/packages/cap-vite/src/cli.ts b/packages/cap-vite/src/cli.ts new file mode 100644 index 000000000..943446496 --- /dev/null +++ b/packages/cap-vite/src/cli.ts @@ -0,0 +1,63 @@ +import type { CapacitorPlatform } from '.' + +import process from 'node:process' + +import { cac } from 'cac' + +export interface ParsedCapViteCliArgs { + capArgs: string[] + platform: CapacitorPlatform + target: string +} + +const usage = 'cap-vite [--target ] [-- ]' + +function createCapViteCli() { + const cli = cac('cap-vite') + + cli.help() + cli + .command('', 'Run Capacitor with a Vite dev server') + .usage(usage) + .option('--target ', 'Set the Capacitor device target') + .example('cap-vite ios --target "iPhone 16 Pro" -- --scheme AIRI') + .example('CAPACITOR_DEVICE_ID=emulator-5554 cap-vite android -- --flavor release') + + return cli +} + +export function parseCapViteCliArgs( + argv: string[], + env: NodeJS.ProcessEnv = process.env, +): ParsedCapViteCliArgs | null { + const cli = createCapViteCli() + const parsed = cli.parse(['node', 'cap-vite', ...argv], { run: false }) + + if (cli.options.help) { + return null + } + + cli.matchedCommand?.checkUnknownOptions() + cli.matchedCommand?.checkOptionValue() + cli.matchedCommand?.checkRequiredArgs() + + if (parsed.args.length > 1) { + throw new Error(usage) + } + + const platform = parsed.args[0] + if (platform !== 'android' && platform !== 'ios') { + throw new Error(usage) + } + + const target = typeof parsed.options.target === 'string' ? parsed.options.target : env.CAPACITOR_DEVICE_ID + if (!target) { + throw new Error(usage) + } + + return { + capArgs: Array.isArray(parsed.options['--']) ? parsed.options['--'] : [], + platform, + target, + } +} diff --git a/packages/cap-vite/src/index.test.ts b/packages/cap-vite/src/index.test.ts new file mode 100644 index 000000000..e94622c24 --- /dev/null +++ b/packages/cap-vite/src/index.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { runCapVite } from './index' + +const { createServer, x } = vi.hoisted(() => ({ + createServer: vi.fn(), + x: vi.fn(), +})) + +vi.mock('tinyexec', () => ({ + x, +})) + +vi.mock('vite', () => ({ + createServer, +})) + +describe('runCapVite', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('forwards cap args into cap run', async () => { + const processOnce = vi.spyOn(process, 'once').mockImplementation(() => process) + + createServer.mockResolvedValue({ + close: vi.fn().mockResolvedValue(undefined), + config: { + logger: { + info: vi.fn(), + }, + }, + listen: vi.fn().mockResolvedValue(undefined), + printUrls: vi.fn(), + resolvedUrls: { + local: ['http://127.0.0.1:5173/'], + }, + watcher: { + add: vi.fn(), + off: vi.fn(), + on: vi.fn(), + unwatch: vi.fn().mockResolvedValue(undefined), + }, + }) + + x.mockReturnValue({ + kill: vi.fn(), + }) + + await runCapVite('android', 'emulator-5554', { + capArgs: ['--flavor', 'release'], + }) + + expect(createServer).toHaveBeenCalledWith({ + clearScreen: false, + root: process.cwd(), + }) + + expect(x).toHaveBeenCalledWith('cap', ['run', 'android', '--target', 'emulator-5554', '--flavor', 'release'], { + nodeOptions: { + cwd: process.cwd(), + env: { + CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/', + }, + stdio: 'inherit', + }, + persist: true, + throwOnError: false, + }) + + expect(processOnce).toHaveBeenCalledWith('SIGINT', expect.any(Function)) + expect(processOnce).toHaveBeenCalledWith('SIGTERM', expect.any(Function)) + }) +}) diff --git a/packages/cap-vite/src/index.ts b/packages/cap-vite/src/index.ts index 5e90bd25c..e037b6ff7 100644 --- a/packages/cap-vite/src/index.ts +++ b/packages/cap-vite/src/index.ts @@ -11,6 +11,7 @@ import { createServer } from 'vite' export type CapacitorPlatform = 'android' | 'ios' export interface RunCapViteOptions { + capArgs?: string[] cwd?: string debounceMs?: number } @@ -142,15 +143,18 @@ async function stopCapProcess(current: Result | undefined) { } } -function startCapProcess(cwd: string, platform: CapacitorPlatform, deviceId: string, url: URL) { - return x('cap', ['run', platform, '--target', deviceId], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit', env: { CAPACITOR_DEV_SERVER_URL: url.toString() } } }) +function startCapProcess(cwd: string, platform: CapacitorPlatform, target: string, url: URL, capArgs: string[]) { + console.info('\n----------------------\n') + console.info('Running cap run', platform, '--target', target, ...capArgs) + return x('cap', ['run', platform, '--target', target, ...capArgs], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit', env: { CAPACITOR_DEV_SERVER_URL: url.toString() } } }) } export async function runCapVite( platform: CapacitorPlatform, - deviceId: string, + target: string, options: RunCapViteOptions = {}, ): Promise { + const capArgs = options.capArgs ?? [] const cwd = resolve(options.cwd ?? process.cwd()) const debounceMs = options.debounceMs ?? 300 const server = await createServer({ @@ -164,7 +168,7 @@ export async function runCapVite( const url = pickServerUrl(server) const logger = server.config.logger - let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, deviceId, url) + let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, target, url, capArgs) let restartTimer: NodeJS.Timeout | undefined let shuttingDown = false @@ -177,7 +181,7 @@ export async function runCapVite( const previous = currentCapProcess currentCapProcess = undefined await stopCapProcess(previous) - currentCapProcess = startCapProcess(cwd, platform, deviceId, url) + currentCapProcess = startCapProcess(cwd, platform, target, url, capArgs) } const onWatcherEvent = (_event: string, file: string) => { diff --git a/packages/cap-vite/vitest.config.ts b/packages/cap-vite/vitest.config.ts new file mode 100644 index 000000000..9e78e0291 --- /dev/null +++ b/packages/cap-vite/vitest.config.ts @@ -0,0 +1,13 @@ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +const root = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + root, + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c0e0f024..2b7e297f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1963,6 +1963,9 @@ importers: '@capacitor/cli': specifier: ^8.0.0 version: 8.1.0 + cac: + specifier: ^6.7.14 + version: 6.7.14 tinyexec: specifier: ^1.0.2 version: 1.0.2 diff --git a/vitest.config.ts b/vitest.config.ts index 233efa589..575052287 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'apps/stage-tamagotchi', 'packages/stage-ui', 'packages/plugin-sdk', + 'packages/cap-vite', 'packages/vite-plugin-warpdrive', 'packages/audio-pipelines-transcribe', 'packages/server-runtime',