mirror of
https://github.com/Heretek-Games/drop-plugin-sdk.git
synced 2026-11-07 10:06:00 -10:00
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:
1 parent
3bb9e1cbc3
commit
4976216ac3
4 files changed
+174
-34
No files matched your search
@@ -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"})`,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user