feat(cli): add --out-manifest so derived fields stay out of the source tree

- sign/build accept --out-manifest <path> and write the derived manifest
  (checksum, files, signature) there instead of rewriting drop-plugin.json
- pack now derives the signed manifest in memory and embeds it in the archive,
  so packing no longer dirties the working tree (F-25)
- tests: source manifest untouched after sign --out-manifest and pack
This commit is contained in:
BillyOutlast committed 2026-09-14 21:20:56 -04:00
1 parent 3bb9e1cbc3
commit 4976216ac3
4 files changed
+174 -34

No files matched your search

+27 -5
View File
@@ -14,6 +14,23 @@ import path from "node:path";
const command = process.argv[2];
const args = process.argv.slice(3);
/** Reads `--flag value`; exits with an error when the value is missing. */
function readFlagValue(argv, flag) {
const index = argv.indexOf(flag);
if (index === -1) return undefined;
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
console.error(`${flag} requires a value`);
process.exit(1);
}
return value;
}
/** First positional argument, ignoring flags and their values. */
function positionalArg(argv, flagValues = []) {
return argv.find((arg) => !arg.startsWith("-") && !flagValues.includes(arg));
}
async function runValidate(dir) {
const manifestPath = path.join(
path.resolve(process.cwd(), dir),
@@ -37,7 +54,9 @@ function printUsage() {
Usage:
drop-plugin init [dir] Initialize a new plugin from starter template
drop-plugin build [dir] Bundle server/client entry points with esbuild and sign
(--out-manifest <path> keeps the source manifest clean)
drop-plugin sign [dir] Calculate SHA-256 digests and sign drop-plugin.json
(--out-manifest <path> writes the derived manifest elsewhere)
drop-plugin verify [dir] Verify checksums and signature of a bundle
(--allow-unsigned accepts bundles without a signature)
drop-plugin validate [dir] Validate drop-plugin.json against official schema
@@ -49,10 +68,12 @@ Usage:
async function main() {
switch (command) {
case "sign": {
const dir = args[0] || ".";
const res = await signPlugin(dir);
const outManifest = readFlagValue(args, "--out-manifest");
const dir = positionalArg(args, [outManifest]) || ".";
const res = await signPlugin(dir, undefined, true, { outManifest });
console.log(
`Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})`,
`Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})` +
(outManifest ? `; manifest written to ${outManifest}` : ""),
);
break;
}
@@ -66,8 +87,9 @@ async function main() {
break;
}
case "build": {
const dir = args[0] || ".";
const res = await buildPlugin(dir);
const outManifest = readFlagValue(args, "--out-manifest");
const dir = positionalArg(args, [outManifest]) || ".";
const res = await buildPlugin(dir, { outManifest });
console.log(
`Built plugin at ${dir} (server: ${res.serverBuilt ? "yes" : "no"}, client: ${res.clientBuilt ? "yes" : "no"})`,
);
+5 -1
View File
@@ -8,6 +8,8 @@ export interface BuildOptions {
sourcemap?: boolean;
sign?: boolean;
signingKey?: string;
/** Write derived manifest fields here instead of the source manifest. */
outManifest?: string;
}
export async function buildPlugin(
@@ -96,7 +98,9 @@ export async function buildPlugin(
// 3. Automatically re-sign the plugin bundle after building
if (options.sign !== false) {
await signPlugin(dir, options.signingKey);
await signPlugin(dir, options.signingKey, true, {
outManifest: options.outManifest,
});
}
return { serverBuilt, clientBuilt };
+66 -28
View File
@@ -155,29 +155,28 @@ export async function listFiles(root: string, prefix = ""): Promise<string[]> {
return results.sort((a, b) => a.localeCompare(b));
}
export async function signPlugin(
targetDir: string,
export interface SignPluginOptions {
/**
* Write the derived manifest here instead of `<bundle>/drop-plugin.json`.
* The source manifest is left untouched, so derived fields (checksum, files,
* signature) can live only in a packaged artifact.
*/
outManifest?: string;
}
/**
* Compute the derived manifest (entry checksum, file checksums, signature)
* without writing anything to disk.
*/
async function deriveManifest(
bundleDir: string,
manifest: Record<string, any>,
signingKey?: string,
validate = true,
): Promise<{ fileCount: number; signed: boolean }> {
const resolvedPath = path.resolve(process.cwd(), targetDir);
const bundleDir = await realpath(resolvedPath).catch(() => null);
if (!bundleDir) {
throw new Error(`Directory not found: ${targetDir}`);
}
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
if (validate) {
const validation = await validateManifest(manifest);
if (!validation.valid) {
throw new Error(
`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`,
);
}
}
): Promise<{
manifest: Record<string, any>;
fileCount: number;
signed: boolean;
}> {
// Identify primary entry (v1 or v2 server/client entry)
const entry =
manifest.entry ??
@@ -217,8 +216,39 @@ export async function signPlugin(
delete manifest.signatureVersion;
}
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
return { fileCount: files.length, signed: Boolean(key) };
return { manifest, fileCount: files.length, signed: Boolean(key) };
}
export async function signPlugin(
targetDir: string,
signingKey?: string,
validate = true,
options: SignPluginOptions = {},
): Promise<{ fileCount: number; signed: boolean }> {
const resolvedPath = path.resolve(process.cwd(), targetDir);
const bundleDir = await realpath(resolvedPath).catch(() => null);
if (!bundleDir) {
throw new Error(`Directory not found: ${targetDir}`);
}
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
if (validate) {
const validation = await validateManifest(manifest);
if (!validation.valid) {
throw new Error(
`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`,
);
}
}
const derived = await deriveManifest(bundleDir, manifest, signingKey);
const outPath = options.outManifest
? path.resolve(process.cwd(), options.outManifest)
: manifestPath;
await writeFile(outPath, `${JSON.stringify(derived.manifest, null, 2)}\n`);
return { fileCount: derived.fileCount, signed: derived.signed };
}
export interface VerifyResult {
@@ -341,11 +371,19 @@ export async function packPlugin(
throw new Error(`Directory not found: ${targetDir}`);
}
// Ensure bundle is signed and validated
await signPlugin(bundleDir);
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
const rawManifest = JSON.parse(await readFile(manifestPath, "utf-8"));
const validation = await validateManifest(rawManifest);
if (!validation.valid) {
throw new Error(
`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`,
);
}
// Derive the signed manifest in memory: packing must not dirty the source
// tree, the derived fields live in the archive only.
const { manifest } = await deriveManifest(bundleDir, rawManifest);
const id = manifest.id;
const version = manifest.version;
+76
View File
@@ -434,3 +434,79 @@ test("schema accepts cloudsave:provider in server and client capabilities", asyn
const res = await validateManifest(manifest);
assert.equal(res.valid, true, res.errors.join("; "));
});
test("signPlugin --out-manifest leaves the source manifest untouched", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sign-out-"));
const key = "out-manifest-key";
try {
await fs.writeFile(
path.join(tmpDir, "drop-plugin.json"),
JSON.stringify({
id: "out-manifest",
name: "Out Manifest",
version: "1.0.0",
apiVersion: 2,
targets: ["server"],
server: { entry: "index.js", capabilities: ["routes"] },
}),
"utf-8",
);
await fs.writeFile(path.join(tmpDir, "index.js"), "export {};", "utf-8");
const manifestPath = path.join(tmpDir, "drop-plugin.json");
const before = await fs.readFile(manifestPath, "utf-8");
const outPath = path.join(tmpDir, "derived", "drop-plugin.json");
await fs.mkdir(path.dirname(outPath), { recursive: true });
const res = await signPlugin(tmpDir, key, true, { outManifest: outPath });
assert.equal(res.signed, true);
assert.equal(await fs.readFile(manifestPath, "utf-8"), before);
const derived = JSON.parse(await fs.readFile(outPath, "utf-8"));
assert.equal(derived.signatureVersion, 2);
assert.ok(derived.signature);
assert.ok(derived.files["index.js"]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test("packPlugin leaves the source manifest untouched", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pack-clean-"));
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "pack-out-"));
const key = "pack-clean-key";
const previousKey = process.env.DROP_PLUGIN_SIGNING_KEY;
process.env.DROP_PLUGIN_SIGNING_KEY = key;
try {
await fs.writeFile(
path.join(tmpDir, "drop-plugin.json"),
JSON.stringify({
id: "pack-clean",
name: "Pack Clean",
version: "1.0.0",
apiVersion: 2,
targets: ["server"],
server: { entry: "index.js", capabilities: ["routes"] },
}),
"utf-8",
);
await fs.writeFile(path.join(tmpDir, "index.js"), "export {};", "utf-8");
const manifestPath = path.join(tmpDir, "drop-plugin.json");
const before = await fs.readFile(manifestPath, "utf-8");
const res = await packPlugin(tmpDir, outDir);
assert.equal(await fs.readFile(manifestPath, "utf-8"), before);
const archive = JSON.parse(await fs.readFile(res.packagePath, "utf-8"));
assert.equal(archive.manifest.signatureVersion, 2);
assert.ok(archive.manifest.signature);
assert.ok(archive.manifest.files["index.js"]);
} finally {
if (previousKey === undefined) {
delete process.env.DROP_PLUGIN_SIGNING_KEY;
} else {
process.env.DROP_PLUGIN_SIGNING_KEY = previousKey;
}
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outDir, { recursive: true, force: true });
}
});