mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): od plugin pack <folder> author distribution archive (Phase 4)
Plan X1 / spec §14 / §16 Phase 4.
apps/daemon/src/plugins/pack.ts ships:
packPlugin({ folder, out? }) \u2192 PackPluginResult
{ outPath, bytes, files, pluginId?, pluginVersion? }
Builds a gzip-compressed tar archive of an author's plugin folder,
ready to install via the installer's HTTPS-tarball path (\u00a73.A6).
Default output path: `<folder>/../<basename>-<manifest.version>.tgz`
when the manifest exposes a version, otherwise <basename>.tgz.
What we put in the archive:
- open-design.json (required \u2014 packPlugin throws when missing)
- SKILL.md / .claude-plugin/plugin.json when present
- Any other plain files in the folder
What we exclude (matches the installer's tarball-extract skiplist):
node_modules / .git / .next / dist / build / out / coverage /
.turbo / .cache / .pnpm-store / .parcel-cache / .svelte-kit /
.nuxt / .astro / .vercel / .vscode / .DS_Store / Thumbs.db
Symlinks are rejected at pack time \u2014 both at walk time and via a
defensive tar filter \u2014 so an author can't pack a symlink and only
discover the rejection at install. The output archive itself is
always excluded from its own contents (avoids the 'pack the
output' spiral when --out lands inside the folder).
CLI: `od plugin pack <folder> [--out <path>] [--json]`. Help block
in printPluginHelp() updated.
Exit codes:
0 archive written
2 CLI usage error
4 pack-time error (missing open-design.json, invalid JSON, etc)
Daemon tests: 1674 \u2192 1683 (+9 cases on plugins-pack: full folder
contents, skiplist enforcement, symlink rejection, default-name
fallback when no version, --out override, round-trip via
tar.extract, missing-manifest rejection, malformed-JSON rejection,
self-archive exclusion).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
fbddf11f8b
commit
1b19837545
4 changed files with 389 additions and 0 deletions
|
|
@ -833,6 +833,7 @@ async function runPlugin(args) {
|
|||
case 'run': return runPluginRun(rest);
|
||||
case 'scaffold': return runPluginScaffold(rest);
|
||||
case 'validate': return runPluginValidate(rest);
|
||||
case 'pack': return runPluginPack(rest);
|
||||
case 'export': return runPluginExport(rest);
|
||||
case 'publish': return runPluginPublish(rest);
|
||||
default:
|
||||
|
|
@ -992,6 +993,83 @@ Exit codes:
|
|||
process.exit(result.ok ? 0 : 4);
|
||||
}
|
||||
|
||||
// Phase 4 / spec §14 / plan §3.X1 — `od plugin pack <folder>`.
|
||||
//
|
||||
// Produces a gzip-compressed tar archive ready to install via the
|
||||
// installer's HTTPS-tarball path. The output path is folder-base +
|
||||
// version when the manifest exposes a version, otherwise folder-base.
|
||||
async function runPluginPack(rest) {
|
||||
const flags = parseFlags(rest, {
|
||||
string: new Set(['out']),
|
||||
boolean: new Set(['help', 'h', 'json']),
|
||||
});
|
||||
if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) {
|
||||
console.log(`Usage:
|
||||
od plugin pack <folder> [--out <path>] [--json]
|
||||
|
||||
Builds a gzip-compressed tar archive of <folder> at --out (default
|
||||
'<folder>/../<basename>-<manifest.version>.tgz'). The archive is the
|
||||
exact shape \`od plugin install --source <https://...>\` consumes.
|
||||
|
||||
Skipped when packing:
|
||||
node_modules / .git / .next / dist / build / out / coverage /
|
||||
.turbo / .cache / .pnpm-store / .parcel-cache / .svelte-kit /
|
||||
.nuxt / .astro / .vercel / .vscode / .DS_Store / Thumbs.db
|
||||
(matches the installer's tarball-extract skiplist).
|
||||
Symlinks are rejected at pack time (consistent with extract-time
|
||||
rejection at install).
|
||||
|
||||
Exit codes:
|
||||
0 archive written
|
||||
2 CLI usage error
|
||||
4 pack-time error (missing open-design.json, invalid JSON, etc)`);
|
||||
process.exit(rest.length === 0 ? 2 : 0);
|
||||
}
|
||||
const folder = rest[0];
|
||||
try {
|
||||
const { packPlugin, PackPluginError } = await import('./plugins/pack.js');
|
||||
let result;
|
||||
try {
|
||||
result = await packPlugin({
|
||||
folder,
|
||||
...(typeof flags.out === 'string' ? { out: flags.out } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PackPluginError) {
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: false, error: err.message }, null, 2) + '\n');
|
||||
} else {
|
||||
console.error(`[pack] ${err.message}`);
|
||||
}
|
||||
process.exit(4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: true,
|
||||
outPath: result.outPath,
|
||||
bytes: result.bytes,
|
||||
fileCount: result.files.length,
|
||||
pluginId: result.pluginId,
|
||||
pluginVersion: result.pluginVersion,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
const idStr = result.pluginVersion
|
||||
? `${result.pluginId ?? 'plugin'}@${result.pluginVersion}`
|
||||
: result.pluginId ?? 'plugin';
|
||||
console.log(`[pack] packed ${idStr}`);
|
||||
console.log(`[pack] out: ${result.outPath}`);
|
||||
console.log(`[pack] files: ${result.files.length}`);
|
||||
console.log(`[pack] bytes: ${result.bytes}`);
|
||||
console.log(`\nNext: od plugin install --source ${result.outPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[pack] failed: ${err?.message ?? err}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
|
||||
//
|
||||
// Produces a publish-ready folder from the AppliedPluginSnapshot
|
||||
|
|
@ -1986,6 +2064,8 @@ function printPluginHelp() {
|
|||
Stage a capability grant (full mutation lands Phase 3).
|
||||
od plugin validate <folder> [--json] Lint a plugin folder before installing
|
||||
(manifest parse + atom + ref checks).
|
||||
od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin
|
||||
folder for distribution.
|
||||
|
||||
Common options:
|
||||
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ export {
|
|||
type ValidatePluginFolderInput,
|
||||
type ValidatePluginFolderResult,
|
||||
} from './validate.js';
|
||||
export {
|
||||
packPlugin,
|
||||
PackPluginError,
|
||||
type PackPluginInput,
|
||||
type PackPluginResult,
|
||||
} from './pack.js';
|
||||
export * from './atoms/build-test.js';
|
||||
export * from './atoms/code-import.js';
|
||||
export * from './atoms/design-extract.js';
|
||||
|
|
|
|||
169
apps/daemon/src/plugins/pack.ts
Normal file
169
apps/daemon/src/plugins/pack.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Phase 4 / spec §14 / plan §3.X1 — `od plugin pack <folder>`.
|
||||
//
|
||||
// Produces a gzip-compressed tar archive of a plugin folder so the
|
||||
// author can hand it to a peer or upload it to a marketplace
|
||||
// without going through GitHub. The installer's HTTPS-tarball
|
||||
// path (§3.A6) consumes the same .tgz shape, so a packed archive
|
||||
// is byte-equal to what `od plugin install --source <https://...>`
|
||||
// would download.
|
||||
//
|
||||
// What we put in the archive:
|
||||
// - open-design.json (required; this is what the installer
|
||||
// resolves first)
|
||||
// - SKILL.md / .claude-plugin/plugin.json when present
|
||||
// - Any other plain files under the folder
|
||||
//
|
||||
// What we exclude:
|
||||
// - node_modules / .git / dist / build / out / coverage
|
||||
// (consistent with the installer's tarball-traversal skiplist
|
||||
// — keeps archive size sane and prevents "ship my whole
|
||||
// development setup" accidents)
|
||||
// - .DS_Store / Thumbs.db (OS noise)
|
||||
// - The output archive itself when --out lands inside the folder
|
||||
// (would otherwise spiral)
|
||||
//
|
||||
// We do NOT chase symlinks (consistent with the installer's
|
||||
// extract-time symlink rejection, §3.A6 plan).
|
||||
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { c as tarCreate } from 'tar';
|
||||
|
||||
export interface PackPluginInput {
|
||||
// Path to the plugin folder. Must contain open-design.json.
|
||||
folder: string;
|
||||
// Absolute path of the output archive. Default:
|
||||
// `<folder>/../<folder-basename>-<version>.tgz` when the manifest
|
||||
// ships a version, otherwise `<folder>/../<folder-basename>.tgz`.
|
||||
out?: string;
|
||||
}
|
||||
|
||||
export interface PackPluginResult {
|
||||
outPath: string;
|
||||
bytes: number;
|
||||
// The set of files added to the archive (POSIX paths, relative
|
||||
// to the folder). Useful for the CLI's audit log.
|
||||
files: string[];
|
||||
// Captured from the manifest at pack time so the CLI can echo
|
||||
// back "packed my-plugin@0.1.2" without the caller re-reading.
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
}
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
|
||||
'.turbo', '.cache', '.pnpm-store', '.parcel-cache', '.svelte-kit',
|
||||
'.nuxt', '.astro', '.vercel', '.vscode',
|
||||
]);
|
||||
const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db']);
|
||||
|
||||
export class PackPluginError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PackPluginError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function packPlugin(input: PackPluginInput): Promise<PackPluginResult> {
|
||||
const folder = path.resolve(input.folder);
|
||||
|
||||
// Confirm the folder shape — open-design.json must exist + parse.
|
||||
let manifestRaw: string;
|
||||
try {
|
||||
manifestRaw = await fsp.readFile(path.join(folder, 'open-design.json'), 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new PackPluginError(`folder ${folder} does not contain open-design.json`);
|
||||
}
|
||||
throw new PackPluginError(`failed to read open-design.json: ${(err as Error).message}`);
|
||||
}
|
||||
let pluginId: string | undefined;
|
||||
let pluginVersion: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(manifestRaw) as { name?: string; version?: string };
|
||||
if (typeof parsed.name === 'string' && parsed.name.length > 0) pluginId = parsed.name;
|
||||
if (typeof parsed.version === 'string' && parsed.version.length > 0) pluginVersion = parsed.version;
|
||||
} catch (err) {
|
||||
throw new PackPluginError(`open-design.json failed to parse as JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const folderBase = path.basename(folder);
|
||||
const defaultOut = pluginVersion
|
||||
? `${folderBase}-${pluginVersion}.tgz`
|
||||
: `${folderBase}.tgz`;
|
||||
const outPath = path.resolve(input.out ?? path.join(path.dirname(folder), defaultOut));
|
||||
|
||||
// Collect every file we'll archive, building a set ahead of the
|
||||
// tar.create call so we can audit the list + reject the case
|
||||
// where the output path lands inside the folder.
|
||||
const files: string[] = [];
|
||||
await walk(folder, '', files, outPath);
|
||||
files.sort();
|
||||
|
||||
// tar.create is symlink-aware via portable mode; the option
|
||||
// `follow: false` is the default. We pass `cwd` so paths in the
|
||||
// archive are folder-relative.
|
||||
await tarCreate(
|
||||
{
|
||||
gzip: true,
|
||||
file: outPath,
|
||||
cwd: folder,
|
||||
portable: true,
|
||||
// Reject symlinks at write time — the installer rejects them
|
||||
// at extract time too. Keeping the contract symmetric stops
|
||||
// an author from packing a symlink and only finding out at
|
||||
// install. The walker also pre-filters them; this is a
|
||||
// belt-and-suspenders pass.
|
||||
filter: (entryPath, stat) => {
|
||||
const candidate = stat as { isSymbolicLink?: () => boolean };
|
||||
if (typeof candidate.isSymbolicLink === 'function' && candidate.isSymbolicLink()) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
files,
|
||||
);
|
||||
|
||||
let bytes = 0;
|
||||
try {
|
||||
const stat = await fsp.stat(outPath);
|
||||
bytes = stat.size;
|
||||
} catch {
|
||||
bytes = 0;
|
||||
}
|
||||
|
||||
const result: PackPluginResult = { outPath, bytes, files };
|
||||
if (pluginId) result.pluginId = pluginId;
|
||||
if (pluginVersion) result.pluginVersion = pluginVersion;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function walk(
|
||||
rootAbs: string,
|
||||
rel: string,
|
||||
out: string[],
|
||||
outArchivePath: string,
|
||||
): Promise<void> {
|
||||
const abs = path.join(rootAbs, rel);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue; // skip symlinks (see filter above)
|
||||
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
const entryAbs = path.join(rootAbs, entryRel);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
await walk(rootAbs, entryRel, out, outArchivePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (SKIP_FILES.has(entry.name)) continue;
|
||||
if (path.resolve(entryAbs) === outArchivePath) continue; // don't pack the archive itself
|
||||
// POSIX paths in the manifest list keep the archive
|
||||
// diff-friendly across platforms.
|
||||
out.push(entryRel.split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
134
apps/daemon/tests/plugins-pack.test.ts
Normal file
134
apps/daemon/tests/plugins-pack.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Phase 4 / plan §3.X1 — packPlugin().
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { x as tarExtract, t as tarList } from 'tar';
|
||||
import { packPlugin, PackPluginError } from '../src/plugins/pack.js';
|
||||
|
||||
let folder: string;
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = await mkdtemp(path.join(os.tmpdir(), 'od-pack-'));
|
||||
folder = path.join(parent, 'my-plugin');
|
||||
await mkdir(folder, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function listArchiveEntries(tgz: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
await tarList({
|
||||
file: tgz,
|
||||
onentry: (entry) => { out.push(entry.path); },
|
||||
});
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe('packPlugin', () => {
|
||||
it('writes a .tgz containing every file in the folder', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({
|
||||
name: 'my-plugin',
|
||||
version: '0.1.2',
|
||||
title: 'Test plugin',
|
||||
od: { taskKind: 'new-generation' },
|
||||
}));
|
||||
await writeFile(path.join(folder, 'SKILL.md'), '---\nname: my-plugin\n---\n# T\n');
|
||||
await mkdir(path.join(folder, 'assets'), { recursive: true });
|
||||
await writeFile(path.join(folder, 'assets', 'logo.svg'), '<svg/>');
|
||||
|
||||
const result = await packPlugin({ folder });
|
||||
expect(result.bytes).toBeGreaterThan(0);
|
||||
expect(result.pluginId).toBe('my-plugin');
|
||||
expect(result.pluginVersion).toBe('0.1.2');
|
||||
// Default output path lands beside the folder, named with the version.
|
||||
expect(path.basename(result.outPath)).toBe('my-plugin-0.1.2.tgz');
|
||||
expect(path.dirname(result.outPath)).toBe(parent);
|
||||
expect(result.files.sort()).toEqual([
|
||||
'SKILL.md', 'assets/logo.svg', 'open-design.json',
|
||||
]);
|
||||
|
||||
const entries = await listArchiveEntries(result.outPath);
|
||||
expect(entries).toEqual([
|
||||
'SKILL.md', 'assets/logo.svg', 'open-design.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips node_modules / .git / dist + .DS_Store noise', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'p', version: '0.0.1' }));
|
||||
for (const dir of ['node_modules', '.git', 'dist', 'build']) {
|
||||
await mkdir(path.join(folder, dir), { recursive: true });
|
||||
await writeFile(path.join(folder, dir, 'noise.txt'), 'x');
|
||||
}
|
||||
await writeFile(path.join(folder, '.DS_Store'), 'noise');
|
||||
|
||||
const result = await packPlugin({ folder });
|
||||
const entries = await listArchiveEntries(result.outPath);
|
||||
expect(entries).toEqual(['open-design.json']);
|
||||
});
|
||||
|
||||
it('skips symlinks both at walk time and at filter time', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'p', version: '0.0.1' }));
|
||||
await writeFile(path.join(folder, 'real.txt'), 'real');
|
||||
await symlink('real.txt', path.join(folder, 'link.txt'));
|
||||
|
||||
const result = await packPlugin({ folder });
|
||||
expect(result.files).toEqual(['open-design.json', 'real.txt']);
|
||||
const entries = await listArchiveEntries(result.outPath);
|
||||
expect(entries).toEqual(['open-design.json', 'real.txt']);
|
||||
});
|
||||
|
||||
it('falls back to <basename>.tgz when the manifest has no version', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'plain' }));
|
||||
const result = await packPlugin({ folder });
|
||||
expect(path.basename(result.outPath)).toBe('my-plugin.tgz');
|
||||
});
|
||||
|
||||
it('honours an explicit --out path', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'p', version: '0.0.1' }));
|
||||
const out = path.join(parent, 'somewhere', 'my-plugin.tgz');
|
||||
await mkdir(path.dirname(out), { recursive: true });
|
||||
const result = await packPlugin({ folder, out });
|
||||
expect(result.outPath).toBe(out);
|
||||
});
|
||||
|
||||
it('round-trips through tar.extract: archive contents match the source tree', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'rt', version: '0.0.1' }));
|
||||
await writeFile(path.join(folder, 'SKILL.md'), '# RT\n');
|
||||
await mkdir(path.join(folder, 'assets'), { recursive: true });
|
||||
await writeFile(path.join(folder, 'assets', 'a.txt'), 'aaa');
|
||||
const result = await packPlugin({ folder });
|
||||
|
||||
const dest = path.join(parent, 'extracted');
|
||||
await mkdir(dest, { recursive: true });
|
||||
await tarExtract({ file: result.outPath, cwd: dest });
|
||||
|
||||
expect((await readFile(path.join(dest, 'open-design.json'), 'utf8')).includes('rt')).toBe(true);
|
||||
expect(await readFile(path.join(dest, 'SKILL.md'), 'utf8')).toBe('# RT\n');
|
||||
expect(await readFile(path.join(dest, 'assets', 'a.txt'), 'utf8')).toBe('aaa');
|
||||
});
|
||||
|
||||
it('rejects a folder without open-design.json', async () => {
|
||||
await writeFile(path.join(folder, 'SKILL.md'), '---\nname: p\n---\n');
|
||||
await expect(packPlugin({ folder })).rejects.toBeInstanceOf(PackPluginError);
|
||||
});
|
||||
|
||||
it('rejects a folder whose open-design.json is malformed JSON', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), '{ broken');
|
||||
await expect(packPlugin({ folder })).rejects.toBeInstanceOf(PackPluginError);
|
||||
});
|
||||
|
||||
it('does not pack the output archive itself when --out lands inside the folder', async () => {
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({ name: 'p', version: '0.0.1' }));
|
||||
await writeFile(path.join(folder, 'a.txt'), 'a');
|
||||
const out = path.join(folder, 'self.tgz');
|
||||
const result = await packPlugin({ folder, out });
|
||||
expect(result.files).toEqual(['a.txt', 'open-design.json']);
|
||||
const entries = await listArchiveEntries(result.outPath);
|
||||
expect(entries).toEqual(['a.txt', 'open-design.json']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue