Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions docs/plugin-cli.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/build.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
57 changes: 57 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
43 changes: 43 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Record<string, unknown>): void {
const manifestPath = resolve(projectDir, 'volt-plugin.json');
const manifest = readJson(manifestPath) as Record<string, unknown>;
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');
}
30 changes: 30 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/harness-fs.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
35 changes: 35 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/harness-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
34 changes: 34 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/init.test.ts
Original file line number Diff line number Diff line change
@@ -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({",
);
});
});
33 changes: 33 additions & 0 deletions packages/volt-cli/src/__tests__/plugin-cli/test-command.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading