#!/usr/bin/env node // Package and sign an add-on update, ready to publish behind an updateURL. // // node scripts/sign-addon-update.mjs [--out ] // // - path to the local addon folder to publish (must contain addon.json) // - canonical URL prefix where the tarball will be hosted; // the tarball is placed at /-.tar.gz // - PKCS#8 PEM (as written by generate-update-keypair.mjs) // - --out output dir (default: ./out/); tarball + updates.json entry are written there // // Produces: // /-.tar.gz — the payload the client will fetch // /-.entry.json — one signed entry (append to your live updates.json) // // Publish flow: // 1. scp /-.tar.gz to your web root at // 2. update the live updates.json for that add-on to include this entry // (prepend it — the client picks the highest advertised version) // 3. wait for existing installs to fetch on their next boot's +30s tick import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import process from "node:process"; import os from "node:os"; const SIG_DOMAIN = "silentmode.addon-update-v1"; function die(msg) { console.error("error: " + msg); process.exit(1); } const args = process.argv.slice(2); const outFlagIdx = args.indexOf("--out"); const outDir = outFlagIdx >= 0 ? args.splice(outFlagIdx, 2)[1] : path.resolve("out"); const [addonDir, baseUrl, keyPath] = args; if (!addonDir || !baseUrl || !keyPath) { console.error("usage: node scripts/sign-addon-update.mjs [--out ]"); process.exit(2); } const addonJsonPath = path.join(addonDir, "addon.json"); if (!fs.existsSync(addonJsonPath)) die(`no addon.json in ${addonDir}`); const manifest = JSON.parse(fs.readFileSync(addonJsonPath, "utf8")); const { id, version } = manifest; if (!id || !version) die("addon.json missing id or version"); const keyPem = fs.readFileSync(keyPath, "utf8"); let privateKey; try { privateKey = crypto.createPrivateKey({ key: keyPem, format: "pem" }); } catch (e) { die(`bad private key at ${keyPath}: ${e.message}`); } if (privateKey.asymmetricKeyType !== "ed25519") die("private key must be Ed25519"); fs.mkdirSync(outDir, { recursive: true }); const tarName = `${id}-${version}.tar.gz`; const tarPath = path.join(outDir, tarName); // Tar the CONTENTS of the addon folder (not the folder itself) so entries // live at the archive root: `addon.json`, `editor.html`, etc. The client // extracts into a fresh dir and expects to find addon.json directly there. try { // On Windows, Git-Bash tar (MSYS2) both mistakes drive letters for // host:file remote-archive syntax AND mangles backslashes on its way to // tar's argv. --force-local kills the first, forward-slash paths kill // the second. Win10 built-in bsdtar and GNU tar both accept the flag. const posix = (p) => p.replace(/\\/g, "/"); execFileSync("tar", ["--force-local", "-c", "-z", "-f", posix(tarPath), "-C", posix(path.resolve(addonDir)), "."], { stdio: "inherit" }); } catch (e) { die(`tar failed: ${e.message}`); } const tarBytes = fs.readFileSync(tarPath); const sha256 = crypto.createHash("sha256").update(tarBytes).digest("hex"); const url = baseUrl.replace(/\/+$/, "") + "/" + tarName; const canonical = `${SIG_DOMAIN}|${id}|${version}|${sha256}`; const sig = crypto.sign(null, Buffer.from(canonical), privateKey).toString("base64"); const entry = { version, url, sha256, sig }; const entryPath = path.join(outDir, `${id}-${version}.entry.json`); fs.writeFileSync(entryPath, JSON.stringify(entry, null, 2) + "\n"); console.log(`tarball: ${tarPath} (${tarBytes.length} bytes, ${sha256})`); console.log(`entry: ${entryPath}`); console.log(""); console.log("To publish, host the tarball at " + url + " and add this entry to updates.json:"); console.log(JSON.stringify(entry, null, 2));