Newer
Older
gnexus-creds / extensions / extension / tools / publish-design-pack.js
#!/usr/bin/env node
/**
 * Publish the extension design pack into ../../pack (served by the backend at
 * /api/v1/extension/pack*). Sources: src/content.css, src/popup/popup.css,
 * pack-src/tables.json and pack-src/config.json (optional, hand-edited —
 * generate snapshots with --emit-tables / --emit-config).
 *
 * Content that differs from the currently published pack auto-bumps
 * packVersion; identical content is a no-op. Files land in v<version>/ first
 * and pack.json is written last (via a .tmp rename), so a client never sees a
 * pack.json pointing at files that are not served yet.
 *
 * Usage: node tools/publish-design-pack.js [--version N] [--min-extension X.Y.Z]
 */
const fs = require("fs");
const path = require("path");

const EXTENSION_DIR = path.resolve(__dirname, "..");
const PACK_DIR = path.resolve(EXTENSION_DIR, "..", "pack");
const PACK_JSON = path.join(PACK_DIR, "pack.json");
const PACK_FILES = ["content.css", "popup.css", "tables.json", "config.json"];

function args() {
  const opts = {};
  const argv = process.argv.slice(2);
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === "--version") opts.version = Number(argv[++i]);
    else if (argv[i] === "--min-extension") opts.minExtension = argv[++i];
    else if (argv[i] === "--emit-tables") opts.emitTables = true;
    else if (argv[i] === "--emit-config") opts.emitConfig = true;
    else {
      console.error(`unknown argument: ${argv[i]}`);
      process.exit(1);
    }
  }
  return opts;
}

function ownVersion() {
  const makefile = fs.readFileSync(path.join(EXTENSION_DIR, "Makefile"), "utf8");
  const match = /^VERSION\s*:?=\s*(.+)$/m.exec(makefile);
  return match ? match[1].trim() : "0.0.0";
}

function readPackJson() {
  try {
    return JSON.parse(fs.readFileSync(PACK_JSON, "utf8"));
  } catch {
    return null;
  }
}

function hashAndSize(text) {
  const crypto = require("crypto");
  const bytes = Buffer.from(text, "utf8");
  return { sha256: crypto.createHash("sha256").update(bytes).digest("hex"), size: bytes.length };
}

function emitSnapshots() {
  const opts = args();
  const srcDir = path.join(EXTENSION_DIR, "pack-src");
  fs.mkdirSync(srcDir, { recursive: true });
  if (opts.emitTables) {
    const snapshot = require(path.join(EXTENSION_DIR, "src", "field-detect.js")).keywordTables;
    fs.writeFileSync(
      path.join(srcDir, "tables.json"),
      JSON.stringify(snapshot, null, "\t") + "\n"
    );
    console.log("wrote pack-src/tables.json");
  }
  if (opts.emitConfig) {
    const packShared = require(path.join(EXTENSION_DIR, "src", "pack-shared.js"));
    fs.writeFileSync(
      path.join(srcDir, "config.json"),
      JSON.stringify(packShared.DEFAULT_CONFIG, null, "\t") + "\n"
    );
    console.log("wrote pack-src/config.json");
  }
  if (opts.emitTables || opts.emitConfig) return;
}

function publish() {
  const opts = args();
  if (opts.emitTables || opts.emitConfig) return emitSnapshots();
  const current = readPackJson();
  const sources = {
    "content.css": fs.readFileSync(path.join(EXTENSION_DIR, "src", "content.css"), "utf8"),
    "popup.css": fs.readFileSync(path.join(EXTENSION_DIR, "src", "popup", "popup.css"), "utf8"),
  };
  for (const name of ["tables.json", "config.json"]) {
    const file = path.join(EXTENSION_DIR, "pack-src", name);
    if (fs.existsSync(file)) sources[name] = fs.readFileSync(file, "utf8");
  }

  const unchanged =
    current !== null &&
    PACK_FILES.every((name) => {
      const dir = path.join(PACK_DIR, `v${current.packVersion}`);
      const published = path.join(dir, name);
      return sources[name] === undefined
        ? !fs.existsSync(published)
        : fs.existsSync(published) && fs.readFileSync(published, "utf8") === sources[name];
    });
  if (unchanged) {
    console.log(`pack unchanged (v${current.packVersion}) — nothing to publish`);
    return;
  }

  const version = opts.version || (current ? current.packVersion + 1 : 1);
  if (current && version <= current.packVersion) {
    console.error(`refusing packVersion ${version}: content changed, must be > ${current.packVersion}`);
    process.exit(1);
  }
  const minExtension = opts.minExtension || ownVersion();

  const versionDir = path.join(PACK_DIR, `v${version}`);
  fs.mkdirSync(versionDir, { recursive: true });
  const files = {};
  for (const [name, text] of Object.entries(sources)) {
    fs.writeFileSync(path.join(versionDir, name), text);
    const { sha256, size } = hashAndSize(text);
    if (size > 262144) {
      console.error(`${name} exceeds the 256KB client cap`);
      process.exit(1);
    }
    files[name] = { sha256, size };
  }

  const packJson = {
    packVersion: version,
    minExtensionVersion: minExtension,
    maxExtensionVersion: null,
    publishedAt: new Date().toISOString(),
    files,
  };
  const tmp = PACK_JSON + ".tmp";
  fs.writeFileSync(tmp, JSON.stringify(packJson, null, "\t") + "\n");
  fs.renameSync(tmp, PACK_JSON);
  for (const dir of fs.readdirSync(PACK_DIR)) {
    if (dir === "v" + String(version) || dir === "pack.json") continue;
    if (/^v\d+$/.test(dir))
      console.log(`note: older pack dir ${dir} remains on disk (clients read storage, server serves only v${version})`);
  }
  console.log(`published pack v${version} (minExtensionVersion ${minExtension})`);
}

publish();