open-design/apps/daemon/tests/plugins-scaffold.test.ts
Cursor Agent 04784013e6
feat(plugins): od plugin scaffold + od plugin export (Phase 4 author tooling)
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>
2026-05-09 12:22:16 +00:00

84 lines
2.8 KiB
TypeScript

// Phase 4 / spec §14.1 — `od plugin scaffold` unit test.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
ScaffoldError,
scaffoldPlugin,
} from '../src/plugins/scaffold.js';
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-scaffold-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe('scaffoldPlugin', () => {
it('writes SKILL.md + open-design.json + README.md by default', async () => {
const result = await scaffoldPlugin({
targetDir: tmpDir,
id: 'sample-plugin',
});
expect(result.folder).toBe(path.join(tmpDir, 'sample-plugin'));
expect(result.files.map((f) => path.basename(f)).sort()).toEqual([
'README.md',
'SKILL.md',
'open-design.json',
]);
const skillBody = await readFile(path.join(result.folder, 'SKILL.md'), 'utf8');
expect(skillBody).toMatch(/^---/);
expect(skillBody).toMatch(/name: sample-plugin/);
const manifest = JSON.parse(
await readFile(path.join(result.folder, 'open-design.json'), 'utf8'),
);
expect(manifest.name).toBe('sample-plugin');
expect(manifest.od.taskKind).toBe('new-generation');
expect(manifest.od.useCase.query).toMatch(/sample plugin/i);
});
it('humanises the title from the id when --title is omitted', async () => {
const result = await scaffoldPlugin({
targetDir: tmpDir,
id: 'my-cool-plugin',
});
const manifest = JSON.parse(
await readFile(path.join(result.folder, 'open-design.json'), 'utf8'),
);
expect(manifest.title).toBe('My Cool Plugin');
});
it('emits a Claude Code plugin.json when withClaudePlugin=true', async () => {
const result = await scaffoldPlugin({
targetDir: tmpDir,
id: 'sample-plugin',
withClaudePlugin: true,
});
const cpPath = path.join(result.folder, '.claude-plugin', 'plugin.json');
const cpStat = await stat(cpPath);
expect(cpStat.isFile()).toBe(true);
const cp = JSON.parse(await readFile(cpPath, 'utf8'));
expect(cp.name).toBe('sample-plugin');
});
it('refuses to overwrite an existing scaffolded folder', async () => {
await scaffoldPlugin({ targetDir: tmpDir, id: 'sample-plugin' });
await expect(
scaffoldPlugin({ targetDir: tmpDir, id: 'sample-plugin' }),
).rejects.toBeInstanceOf(ScaffoldError);
});
it('rejects unsafe ids', async () => {
await expect(
scaffoldPlugin({ targetDir: tmpDir, id: 'BadID' }),
).rejects.toBeInstanceOf(ScaffoldError);
await expect(
scaffoldPlugin({ targetDir: tmpDir, id: '../escape' }),
).rejects.toBeInstanceOf(ScaffoldError);
});
});