diff --git a/docs/cli.md b/docs/cli.md index 9086205..8d5f172 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,9 +22,10 @@ volt dev [options] 3. Waits for the Vite server to become ready 4. Creates a native window pointing to the dev server URL 5. Enables devtools in the WebView +6. If `plugins.pluginDirs` is configured, watches discovered plugin projects, rebuilds them on change, and restarts their plugin host processes Runtime/native logging can be tuned with `VOLT_LOG` (or `RUST_LOG` as fallback). Defaults are `debug` for development builds and `warn` for production builds. -6. Falls back to web-only mode if the native binding is unavailable +7. Falls back to web-only mode if the native binding is unavailable **Example:** ```bash @@ -137,6 +138,16 @@ volt doctor [options] volt doctor --target win32 --format msix ``` +## `volt plugin` + +Plugin-focused scaffolding, build, smoke-test, and diagnostics commands. + +See [Plugin CLI](plugin-cli.md) for: +- `volt plugin init` +- `volt plugin build` +- `volt plugin test` +- `volt plugin doctor` + ## `volt package` Package the built application into platform-specific installers. diff --git a/docs/plugin-cli.md b/docs/plugin-cli.md new file mode 100644 index 0000000..fe9180a --- /dev/null +++ b/docs/plugin-cli.md @@ -0,0 +1,58 @@ +# Plugin CLI + +Plugin-focused scaffolding, build, smoke-test, and diagnostics commands for Volt backend plugins. + +## `volt plugin init` + +Create a new backend plugin project scaffold. + +```bash +volt plugin init my-plugin +``` + +Creates a `volt-plugin.json`, `src/plugin.ts`, `package.json`, and `tsconfig.json` scaffold that can be bundled immediately with `volt plugin build`. + +## `volt plugin build` + +Bundle the plugin backend entry declared by `volt-plugin.json`. + +```bash +volt plugin build +``` + +Behavior: +1. Loads and validates `volt-plugin.json` +2. Resolves the plugin source entry (`src/plugin.ts`, `src/plugin.js`, `plugin.ts`, or `plugin.js`) +3. Bundles the backend to the configured manifest output, typically `dist/plugin.js` +4. Treats `volt:*` imports as external runtime modules + +## `volt plugin test` + +Run a smoke test against the real `volt-plugin-host` binary. + +```bash +volt plugin test +``` + +Behavior: +1. Builds the plugin bundle +2. Starts the real plugin host process with the plugin loaded +3. Sends `activate` +4. Invokes each command listed in `contributes.commands` +5. Sends `deactivate` and tears down the host cleanly + +## `volt plugin doctor` + +Validate plugin schema and compatibility with the nearest Volt app, when present. + +```bash +volt plugin doctor +``` + +Checks: +1. Manifest presence, JSON parsing, and schema validity +2. Plugin source entry existence and extension support +3. Bundle output path validity and current build presence +4. `apiVersion` compatibility with the current Volt CLI/runtime +5. `engine.volt` semver compatibility +6. Parent app permission and grant compatibility, when a Volt app config is found diff --git a/packages/volt-cli/src/__tests__/plugin-cli/build.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/build.test.ts new file mode 100644 index 0000000..6751075 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/build.test.ts @@ -0,0 +1,17 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { pluginBuildCommand } from '../../commands/plugin/build.js'; +import { createTempPluginProject } from './fixtures.js'; + +describe('plugin build', () => { + it('bundles the plugin source to dist/plugin.js', async () => { + const projectDir = createTempPluginProject(); + + await pluginBuildCommand({ cwd: projectDir }); + + const bundlePath = resolve(projectDir, 'dist', 'plugin.js'); + expect(existsSync(bundlePath)).toBe(true); + expect(readFileSync(bundlePath, 'utf8')).toContain('definePlugin'); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/command-registration.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/command-registration.test.ts new file mode 100644 index 0000000..69290de --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/command-registration.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { createPluginCommand } from '../../commands/plugin.js'; + +describe('plugin command registration', () => { + it('registers the expected subcommands', () => { + const command = createPluginCommand(); + expect(command.commands.map((child) => child.name())).toEqual([ + 'init', + 'build', + 'test', + 'doctor', + ]); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/doctor.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/doctor.test.ts new file mode 100644 index 0000000..763a2fb --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/doctor.test.ts @@ -0,0 +1,57 @@ +import { mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { pluginBuildCommand } from '../../commands/plugin/build.js'; +import { collectPluginDoctorChecks } from '../../commands/plugin/doctor.js'; +import { copyPluginProject, createTempPluginProject, createTempWorkspace, writePluginManifest, writeVoltAppConfig } from './fixtures.js'; + +describe('plugin doctor', () => { + it('reports passing checks for a valid built plugin inside a compatible app', async () => { + const appRoot = createTempWorkspace(); + const pluginDir = resolve(appRoot, 'plugins', 'sample-plugin'); + mkdirSync(resolve(appRoot, 'plugins'), { recursive: true }); + mkdirSync(pluginDir, { recursive: true }); + const scaffoldRoot = createTempPluginProject('sample-plugin'); + copyPluginProject(scaffoldRoot, pluginDir); + writeVoltAppConfig( + appRoot, + `export default { name: 'App', permissions: ['fs'], plugins: { grants: { 'com.example.sample': ['fs'] } } };`, + ); + await pluginBuildCommand({ cwd: pluginDir }); + + const checks = await collectPluginDoctorChecks(pluginDir); + + expect(checks.every((check) => check.status !== 'fail')).toBe(true); + expect(checks.find((check) => check.id === 'host.permissions')?.status).toBe('pass'); + }); + + it('fails when engine range or apiVersion is incompatible', async () => { + const projectDir = createTempPluginProject(); + writePluginManifest(projectDir, (manifest) => ({ + ...manifest, + apiVersion: 99, + engine: { volt: '>=9.0.0' }, + })); + + const checks = await collectPluginDoctorChecks(projectDir); + + expect(checks.find((check) => check.id === 'api.version')?.status).toBe('fail'); + expect(checks.find((check) => check.id === 'engine.volt')?.status).toBe('fail'); + }); + + it('reports invalid manifest schema as a failed doctor check', async () => { + const projectDir = createTempPluginProject(); + writePluginManifest(projectDir, (manifest) => ({ + ...manifest, + id: 'not-a-reverse-domain', + })); + + const checks = await collectPluginDoctorChecks(projectDir); + + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'manifest.schema', + status: 'fail', + }); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/fixtures.ts b/packages/volt-cli/src/__tests__/plugin-cli/fixtures.ts new file mode 100644 index 0000000..639c788 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/fixtures.ts @@ -0,0 +1,43 @@ +import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Permission } from 'voltkit'; +import { createPluginScaffold } from '../../commands/plugin/scaffold.js'; + +export function createTempWorkspace(prefix = 'volt-plugin-cli-'): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function createTempPluginProject( + name = 'sample-plugin', + overrides: Partial<{ pluginId: string; capabilities: Permission[]; description: string }> = {}, +): string { + const root = createTempWorkspace(); + const projectDir = resolve(root, name); + createPluginScaffold({ + targetDir: projectDir, + pluginId: overrides.pluginId ?? 'com.example.sample', + name: 'Sample Plugin', + description: overrides.description ?? 'Sample plugin', + capabilities: overrides.capabilities ?? ['fs'], + }); + return projectDir; +} + +export function copyPluginProject(source: string, target: string): void { + cpSync(source, target, { recursive: true }); +} + +export function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) as unknown; +} + +export function writePluginManifest(projectDir: string, update: (value: Record) => Record): void { + const manifestPath = resolve(projectDir, 'volt-plugin.json'); + const manifest = readJson(manifestPath) as Record; + writeFileSync(manifestPath, `${JSON.stringify(update(manifest), null, 2)}\n`, 'utf8'); +} + +export function writeVoltAppConfig(appRoot: string, contents: string): void { + writeFileSync(resolve(appRoot, 'volt.config.mjs'), contents, 'utf8'); +} diff --git a/packages/volt-cli/src/__tests__/plugin-cli/harness-fs.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/harness-fs.test.ts new file mode 100644 index 0000000..6984c02 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/harness-fs.test.ts @@ -0,0 +1,30 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { performScopedFsRequest } from '../../utils/plugin-host-harness/fs.js'; +import { createTempWorkspace } from './fixtures.js'; + +describe('plugin harness fs', () => { + it('reads and writes inside the scoped data root', () => { + const dataRoot = createTempWorkspace(); + + performScopedFsRequest(dataRoot, 'write-file', { + path: 'notes/output.txt', + data: 'hello from plugin', + }); + + expect(readFileSync(resolve(dataRoot, 'notes', 'output.txt'), 'utf8')).toBe('hello from plugin'); + expect( + performScopedFsRequest(dataRoot, 'read-file', { path: 'notes/output.txt' }), + ).toBe('hello from plugin'); + }); + + it('rejects traversal outside the scoped data root', () => { + const dataRoot = createTempWorkspace(); + mkdirSync(resolve(dataRoot, 'safe'), { recursive: true }); + + expect(() => + performScopedFsRequest(dataRoot, 'read-file', { path: '../outside.txt' }), + ).toThrow(/path escapes base directory|path traversal is not allowed/); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/harness-storage.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/harness-storage.test.ts new file mode 100644 index 0000000..23927b2 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/harness-storage.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { performStorageRequest } from '../../utils/plugin-host-harness/storage.js'; +import { createTempWorkspace } from './fixtures.js'; + +describe('plugin harness storage', () => { + it('stores, lists, and deletes values', () => { + const dataRoot = createTempWorkspace(); + + performStorageRequest(dataRoot, 'set', { key: 'alpha', value: 'one' }); + performStorageRequest(dataRoot, 'set', { key: 'beta', value: 'two' }); + + expect(performStorageRequest(dataRoot, 'get', { key: 'alpha' })).toBe('one'); + expect(performStorageRequest(dataRoot, 'has', { key: 'beta' })).toBe(true); + expect(performStorageRequest(dataRoot, 'keys', null)).toEqual(['alpha', 'beta']); + + performStorageRequest(dataRoot, 'delete', { key: 'alpha' }); + + expect(performStorageRequest(dataRoot, 'get', { key: 'alpha' })).toBeNull(); + expect(performStorageRequest(dataRoot, 'keys', null)).toEqual(['beta']); + }); + + it('rejects invalid keys and oversized values', () => { + const dataRoot = createTempWorkspace(); + + expect(() => + performStorageRequest(dataRoot, 'set', { key: '../escape', value: 'x' }), + ).toThrow(/invalid storage key/); + expect(() => + performStorageRequest(dataRoot, 'set', { + key: 'large', + value: 'x'.repeat(1024 * 1024 + 1), + }), + ).toThrow(/storage value exceeds/); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/init.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/init.test.ts new file mode 100644 index 0000000..4b9e22e --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/init.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { pluginInitCommand } from '../../commands/plugin/init.js'; +import { validatePluginManifest } from '../../utils/plugin-manifest.js'; +import { createTempWorkspace, readJson } from './fixtures.js'; + +describe('plugin init', () => { + it('creates the expected scaffold with a valid manifest', async () => { + const cwd = createTempWorkspace(); + await pluginInitCommand( + 'new-plugin', + { cwd }, + async () => ({ + pluginId: 'com.example.newplugin', + name: 'New Plugin', + description: 'Test plugin', + capabilities: ['fs', 'http'], + }), + ); + + const projectDir = resolve(cwd, 'new-plugin'); + expect(existsSync(resolve(projectDir, 'volt-plugin.json'))).toBe(true); + expect(existsSync(resolve(projectDir, 'src', 'plugin.ts'))).toBe(true); + expect(existsSync(resolve(projectDir, 'package.json'))).toBe(true); + expect(existsSync(resolve(projectDir, 'tsconfig.json'))).toBe(true); + expect( + validatePluginManifest(readJson(resolve(projectDir, 'volt-plugin.json'))).valid, + ).toBe(true); + expect(readFileSync(resolve(projectDir, 'src', 'plugin.ts'), 'utf8')).toContain( + "definePlugin({", + ); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/test-command.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/test-command.test.ts new file mode 100644 index 0000000..da5b940 --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/test-command.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { pluginTestCommand } from '../../commands/plugin/test.js'; +import { createTempPluginProject } from './fixtures.js'; + +describe('plugin test command', () => { + it('builds, activates, and invokes contributed commands', async () => { + const projectDir = createTempPluginProject(); + const buildPlugin = vi.fn(async () => {}); + const activate = vi.fn(async () => {}); + const invokeCommand = vi.fn(async () => ({ ok: true })); + const shutdown = vi.fn(async () => {}); + + await pluginTestCommand( + { + buildPlugin, + createDataRoot: () => '/tmp/plugin-data', + startHarness: vi.fn(async () => ({ + process: {} as never, + state: { commands: new Set(), eventSubscriptions: new Set(), ipcHandlers: new Set(), emittedEvents: [] }, + activate, + invokeCommand, + shutdown, + kill: vi.fn(), + })), + }, + { cwd: projectDir }, + ); + + expect(buildPlugin).toHaveBeenCalledWith(projectDir); + expect(activate).toHaveBeenCalled(); + expect(shutdown).toHaveBeenCalled(); + }); +}); diff --git a/packages/volt-cli/src/__tests__/plugin-cli/watch.test.ts b/packages/volt-cli/src/__tests__/plugin-cli/watch.test.ts new file mode 100644 index 0000000..13a82ba --- /dev/null +++ b/packages/volt-cli/src/__tests__/plugin-cli/watch.test.ts @@ -0,0 +1,55 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { startPluginDevelopment } from '../../commands/dev/plugin-development.js'; +import { copyPluginProject, createTempPluginProject, createTempWorkspace, writeVoltAppConfig } from './fixtures.js'; + +describe('plugin dev watch', () => { + it('resolves configured plugin projects and restarts on rebuild', async () => { + const appRoot = createTempWorkspace(); + const pluginDir = resolve(appRoot, 'plugins', 'watched-plugin'); + mkdirSync(resolve(appRoot, 'plugins'), { recursive: true }); + const sourceProject = createTempPluginProject('watched-plugin'); + copyPluginProject(sourceProject, pluginDir); + writeVoltAppConfig( + appRoot, + `export default { name: 'App', plugins: { pluginDirs: ['./plugins'], enabled: ['com.example.sample'] } };`, + ); + + const shutdown = vi.fn(async () => {}); + let rebuildHandler: ((ok: boolean) => Promise | void) | null = null; + + const dispose = await startPluginDevelopment( + appRoot, + { pluginDirs: ['./plugins'], enabled: ['com.example.sample'] }, + { + buildPlugin: vi.fn(async () => {}), + loadProject: (cwd) => ({ + projectRoot: cwd, + manifestPath: resolve(cwd, 'volt-plugin.json'), + manifestResult: { valid: true, errors: [], manifest: JSON.parse(readFileSync(resolve(cwd, 'volt-plugin.json'), 'utf8')) }, + manifest: JSON.parse(readFileSync(resolve(cwd, 'volt-plugin.json'), 'utf8')), + }), + startHarness: vi.fn(async () => ({ + process: {} as never, + state: { commands: new Set(), eventSubscriptions: new Set(), ipcHandlers: new Set(), emittedEvents: [] }, + activate: vi.fn(async () => {}), + invokeCommand: vi.fn(async () => null), + shutdown, + kill: vi.fn(), + })), + createDataRoot: () => '/tmp/plugin-data', + watchPlugin: vi.fn(async (_cwd, onRebuild) => { + rebuildHandler = onRebuild; + return async () => {}; + }), + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }, + ); + + await rebuildHandler?.(true); + await dispose(); + + expect(shutdown).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/volt-cli/src/commands/dev.ts b/packages/volt-cli/src/commands/dev.ts index 8b75c33..c35ce37 100644 --- a/packages/volt-cli/src/commands/dev.ts +++ b/packages/volt-cli/src/commands/dev.ts @@ -26,6 +26,7 @@ import { startOutOfProcessRuntime, } from './dev/runtime.js'; import { parseDevPort, resolveViteDevUrl, spawnVite } from './dev/server.js'; +import { startPluginDevelopment } from './dev/plugin-development.js'; export interface DevOptions { port: string; @@ -75,6 +76,7 @@ export async function devCommand(options: DevOptions): Promise { let shutdownNativeRuntime = () => {}; let disposeBackendBundle = () => {}; + let disposePluginDevelopment = async () => {}; let nativeRuntimeExitPromise: Promise | null = null; let cleanupStarted = false; const waitFor = (timeoutMs: number) => @@ -100,6 +102,8 @@ export async function devCommand(options: DevOptions): Promise { // Continue cleanup even if temporary backend bundle cleanup fails. } + await disposePluginDevelopment().catch(() => {}); + try { shutdownNativeRuntime(); } catch { @@ -208,6 +212,7 @@ export async function devCommand(options: DevOptions): Promise { } shutdownNativeRuntime = () => nativeRuntime.shutdown(); + disposePluginDevelopment = await startPluginDevelopment(cwd, config.plugins); // Register the event handler BEFORE run() so no IPC messages are dropped. // run() starts the native event loop and the WebView becomes live immediately, diff --git a/packages/volt-cli/src/commands/dev/plugin-development.ts b/packages/volt-cli/src/commands/dev/plugin-development.ts new file mode 100644 index 0000000..6bf98a0 --- /dev/null +++ b/packages/volt-cli/src/commands/dev/plugin-development.ts @@ -0,0 +1,139 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { VoltConfig } from 'voltkit'; +import { pluginBuildCommand } from '../plugin/build.js'; +import { loadPluginProject, resolvePluginManifestPath } from '../plugin/project.js'; +import { watchPluginBuild } from '../plugin/watch.js'; +import { createPluginHarnessDataRoot, startPluginHarness } from '../../utils/plugin-host-harness.js'; + +type PluginConfig = NonNullable; + +interface PluginDevelopmentLogger { + log(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +interface PluginDevelopmentDeps { + buildPlugin(cwd: string): Promise; + loadProject(cwd: string): ReturnType; + startHarness: typeof startPluginHarness; + createDataRoot(pluginId: string): string; + watchPlugin: typeof watchPluginBuild; + logger: PluginDevelopmentLogger; +} + +const DEFAULT_DEPS: PluginDevelopmentDeps = { + buildPlugin: (cwd) => pluginBuildCommand({ cwd }), + loadProject: loadPluginProject, + startHarness: startPluginHarness, + createDataRoot: createPluginHarnessDataRoot, + watchPlugin: watchPluginBuild, + logger: console, +}; + +export async function startPluginDevelopment( + appRoot: string, + config: PluginConfig | undefined, + deps: PluginDevelopmentDeps = DEFAULT_DEPS, +): Promise<() => Promise> { + const projectRoots = resolvePluginProjects(appRoot, config); + const disposers = await Promise.all(projectRoots.map((projectRoot) => startWatchedPlugin(projectRoot, deps))); + if (projectRoots.length > 0) { + deps.logger.log(`[volt] Watching ${projectRoots.length} plugin${projectRoots.length === 1 ? '' : 's'} for rebuild/restart.`); + } + return async () => { + for (const dispose of disposers.reverse()) { + await dispose(); + } + }; +} + +async function startWatchedPlugin( + projectRoot: string, + deps: PluginDevelopmentDeps, +): Promise<() => Promise> { + const project = deps.loadProject(projectRoot); + let harness = await createAndActivateHarness(projectRoot, deps); + let restartChain = Promise.resolve(); + + const stopWatching = await deps.watchPlugin(projectRoot, async (ok) => { + if (!ok) { + deps.logger.error(`[volt] Plugin rebuild failed: ${project.manifest.id}`); + return; + } + restartChain = restartChain.then(async () => { + await harness.shutdown(); + harness = await createAndActivateHarness(projectRoot, deps); + deps.logger.log(`[volt] Reloaded plugin ${project.manifest.id}`); + }); + await restartChain; + }); + + return async () => { + await stopWatching(); + await harness.shutdown(); + }; +} + +async function createAndActivateHarness( + projectRoot: string, + deps: PluginDevelopmentDeps, +) { + await deps.buildPlugin(projectRoot); + const project = deps.loadProject(projectRoot); + const harness = await deps.startHarness({ + pluginId: project.manifest.id, + backendEntry: resolve(projectRoot, project.manifest.backend), + dataRoot: deps.createDataRoot(project.manifest.id), + manifest: project.manifest, + }); + await harness.activate(); + return harness; +} + +function resolvePluginProjects(appRoot: string, config: PluginConfig | undefined): string[] { + const pluginDirs = config?.pluginDirs ?? []; + const enabled = new Set(config?.enabled ?? []); + const projects = new Map(); + + for (const configuredDir of pluginDirs) { + const baseDir = resolve(appRoot, configuredDir); + const manifestPath = resolvePluginManifestPath(baseDir); + if (existsPluginManifest(manifestPath)) { + projects.set(baseDir, baseDir); + continue; + } + + for (const child of safeReadDirectory(baseDir)) { + const childRoot = resolve(baseDir, child); + if (existsPluginManifest(resolvePluginManifestPath(childRoot))) { + projects.set(childRoot, childRoot); + } + } + } + + const values = [...projects.values()]; + if (enabled.size === 0) { + return values; + } + return values.filter((projectRoot) => enabled.has(loadPluginProject(projectRoot).manifest.id)); +} + +function existsPluginManifest(path: string): boolean { + return existsSync(path); +} + +function safeReadDirectory(path: string): string[] { + try { + return readdirSync(path, { withFileTypes: true }) + .filter((entry: { isDirectory(): boolean }) => entry.isDirectory()) + .map((entry: { name: string }) => entry.name); + } catch { + return []; + } +} + +export const __testOnly = { + resolvePluginProjects, +}; diff --git a/packages/volt-cli/src/commands/plugin.ts b/packages/volt-cli/src/commands/plugin.ts new file mode 100644 index 0000000..f2aec55 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin.ts @@ -0,0 +1,39 @@ +import { Command } from 'commander'; +import { pluginBuildCommand } from './plugin/build.js'; +import { pluginDoctorCommand } from './plugin/doctor.js'; +import { pluginInitCommand } from './plugin/init.js'; +import { pluginTestCommand } from './plugin/test.js'; + +export function createPluginCommand(): Command { + const command = new Command('plugin').description('Plugin development tooling'); + + command + .command('init [name]') + .description('Scaffold a new Volt plugin project') + .action(async (name) => { + await pluginInitCommand(name); + }); + + command + .command('build') + .description('Bundle a Volt plugin backend for production') + .action(async () => { + await pluginBuildCommand(); + }); + + command + .command('test') + .description('Smoke-test a plugin in the real plugin host') + .action(async () => { + await pluginTestCommand(); + }); + + command + .command('doctor') + .description('Validate plugin manifest, entrypoints, and host compatibility') + .action(async () => { + await pluginDoctorCommand(); + }); + + return command; +} diff --git a/packages/volt-cli/src/commands/plugin/build.ts b/packages/volt-cli/src/commands/plugin/build.ts new file mode 100644 index 0000000..78e03e3 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/build.ts @@ -0,0 +1,15 @@ +import { buildBackendBundle } from '../build/backend.js'; +import { loadPluginProject, resolvePluginBundlePath, resolvePluginProjectRoot, resolvePluginSourceEntry } from './project.js'; + +export interface PluginBuildOptions { + cwd?: string; +} + +export async function pluginBuildCommand(options: PluginBuildOptions = {}): Promise { + const projectRoot = resolvePluginProjectRoot(options.cwd ?? process.cwd()); + const project = loadPluginProject(projectRoot); + const sourceEntry = resolvePluginSourceEntry(projectRoot); + const bundlePath = resolvePluginBundlePath(projectRoot, project.manifest); + await buildBackendBundle(projectRoot, sourceEntry, bundlePath); + console.log(`[volt:plugin] Built ${project.manifest.id} -> ${bundlePath}`); +} diff --git a/packages/volt-cli/src/commands/plugin/doctor.ts b/packages/volt-cli/src/commands/plugin/doctor.ts new file mode 100644 index 0000000..b1dd493 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/doctor.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { relative } from 'node:path'; +import type { Permission, VoltConfig } from 'voltkit'; +import { loadConfig } from '../../utils/config.js'; +import { type PluginManifest, validatePluginManifest } from '../../utils/plugin-manifest.js'; +import { findNearestVoltAppRoot, resolvePluginBundlePath, resolvePluginManifestPath, resolvePluginProjectRoot, resolvePluginSourceEntry, resolveVoltCliVersion } from './project.js'; +import { semverSatisfies } from './semver.js'; + +export type PluginDoctorStatus = 'pass' | 'warn' | 'fail'; + +export interface PluginDoctorCheck { + id: string; + status: PluginDoctorStatus; + title: string; + detail: string; +} + +const SUPPORTED_PLUGIN_API_VERSIONS = new Set([1]); + +export async function pluginDoctorCommand(): Promise { + const checks = await collectPluginDoctorChecks(process.cwd()); + for (const check of checks) { + const icon = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL'; + console.log(`[volt:plugin:doctor] ${icon} ${check.title}: ${check.detail}`); + } + if (checks.some((check) => check.status === 'fail')) { + process.exit(1); + } +} + +export async function collectPluginDoctorChecks(cwd: string): Promise { + const projectRoot = resolvePluginProjectRoot(cwd); + const manifestResult = loadDoctorManifest(projectRoot); + if ('checks' in manifestResult) { + return manifestResult.checks; + } + + const project = manifestResult.project; + const sourceEntry = tryResolve(() => resolvePluginSourceEntry(project.projectRoot)); + const bundlePath = resolvePluginBundlePath(project.projectRoot, project.manifest); + const currentVoltVersion = resolveVoltCliVersion(); + const appRoot = findNearestVoltAppRoot(project.projectRoot); + const appConfig = appRoot ? await loadConfig(appRoot, { strict: false, commandName: 'plugin doctor' }) : null; + + return [ + { + id: 'manifest.schema', + status: 'pass', + title: 'Manifest schema', + detail: relative(project.projectRoot, project.manifestPath) || project.manifestPath, + }, + { + id: 'backend.source', + status: sourceEntry ? 'pass' : 'fail', + title: 'Source entry', + detail: sourceEntry ?? 'Expected src/plugin.ts, src/plugin.js, plugin.ts, or plugin.js', + }, + { + id: 'backend.bundle', + status: project.manifest.backend.endsWith('.js') || project.manifest.backend.endsWith('.mjs') ? 'pass' : 'fail', + title: 'Bundle path', + detail: bundlePath, + }, + { + id: 'api.version', + status: SUPPORTED_PLUGIN_API_VERSIONS.has(project.manifest.apiVersion) ? 'pass' : 'fail', + title: 'API version', + detail: SUPPORTED_PLUGIN_API_VERSIONS.has(project.manifest.apiVersion) + ? `apiVersion ${project.manifest.apiVersion} is supported` + : `apiVersion ${project.manifest.apiVersion} is not supported by Volt ${currentVoltVersion}`, + }, + { + id: 'engine.volt', + status: semverSatisfies(currentVoltVersion, project.manifest.engine.volt) ? 'pass' : 'fail', + title: 'Volt version range', + detail: semverSatisfies(currentVoltVersion, project.manifest.engine.volt) + ? `Volt ${currentVoltVersion} satisfies ${project.manifest.engine.volt}` + : `Volt ${currentVoltVersion} does not satisfy ${project.manifest.engine.volt}`, + }, + ...collectCompatibilityChecks(project.manifest.id, project.manifest.capabilities, appRoot, appConfig), + { + id: 'bundle.exists', + status: existsSync(bundlePath) ? 'pass' : 'warn', + title: 'Built bundle', + detail: existsSync(bundlePath) + ? `Found ${relative(project.projectRoot, bundlePath) || bundlePath}` + : `Missing ${relative(project.projectRoot, bundlePath) || bundlePath}. Run \`volt plugin build\`.`, + }, + ]; +} + +function loadDoctorManifest( + projectRoot: string, +): { project: { projectRoot: string; manifestPath: string; manifest: PluginManifest } } | { checks: PluginDoctorCheck[] } { + const manifestPath = resolvePluginManifestPath(projectRoot); + if (!existsSync(manifestPath)) { + return { + checks: [ + { + id: 'manifest.schema', + status: 'fail', + title: 'Manifest schema', + detail: `Missing volt-plugin.json in ${projectRoot}`, + }, + ], + }; + } + + const rawManifest = tryReadManifest(manifestPath); + if (!rawManifest.ok) { + return { + checks: [ + { + id: 'manifest.schema', + status: 'fail', + title: 'Manifest schema', + detail: rawManifest.message, + }, + ], + }; + } + + const validation = validatePluginManifest(rawManifest.value); + if (!validation.valid || !validation.manifest) { + return { + checks: [ + { + id: 'manifest.schema', + status: 'fail', + title: 'Manifest schema', + detail: validation.errors.map((error) => `${error.field}: ${error.message}`).join('; '), + }, + ], + }; + } + + return { + project: { + projectRoot, + manifestPath, + manifest: validation.manifest, + }, + }; +} + +function tryReadManifest( + manifestPath: string, +): { ok: true; value: unknown } | { ok: false; message: string } { + try { + return { + ok: true, + value: JSON.parse(readFileSync(manifestPath, 'utf8')) as unknown, + }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +function collectCompatibilityChecks( + pluginId: string, + capabilities: Permission[], + appRoot: string | null, + appConfig: VoltConfig | null, +): PluginDoctorCheck[] { + if (!appRoot || !appConfig) { + return [ + { + id: 'host.config', + status: 'warn', + title: 'Host app compatibility', + detail: 'No parent Volt app config found. Capability compatibility was not checked.', + }, + ]; + } + + const appPermissions = new Set((appConfig.permissions ?? []) as Permission[]); + const granted = new Set((appConfig.plugins?.grants?.[pluginId] ?? []) as Permission[]); + const missingPermissions = capabilities.filter((capability) => !appPermissions.has(capability)); + const missingGrants = capabilities.filter((capability) => !granted.has(capability)); + const checks: PluginDoctorCheck[] = [ + { + id: 'host.root', + status: 'pass', + title: 'Host app root', + detail: appRoot, + }, + { + id: 'host.permissions', + status: missingPermissions.length === 0 ? 'pass' : 'fail', + title: 'App permissions', + detail: + missingPermissions.length === 0 + ? 'All requested plugin capabilities are declared by the app' + : `App is missing permissions: ${missingPermissions.join(', ')}`, + }, + ]; + + checks.push({ + id: 'host.grants', + status: missingGrants.length === 0 ? 'pass' : 'warn', + title: 'Plugin grants', + detail: + missingGrants.length === 0 + ? `App grants all requested capabilities to ${pluginId}` + : `App does not currently grant: ${missingGrants.join(', ')}`, + }); + + return checks; +} + +function tryResolve(factory: () => T): T | null { + try { + return factory(); + } catch { + return null; + } +} + +export const __testOnly = { + collectPluginDoctorChecks, +}; diff --git a/packages/volt-cli/src/commands/plugin/init.ts b/packages/volt-cli/src/commands/plugin/init.ts new file mode 100644 index 0000000..68cb2e3 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/init.ts @@ -0,0 +1,93 @@ +import { existsSync } from 'node:fs'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { resolve } from 'node:path'; +import type { Permission } from 'voltkit'; +import { VALID_PERMISSIONS } from '../../utils/config/constants.js'; +import { createPluginScaffold } from './scaffold.js'; + +export interface PluginInitOptions { + cwd?: string; +} + +interface PluginInitAnswers { + pluginId: string; + name: string; + description: string; + capabilities: Permission[]; +} + +export async function pluginInitCommand( + projectName: string | undefined, + options: PluginInitOptions = {}, + prompt: (projectName: string) => Promise = promptForPluginAnswers, +): Promise { + const targetName = (projectName ?? '').trim(); + if (!targetName) { + throw new Error('[volt:plugin] Project directory name is required.'); + } + + const targetDir = resolve(options.cwd ?? process.cwd(), targetName); + if (existsSync(targetDir)) { + throw new Error(`[volt:plugin] Target directory already exists: ${targetDir}`); + } + + const answers = await prompt(targetName); + createPluginScaffold({ + targetDir, + ...answers, + }); + + console.log(`[volt:plugin] Created plugin scaffold in ${targetDir}`); +} + +async function promptForPluginAnswers(projectName: string): Promise { + const rl = createInterface({ input, output }); + try { + const pluginId = await ask(rl, 'Plugin ID (reverse-domain)', `com.example.${projectName}`); + const name = await ask(rl, 'Plugin name', projectName); + const description = await ask(rl, 'Description', `${name} plugin`); + const capabilityAnswer = await ask( + rl, + `Capabilities (comma-separated from: ${VALID_PERMISSIONS.join(', ')})`, + '', + ); + return { + pluginId, + name, + description, + capabilities: parseCapabilities(capabilityAnswer), + }; + } finally { + rl.close(); + } +} + +async function ask( + rl: ReturnType, + label: string, + fallback: string, +): Promise { + const answer = (await rl.question(`${label} [${fallback}]: `)).trim(); + return answer || fallback; +} + +function parseCapabilities(raw: string): Permission[] { + const values = raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const unique = new Set(); + for (const value of values) { + if (!VALID_PERMISSIONS.includes(value as Permission)) { + throw new Error(`[volt:plugin] Unknown capability "${value}".`); + } + unique.add(value as Permission); + } + return [...unique]; +} + +export const __testOnly = { + parseCapabilities, + promptForPluginAnswers, +}; diff --git a/packages/volt-cli/src/commands/plugin/project.ts b/packages/volt-cli/src/commands/plugin/project.ts new file mode 100644 index 0000000..705ddf2 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/project.ts @@ -0,0 +1,134 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CONFIG_FILES } from '../../utils/config/constants.js'; +import { + type ManifestValidationResult, + type PluginManifest, + validatePluginManifest, +} from '../../utils/plugin-manifest.js'; + +export const PLUGIN_MANIFEST_FILE = 'volt-plugin.json'; + +const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ + 'src/plugin.ts', + 'src/plugin.js', + 'plugin.ts', + 'plugin.js', +] as const; +const SUPPORTED_PLUGIN_ENTRY_EXTENSIONS = new Set(['.ts', '.js', '.mts', '.mjs', '.cts', '.cjs']); + +interface CliPackageJson { + version?: string; +} + +export interface LoadedPluginProject { + projectRoot: string; + manifestPath: string; + manifestResult: ManifestValidationResult; + manifest: PluginManifest; +} + +export function resolvePluginProjectRoot(startDir: string): string { + return resolve(startDir); +} + +export function resolvePluginManifestPath(projectRoot: string): string { + return resolve(projectRoot, PLUGIN_MANIFEST_FILE); +} + +export function loadPluginProject(projectRoot: string): LoadedPluginProject { + const manifestPath = resolvePluginManifestPath(projectRoot); + if (!existsSync(manifestPath)) { + throw new Error(`[volt:plugin] Missing ${PLUGIN_MANIFEST_FILE} in ${projectRoot}`); + } + + const raw = JSON.parse(readFileSync(manifestPath, 'utf8')) as unknown; + const manifestResult = validatePluginManifest(raw); + if (!manifestResult.valid || !manifestResult.manifest) { + const summary = manifestResult.errors.map((error) => `${error.field}: ${error.message}`).join('; '); + throw new Error(`[volt:plugin] Invalid plugin manifest: ${summary}`); + } + + return { + projectRoot, + manifestPath, + manifestResult, + manifest: manifestResult.manifest, + }; +} + +export function resolvePluginSourceEntry(projectRoot: string): string { + for (const candidate of DEFAULT_PLUGIN_ENTRY_CANDIDATES) { + const candidatePath = resolve(projectRoot, candidate); + if (existsSync(candidatePath)) { + ensureSupportedPluginEntryExtension(candidatePath); + ensurePathWithinProject(projectRoot, candidatePath, 'plugin source entry'); + return candidatePath; + } + } + + throw new Error( + `[volt:plugin] No plugin source entry found. Expected one of: ${DEFAULT_PLUGIN_ENTRY_CANDIDATES.join(', ')}`, + ); +} + +export function resolvePluginBundlePath(projectRoot: string, manifest: PluginManifest): string { + const bundlePath = resolve(projectRoot, manifest.backend); + ensureSupportedPluginEntryExtension(bundlePath); + ensurePathWithinProject(projectRoot, bundlePath, 'plugin bundle output'); + mkdirSync(dirname(bundlePath), { recursive: true }); + return bundlePath; +} + +export function ensureSupportedPluginEntryExtension(entryPath: string): void { + const extension = extname(entryPath).toLowerCase(); + if (SUPPORTED_PLUGIN_ENTRY_EXTENSIONS.has(extension)) { + return; + } + throw new Error( + `[volt:plugin] Unsupported plugin entry extension "${extension || '(none)'}". ` + + `Expected one of: ${Array.from(SUPPORTED_PLUGIN_ENTRY_EXTENSIONS).join(', ')}`, + ); +} + +export function ensurePathWithinProject( + projectRoot: string, + candidatePath: string, + label: string, +): void { + const relativePath = relative(resolve(projectRoot), resolve(candidatePath)); + if (relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))) { + return; + } + throw new Error(`[volt:plugin] ${label} must reside within project root: ${candidatePath}`); +} + +export function findNearestVoltAppRoot(startDir: string): string | null { + let current = resolve(startDir); + while (true) { + if (CONFIG_FILES.some((name) => existsSync(resolve(current, name)))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +export function resolveVoltCliVersion(): string { + const packageJsonPath = fileURLToPath(new URL('../../../package.json', import.meta.url)); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as CliPackageJson; + return packageJson.version ?? '0.1.0'; +} + +export function toSafePackageName(name: string): string { + return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-'); +} + +export const __testOnly = { + DEFAULT_PLUGIN_ENTRY_CANDIDATES, + SUPPORTED_PLUGIN_ENTRY_EXTENSIONS, +}; diff --git a/packages/volt-cli/src/commands/plugin/scaffold.ts b/packages/volt-cli/src/commands/plugin/scaffold.ts new file mode 100644 index 0000000..183fe8f --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/scaffold.ts @@ -0,0 +1,97 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { type Permission } from 'voltkit'; +import { resolveVoltCliVersion, toSafePackageName } from './project.js'; + +export interface PluginScaffoldOptions { + targetDir: string; + pluginId: string; + name: string; + description: string; + capabilities: Permission[]; +} + +export function createPluginScaffold(options: PluginScaffoldOptions): void { + const targetDir = resolve(options.targetDir); + const srcDir = join(targetDir, 'src'); + mkdirSync(srcDir, { recursive: true }); + + writeFileSync(join(targetDir, 'volt-plugin.json'), `${JSON.stringify(createManifest(options), null, 2)}\n`, 'utf8'); + writeFileSync(join(srcDir, 'plugin.ts'), createPluginSource(options), 'utf8'); + writeFileSync(join(targetDir, 'package.json'), `${JSON.stringify(createPackageJson(options), null, 2)}\n`, 'utf8'); + writeFileSync(join(targetDir, 'tsconfig.json'), `${JSON.stringify(createTsconfig(), null, 2)}\n`, 'utf8'); +} + +function createManifest(options: PluginScaffoldOptions) { + return { + id: options.pluginId, + name: options.name, + description: options.description, + version: '0.1.0', + apiVersion: 1, + engine: { + volt: `>=${resolveVoltCliVersion()}`, + }, + backend: './dist/plugin.js', + capabilities: options.capabilities, + contributes: { + commands: [], + }, + }; +} + +function createPluginSource(options: PluginScaffoldOptions): string { + const logLine = + options.description.trim().length > 0 + ? ` context.log.info(${JSON.stringify(`${options.name} activated`)})` + : ` context.log.info(${JSON.stringify(`${options.pluginId} activated`)})`; + return [ + "import { definePlugin } from 'volt:plugin';", + '', + 'definePlugin({', + ' async activate(context) {', + ` ${logLine};`, + ' },', + ' async deactivate(context) {', + " context.log.info('plugin deactivated');", + ' },', + '});', + '', + ].join('\n'); +} + +function createPackageJson(options: PluginScaffoldOptions) { + return { + name: toSafePackageName(options.name || options.pluginId), + version: '0.1.0', + private: true, + type: 'module', + scripts: { + build: 'volt plugin build', + test: 'volt plugin test', + doctor: 'volt plugin doctor', + }, + dependencies: { + voltkit: `^${resolveVoltCliVersion()}`, + }, + devDependencies: { + typescript: '^5.9.3', + }, + }; +} + +function createTsconfig() { + return { + compilerOptions: { + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'bundler', + strict: true, + isolatedModules: true, + skipLibCheck: true, + noEmit: true, + types: [], + }, + include: ['src/**/*.ts'], + }; +} diff --git a/packages/volt-cli/src/commands/plugin/semver.ts b/packages/volt-cli/src/commands/plugin/semver.ts new file mode 100644 index 0000000..0a40cb5 --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/semver.ts @@ -0,0 +1,90 @@ +interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +const VERSION_RE = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export function parseSemverVersion(value: string): ParsedVersion | null { + const match = value.trim().match(VERSION_RE); + if (!match) { + return null; + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3] ?? '0'), + }; +} + +export function semverSatisfies(version: string, range: string): boolean { + const parsed = parseSemverVersion(version); + if (!parsed) { + return false; + } + + return range + .split(/\s*\|\|\s*/) + .some((group) => group.split(/\s*&&\s*|\s+/).filter(Boolean).every((token) => testComparator(parsed, token))); +} + +function testComparator(version: ParsedVersion, token: string): boolean { + if (token.startsWith('^')) { + return testCaret(version, token.slice(1)); + } + if (token.startsWith('~')) { + return testTilde(version, token.slice(1)); + } + + const match = token.match(/^(>=|<=|>|<|=)?(.+)$/); + if (!match) { + return false; + } + const comparator = match[1] ?? '='; + const expected = parseSemverVersion(match[2]); + if (!expected) { + return false; + } + const comparison = compareSemver(version, expected); + return ( + (comparator === '=' && comparison === 0) || + (comparator === '>' && comparison > 0) || + (comparator === '>=' && comparison >= 0) || + (comparator === '<' && comparison < 0) || + (comparator === '<=' && comparison <= 0) + ); +} + +function testCaret(version: ParsedVersion, raw: string): boolean { + const base = parseSemverVersion(raw); + if (!base || compareSemver(version, base) < 0) { + return false; + } + const upper = + base.major > 0 + ? { major: base.major + 1, minor: 0, patch: 0 } + : base.minor > 0 + ? { major: 0, minor: base.minor + 1, patch: 0 } + : { major: 0, minor: 0, patch: base.patch + 1 }; + return compareSemver(version, upper) < 0; +} + +function testTilde(version: ParsedVersion, raw: string): boolean { + const base = parseSemverVersion(raw); + if (!base || compareSemver(version, base) < 0) { + return false; + } + return compareSemver(version, { + major: base.major, + minor: base.minor + 1, + patch: 0, + }) < 0; +} + +export function compareSemver(left: ParsedVersion, right: ParsedVersion): number { + return ( + left.major - right.major || left.minor - right.minor || left.patch - right.patch + ); +} diff --git a/packages/volt-cli/src/commands/plugin/test.ts b/packages/volt-cli/src/commands/plugin/test.ts new file mode 100644 index 0000000..02d669b --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/test.ts @@ -0,0 +1,56 @@ +import { createPluginHarnessDataRoot, startPluginHarness } from '../../utils/plugin-host-harness.js'; +import { pluginBuildCommand } from './build.js'; +import { loadPluginProject, resolvePluginBundlePath, resolvePluginProjectRoot } from './project.js'; + +interface PluginTestDeps { + buildPlugin(cwd: string): Promise; + createDataRoot(pluginId: string): string; + startHarness: typeof startPluginHarness; +} + +export interface PluginTestOptions { + cwd?: string; +} + +const DEFAULT_DEPS: PluginTestDeps = { + buildPlugin: (cwd) => pluginBuildCommand({ cwd }), + createDataRoot: createPluginHarnessDataRoot, + startHarness: startPluginHarness, +}; + +export async function pluginTestCommand( + deps: PluginTestDeps = DEFAULT_DEPS, + options: PluginTestOptions = {}, +): Promise { + const projectRoot = resolvePluginProjectRoot(options.cwd ?? process.cwd()); + await deps.buildPlugin(projectRoot); + const project = loadPluginProject(projectRoot); + const harness = await deps.startHarness({ + pluginId: project.manifest.id, + backendEntry: resolvePluginBundlePath(projectRoot, project.manifest), + dataRoot: deps.createDataRoot(project.manifest.id), + manifest: project.manifest, + }); + + try { + await harness.activate(); + console.log(`[volt:plugin:test] PASS activate ${project.manifest.id}`); + for (const command of project.manifest.contributes?.commands ?? []) { + const result = await harness.invokeCommand(command.id, null); + console.log( + `[volt:plugin:test] PASS command ${command.id} -> ${JSON.stringify(result ?? null)}`, + ); + } + } catch (error) { + console.error( + `[volt:plugin:test] FAIL ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } finally { + await harness.shutdown(); + } +} + +export const __testOnly = { + DEFAULT_DEPS, +}; diff --git a/packages/volt-cli/src/commands/plugin/watch.ts b/packages/volt-cli/src/commands/plugin/watch.ts new file mode 100644 index 0000000..a18b68c --- /dev/null +++ b/packages/volt-cli/src/commands/plugin/watch.ts @@ -0,0 +1,42 @@ +import { context as esbuildContext } from 'esbuild'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { resolvePluginBundlePath, resolvePluginProjectRoot, resolvePluginSourceEntry, loadPluginProject } from './project.js'; + +export async function watchPluginBuild( + cwd: string, + onRebuild: (ok: boolean) => Promise | void, +): Promise<() => Promise> { + const projectRoot = resolvePluginProjectRoot(cwd); + const project = loadPluginProject(projectRoot); + const sourceEntry = resolvePluginSourceEntry(projectRoot); + const outfile = resolvePluginBundlePath(projectRoot, project.manifest); + const tsconfigPath = resolve(projectRoot, 'tsconfig.json'); + + const ctx = await esbuildContext({ + entryPoints: [sourceEntry], + outfile, + bundle: true, + format: 'esm', + platform: 'neutral', + target: ['es2022'], + sourcemap: false, + minify: false, + external: ['volt:*'], + tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined, + logLevel: 'warning', + plugins: [ + { + name: 'volt-plugin-watch-rebuild', + setup(build) { + build.onEnd((result) => onRebuild(result.errors.length === 0)); + }, + }, + ], + }); + + await ctx.watch(); + return async () => { + await ctx.dispose(); + }; +} diff --git a/packages/volt-cli/src/index.ts b/packages/volt-cli/src/index.ts index 109e4d6..38441c7 100644 --- a/packages/volt-cli/src/index.ts +++ b/packages/volt-cli/src/index.ts @@ -7,6 +7,7 @@ import { buildCommand } from './commands/build.js'; import { previewCommand } from './commands/preview.js'; import { packageCommand } from './commands/package.js'; import { doctorCommand } from './commands/doctor.js'; +import { createPluginCommand } from './commands/plugin.js'; import { signSetupCommand } from './commands/sign.js'; import { testCommand } from './commands/test.js'; import { updatePublishCommand } from './commands/update.js'; @@ -95,6 +96,8 @@ const signProgram = program .command('sign') .description('Signing setup and tooling helpers'); +program.addCommand(createPluginCommand()); + signProgram .command('setup') .description('Generate signing environment template and prerequisite checks') diff --git a/packages/volt-cli/src/utils/plugin-host-binary.ts b/packages/volt-cli/src/utils/plugin-host-binary.ts new file mode 100644 index 0000000..591204d --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-binary.ts @@ -0,0 +1,50 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ext = process.platform === 'win32' ? '.exe' : ''; + +export async function ensurePluginHostBinary(): Promise { + const configured = process.env.VOLT_PLUGIN_HOST_PATH; + if (configured && existsSync(configured)) { + return configured; + } + + const repoRoot = resolveRepoRoot(); + const debugBinary = resolve(repoRoot, 'target', 'debug', `volt-plugin-host${ext}`); + if (existsSync(debugBinary)) { + return debugBinary; + } + + await execCargoBuild(repoRoot); + if (!existsSync(debugBinary)) { + throw new Error(`[volt:plugin] Failed to locate built plugin host binary at ${debugBinary}`); + } + return debugBinary; +} + +function resolveRepoRoot(): string { + return resolve(fileURLToPath(new URL('../../../../', import.meta.url))); +} + +function execCargoBuild(cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + execFile( + 'cargo', + ['build', '-p', 'volt-plugin-host'], + { cwd }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `[volt:plugin] Failed to build volt-plugin-host: ${stderr || stdout || error.message}`, + ), + ); + return; + } + resolvePromise(); + }, + ); + }); +} diff --git a/packages/volt-cli/src/utils/plugin-host-harness.ts b/packages/volt-cli/src/utils/plugin-host-harness.ts new file mode 100644 index 0000000..5ab09bd --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-harness.ts @@ -0,0 +1,6 @@ +export { createPluginHarnessDataRoot, startPluginHarness } from './plugin-host-harness/host.js'; +export type { + PluginHarnessOptions, + PluginHarnessState, + RunningPluginHarness, +} from './plugin-host-harness/types.js'; diff --git a/packages/volt-cli/src/utils/plugin-host-harness/fs.ts b/packages/volt-cli/src/utils/plugin-host-harness/fs.ts new file mode 100644 index 0000000..ebcf338 --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-harness/fs.ts @@ -0,0 +1,67 @@ +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; + +export function performScopedFsRequest( + baseDir: string, + operation: string, + payload: Record | null, +): unknown { + const path = requireString(payload, 'path'); + const resolved = safeResolve(baseDir, path); + switch (operation) { + case 'read-file': + return readFileSync(resolved, 'utf8'); + case 'write-file': + mkdirSync(dirname(resolved), { recursive: true }); + writeFileSync(resolved, requireString(payload, 'data'), 'utf8'); + return true; + case 'read-dir': + return readdirSync(resolved); + case 'stat': { + const info = statSync(resolved); + return { + size: info.size, + isFile: info.isFile(), + isDir: info.isDirectory(), + readonly: false, + modifiedMs: info.mtimeMs, + createdMs: info.birthtimeMs || null, + }; + } + case 'exists': + try { + statSync(resolved); + return true; + } catch { + return false; + } + case 'mkdir': + mkdirSync(resolved, { recursive: true }); + return true; + case 'remove': + rmSync(resolved, { recursive: true, force: true }); + return true; + default: + throw new Error(`unsupported fs operation '${operation}'`); + } +} + +function requireString(payload: Record | null, key: string): string { + const value = payload?.[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`payload is missing required '${key}' string`); + } + return value; +} + +function safeResolve(baseDir: string, userPath: string): string { + if (userPath.includes('\\') || isAbsolute(userPath)) { + throw new Error(`path traversal is not allowed: ${userPath}`); + } + const resolved = resolve(baseDir, userPath); + const relativePath = relative(resolve(baseDir), resolved); + if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`path escapes base directory: ${userPath}`); + } + return resolved; +} diff --git a/packages/volt-cli/src/utils/plugin-host-harness/host.ts b/packages/volt-cli/src/utils/plugin-host-harness/host.ts new file mode 100644 index 0000000..594d46d --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-harness/host.ts @@ -0,0 +1,150 @@ +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { PluginIpcHost } from '../plugin-ipc-host.js'; +import { ensurePluginHostBinary } from '../plugin-host-binary.js'; +import { performScopedFsRequest } from './fs.js'; +import { performStorageRequest } from './storage.js'; +import type { PluginHarnessOptions, PluginHarnessState, RunningPluginHarness } from './types.js'; + +export async function startPluginHarness( + options: PluginHarnessOptions, +): Promise { + const binary = await ensurePluginHostBinary(); + mkdirSync(options.dataRoot, { recursive: true }); + const host = new PluginIpcHost(); + const state: PluginHarnessState = { + commands: new Set(), + eventSubscriptions: new Set(), + ipcHandlers: new Set(), + emittedEvents: [], + }; + const child = spawn(binary, ['--plugin', '--config', buildConfig(options)], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + host.attach(child); + host.on('message', (message) => handleMessage(host, state, options, message as Record)); + await host.waitForReady(10_000); + + return { + process: child, + state, + async activate() { + const response = await host.signal('activate'); + if (response.error) { + throw new Error(response.error.message); + } + }, + async invokeCommand(id: string, args?: unknown) { + const response = await host.request('plugin:invoke-command', { id, args: args ?? null }); + if (response.error) { + throw new Error(response.error.message); + } + return response.payload; + }, + async shutdown() { + await host.shutdown(5_000); + }, + kill() { + host.kill(); + }, + }; +} + +export function createPluginHarnessDataRoot(pluginId: string): string { + return mkdtempSync(join(tmpdir(), `${pluginId.replace(/\./g, '-')}-`)); +} + +function buildConfig(options: PluginHarnessOptions): string { + return Buffer.from( + JSON.stringify({ + pluginId: options.pluginId, + backendEntry: options.backendEntry, + manifest: options.manifest, + capabilities: options.manifest.capabilities, + dataRoot: options.dataRoot, + delegatedGrants: [], + hostIpcSettings: null, + }), + ).toString('base64'); +} + +function handleMessage( + host: PluginIpcHost, + state: PluginHarnessState, + options: PluginHarnessOptions, + message: Record, +): void { + if (message['type'] !== 'request') { + return; + } + const id = String(message['id']); + const method = String(message['method']); + const payload = (message['payload'] ?? null) as Record | null; + try { + host.sendResponse(id, method, routeRequest(state, options, method, payload)); + } catch (error) { + host.sendError( + id, + method, + 'PLUGIN_TEST_HOST_ERROR', + error instanceof Error ? error.message : String(error), + ); + } +} + +function routeRequest( + state: PluginHarnessState, + options: PluginHarnessOptions, + method: string, + payload: Record | null, +): unknown { + switch (method) { + case 'plugin:register-command': + state.commands.add(requireString(payload, 'id')); + return null; + case 'plugin:unregister-command': + state.commands.delete(requireString(payload, 'id')); + return null; + case 'plugin:subscribe-event': + state.eventSubscriptions.add(requireString(payload, 'event')); + return null; + case 'plugin:unsubscribe-event': + state.eventSubscriptions.delete(requireString(payload, 'event')); + return null; + case 'plugin:register-ipc': + state.ipcHandlers.add(requireString(payload, 'channel')); + return null; + case 'plugin:unregister-ipc': + state.ipcHandlers.delete(requireString(payload, 'channel')); + return null; + case 'plugin:emit-event': + state.emittedEvents.push({ event: requireString(payload, 'event'), data: payload?.['data'] ?? null }); + return null; + case 'plugin:list-grants': + return []; + case 'plugin:bind-grant': + case 'plugin:request-access': + return null; + default: + if (method.startsWith('plugin:fs:')) { + return performScopedFsRequest(options.dataRoot, method.slice('plugin:fs:'.length), payload); + } + if (method.startsWith('plugin:storage:')) { + return performStorageRequest(options.dataRoot, method.slice('plugin:storage:'.length), payload); + } + if (method.startsWith('plugin:grant-fs:')) { + return performScopedFsRequest(options.dataRoot, method.slice('plugin:grant-fs:'.length), payload); + } + throw new Error(`unsupported plugin host request '${method}'`); + } +} + +function requireString(payload: Record | null, key: string): string { + const value = payload?.[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`payload is missing required '${key}' string`); + } + return value; +} diff --git a/packages/volt-cli/src/utils/plugin-host-harness/storage.ts b/packages/volt-cli/src/utils/plugin-host-harness/storage.ts new file mode 100644 index 0000000..ae445e6 --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-harness/storage.ts @@ -0,0 +1,114 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const STORAGE_DIR = 'storage'; +const STORAGE_INDEX = '_index.json'; + +interface StorageIndex { + entries: Record; +} + +export function performStorageRequest( + dataRoot: string, + operation: string, + payload: Record | null, +): unknown { + const storageRoot = join(dataRoot, STORAGE_DIR); + mkdirSync(storageRoot, { recursive: true }); + const index = loadIndex(storageRoot); + + switch (operation) { + case 'get': { + const key = requireKey(payload); + const hash = index.entries[key]; + return hash ? readValueIfPresent(storageRoot, hash) : null; + } + case 'set': { + const key = requireKey(payload); + const hash = hashKey(key); + writeFileSync(join(storageRoot, `${hash}.val`), requireValue(payload), 'utf8'); + index.entries[key] = hash; + saveIndex(storageRoot, index); + return null; + } + case 'has': + return performStorageRequest(dataRoot, 'get', payload) !== null; + case 'delete': { + const key = requireKey(payload); + const hash = index.entries[key]; + delete index.entries[key]; + if (hash) { + rmSync(join(storageRoot, `${hash}.val`), { force: true }); + } + saveIndex(storageRoot, index); + return null; + } + case 'keys': + reconcileIndex(storageRoot, index); + return Object.keys(index.entries); + default: + throw new Error(`unsupported storage operation '${operation}'`); + } +} + +function loadIndex(storageRoot: string): StorageIndex { + const indexPath = join(storageRoot, STORAGE_INDEX); + if (!existsSync(indexPath)) { + return { entries: {} }; + } + return JSON.parse(readFileSync(indexPath, 'utf8')) as StorageIndex; +} + +function saveIndex(storageRoot: string, index: StorageIndex): void { + writeFileSync(join(storageRoot, STORAGE_INDEX), `${JSON.stringify(index, null, 2)}\n`, 'utf8'); +} + +function reconcileIndex(storageRoot: string, index: StorageIndex): void { + for (const [key, hash] of Object.entries(index.entries)) { + if (!existsSync(join(storageRoot, `${hash}.val`))) { + delete index.entries[key]; + } + } + for (const name of readdirSync(storageRoot)) { + if (!name.endsWith('.val')) { + continue; + } + const tracked = Object.values(index.entries).some((hash) => `${hash}.val` === name); + if (!tracked) { + rmSync(join(storageRoot, name), { force: true }); + } + } + saveIndex(storageRoot, index); +} + +function readValueIfPresent(storageRoot: string, hash: string): string | null { + const path = join(storageRoot, `${hash}.val`); + return existsSync(path) ? readFileSync(path, 'utf8') : null; +} + +function hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +function requireKey(payload: Record | null): string { + const key = payload?.['key']; + if (typeof key !== 'string' || key.trim().length === 0) { + throw new Error("payload is missing required 'key' string"); + } + if (key.length > 256 || key.includes('..') || key.includes('/') || key.includes('\\')) { + throw new Error('invalid storage key'); + } + return key; +} + +function requireValue(payload: Record | null): string { + const value = payload?.['value']; + if (typeof value !== 'string') { + throw new Error("payload is missing required 'value' string"); + } + if (value.length > 1024 * 1024) { + throw new Error('storage value exceeds 1048576 bytes'); + } + return value; +} diff --git a/packages/volt-cli/src/utils/plugin-host-harness/types.ts b/packages/volt-cli/src/utils/plugin-host-harness/types.ts new file mode 100644 index 0000000..59d6b87 --- /dev/null +++ b/packages/volt-cli/src/utils/plugin-host-harness/types.ts @@ -0,0 +1,25 @@ +import type { ChildProcess } from 'node:child_process'; +import type { PluginManifest } from '../plugin-manifest.js'; + +export interface PluginHarnessOptions { + pluginId: string; + backendEntry: string; + dataRoot: string; + manifest: PluginManifest; +} + +export interface PluginHarnessState { + commands: Set; + eventSubscriptions: Set; + ipcHandlers: Set; + emittedEvents: Array<{ event: string; data: unknown }>; +} + +export interface RunningPluginHarness { + process: ChildProcess; + state: PluginHarnessState; + activate(): Promise; + invokeCommand(id: string, args?: unknown): Promise; + shutdown(): Promise; + kill(): void; +} diff --git a/packages/volt-cli/src/utils/plugin-ipc-host/host.ts b/packages/volt-cli/src/utils/plugin-ipc-host/host.ts index f760faa..3dd5809 100644 --- a/packages/volt-cli/src/utils/plugin-ipc-host/host.ts +++ b/packages/volt-cli/src/utils/plugin-ipc-host/host.ts @@ -114,6 +114,40 @@ export class PluginIpcHost extends EventEmitter { }); } + signal(method: string, payload: Record | null = null): Promise { + return this.tracker.request({ + type: 'signal', + id: randomUUID(), + method, + payload, + error: null, + }); + } + + sendResponse( + id: string, + method: string, + payload: unknown | null = null, + ): void { + this.writeMessage({ + type: 'response', + id, + method, + payload, + error: null, + }); + } + + sendError(id: string, method: string, code: string, message: string): void { + this.writeMessage({ + type: 'response', + id, + method, + payload: null, + error: { code, message }, + }); + } + async shutdown(timeoutMs = 5000): Promise { if (this.closed) return; this.closed = true; diff --git a/packages/volt-cli/src/utils/plugin-ipc-host/types.ts b/packages/volt-cli/src/utils/plugin-ipc-host/types.ts index e76178e..4e8564f 100644 --- a/packages/volt-cli/src/utils/plugin-ipc-host/types.ts +++ b/packages/volt-cli/src/utils/plugin-ipc-host/types.ts @@ -4,7 +4,7 @@ export interface IpcMessage { type: MessageType; id: string; method: string; - payload: Record | null; + payload: unknown | null; error: { code: string; message: string } | null; }