mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan G1 + G2 / spec §14.
Two new author-side CLI verbs that close the spec §14 'one repo, every
catalog' loop without leaving the terminal.
apps/daemon/src/plugins/scaffold.ts owns the scaffold path:
od plugin scaffold --id <id> [--title] [--description]
[--task-kind <new-generation|code-migration|
figma-migration|tune-collab>]
[--mode <m>] [--scenario <s>]
[--out <dir>] [--with-claude-plugin]
Writes <out>/<id>/{SKILL.md, open-design.json, README.md} (plus the
optional .claude-plugin/plugin.json for cross-catalog publishing).
Refuses to overwrite an existing scaffold; rejects unsafe ids.
apps/daemon/src/plugins/export.ts + POST /api/applied-plugins/export
own the export path. Three targets:
od → SKILL.md + open-design.json + provenance README
claude-plugin → SKILL.md + .claude-plugin/plugin.json + README
agent-skill → SKILL.md + README only (every catalog accepts this)
od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir>
od plugin export --snapshot-id <id> --as <target> --out <dir>
The exporter pulls SKILL.md straight off the installed plugin's fsPath
when available and otherwise synthesises one from the snapshot's
frozen plugin metadata, so a publishable folder is reproducible even
after od plugin update / uninstall rotates the live source. The
generated open-design.json carries a provenance block recording
snapshot id + manifestSourceDigest so a downstream catalog viewer can
reverse-resolve the originating run.
The HTTP route is loopback-only (requireLocalDaemonRequest) because
it writes to the host filesystem; the CLI is the canonical caller.
Daemon tests: 1455 → 1465 (+10): plugins-scaffold (5 cases) +
plugins-export (5 cases).
Co-authored-by: Tom Huang <1043269994@qq.com>
122 lines
4.8 KiB
TypeScript
122 lines
4.8 KiB
TypeScript
// Phase 4 / spec §14 — `od plugin export` unit test.
|
|
//
|
|
// Exercises the three export targets directly through `exportPlugin()`
|
|
// against an in-memory daemon DB. The HTTP route mounted in
|
|
// server.ts is a thin pass-through, so the public contract is the
|
|
// returned `folder` / `files` / `snapshotId` shape.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import { migratePlugins } from '../src/plugins/persistence.js';
|
|
import { createSnapshot } from '../src/plugins/snapshots.js';
|
|
import { ExportError, exportPlugin } from '../src/plugins/export.js';
|
|
|
|
let db: Database.Database;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-export-'));
|
|
db = new Database(':memory:');
|
|
db.exec(`
|
|
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
|
|
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
|
|
`);
|
|
migratePlugins(db);
|
|
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
db.close();
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function persistSampleSnapshot() {
|
|
return createSnapshot(db, {
|
|
projectId: 'project-1',
|
|
conversationId: null,
|
|
runId: null,
|
|
pluginId: 'sample-plugin',
|
|
pluginVersion: '1.0.0',
|
|
pluginTitle: 'Sample Plugin',
|
|
pluginDescription: 'fixture',
|
|
manifestSourceDigest: 'a'.repeat(64),
|
|
taskKind: 'new-generation' as const,
|
|
inputs: { topic: 'design' },
|
|
resolvedContext: {
|
|
items: [
|
|
{ kind: 'atom', id: 'todo-write', label: 'Todo Write' },
|
|
],
|
|
},
|
|
pipeline: undefined,
|
|
genuiSurfaces: [],
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
assetsStaged: [],
|
|
connectorsRequired: [],
|
|
connectorsResolved: [],
|
|
mcpServers: [],
|
|
query: 'Make a deck.',
|
|
});
|
|
}
|
|
|
|
describe('exportPlugin', () => {
|
|
it('target=od writes SKILL.md + open-design.json + README.md', async () => {
|
|
const snap = persistSampleSnapshot();
|
|
const result = await exportPlugin({ db, snapshotId: snap.snapshotId, target: 'od', outDir: tmpDir });
|
|
expect(result.snapshotId).toBe(snap.snapshotId);
|
|
expect(result.files.map((f) => path.basename(f)).sort()).toEqual([
|
|
'README.md',
|
|
'SKILL.md',
|
|
'open-design.json',
|
|
]);
|
|
const manifest = JSON.parse(
|
|
await readFile(path.join(result.folder, 'open-design.json'), 'utf8'),
|
|
);
|
|
expect(manifest.provenance.snapshotId).toBe(snap.snapshotId);
|
|
expect(manifest.provenance.manifestSourceDigest).toBe('a'.repeat(64));
|
|
});
|
|
|
|
it('target=claude-plugin writes SKILL.md + .claude-plugin/plugin.json', async () => {
|
|
const snap = persistSampleSnapshot();
|
|
const result = await exportPlugin({ db, snapshotId: snap.snapshotId, target: 'claude-plugin', outDir: tmpDir });
|
|
expect(result.files.some((f) => f.endsWith('.claude-plugin/plugin.json'))).toBe(true);
|
|
const cpRaw = await readFile(path.join(result.folder, '.claude-plugin', 'plugin.json'), 'utf8');
|
|
const cp = JSON.parse(cpRaw);
|
|
expect(cp.name).toBe('sample-plugin');
|
|
expect(cp.version).toBe('1.0.0');
|
|
});
|
|
|
|
it('target=agent-skill ships SKILL.md only (plus the audit README)', async () => {
|
|
const snap = persistSampleSnapshot();
|
|
const result = await exportPlugin({ db, snapshotId: snap.snapshotId, target: 'agent-skill', outDir: tmpDir });
|
|
expect(result.files.map((f) => path.basename(f)).sort()).toEqual([
|
|
'README.md',
|
|
'SKILL.md',
|
|
]);
|
|
});
|
|
|
|
it('falls back to the most recent snapshot for a given projectId', async () => {
|
|
persistSampleSnapshot();
|
|
// applied_at is a unix-ms integer; bump the second insert by hand so
|
|
// the ORDER BY DESC tie-break is deterministic regardless of clock
|
|
// resolution (createSnapshot stamps Date.now()).
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const b = persistSampleSnapshot();
|
|
db.prepare('UPDATE applied_plugin_snapshots SET applied_at = applied_at + 100 WHERE id = ?')
|
|
.run(b.snapshotId);
|
|
const result = await exportPlugin({ db, projectId: 'project-1', target: 'od', outDir: tmpDir });
|
|
expect(result.snapshotId).toBe(b.snapshotId);
|
|
});
|
|
|
|
it('throws ExportError when neither snapshot nor project resolves', async () => {
|
|
await expect(
|
|
exportPlugin({ db, snapshotId: 'missing', target: 'od', outDir: tmpDir }),
|
|
).rejects.toBeInstanceOf(ExportError);
|
|
await expect(
|
|
exportPlugin({ db, projectId: 'no-such-project', target: 'od', outDir: tmpDir }),
|
|
).rejects.toBeInstanceOf(ExportError);
|
|
});
|
|
});
|