#!/usr/bin/env node
/*
 * NepTunes widget author tools.
 *
 *   node widget-tools.mjs keygen [name] [--force]
 *   node widget-tools.mjs validate <Bundle.nepget>
 *   node widget-tools.mjs embed-sign <Bundle.nepget> --key <private.pem>
 *   node widget-tools.mjs embed-verify <Bundle.nepget>
 *
 * This file is PUBLISHED as-is at https://neptunesmac.app/widget-tools.mjs, so
 * it must work from any directory with no NepTunes source tree around it: only
 * node: built-ins, no npm install, and nothing resolved relative to where the
 * script happens to sit. website/test/widgetPackaging.test.js asserts the
 * published copy is byte-identical to this one and runs it from a directory
 * with no node_modules.
 *
 * Ed25519 via Node's built-in node:crypto — same algorithm the app verifies
 * with (CryptoKit Curve25519.Signing). Wire encoding is raw base64 both ends:
 * public key = base64 of the 32 raw bytes, signature = base64 of the 64 raw
 * bytes. No "ed25519:" prefix, no DER on the wire.
 *
 * Keys are written relative to the CURRENT DIRECTORY: the private half to
 * ./.keys/ (mode 0600) and the public half to ./public-keys/. Neither ever
 * leaves the machine.
 */
import {
  generateKeyPairSync, createPrivateKey, createPublicKey, sign, verify, createHash,
} from 'node:crypto';
import {
  existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, readdirSync, realpathSync,
} from 'node:fs';
import { join, resolve, basename } from 'node:path';
import { pathToFileURL } from 'node:url';

// No ROOT / PRIVATE_KEY_DIR / PUBLIC_KEY_DIR here on purpose. They resolved against this
// script's own location, which is meaningless for a downloaded copy and is what sent an
// author's private key to ~/Scripts/.keys/. Everything user-facing is now resolved against
// process.cwd() at call time, and the manifest schema is embedded rather than read from
// a sibling directory that only exists in this repo.

// SubjectPublicKeyInfo header for a 32-byte Ed25519 key. Prepending it turns
// the raw wire bytes back into something createPublicKey() accepts.
const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');

/** Rebuild a KeyObject from the 32 raw public-key bytes carried on the wire. */
export function publicKeyFromBase64(publicKeyBase64) {
  const raw = Buffer.from(publicKeyBase64, 'base64');
  if (raw.length !== 32) throw new Error(`expected a 32-byte Ed25519 public key, got ${raw.length}`);
  return createPublicKey({
    key: Buffer.concat([SPKI_ED25519_PREFIX, raw]),
    format: 'der',
    type: 'spki',
  });
}

/** New Ed25519 keypair: raw base64 public half + PKCS8 PEM private half. */
export function generateKeyPair() {
  const { publicKey, privateKey } = generateKeyPairSync('ed25519');
  // The last 32 bytes of the SPKI DER are the raw public key.
  const raw = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
  return {
    publicKeyBase64: Buffer.from(raw).toString('base64'),
    privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
  };
}

/** Detached Ed25519 signature over `bytes`, base64 of the 64 raw bytes. */
export function signBytes(bytes, privateKeyPem) {
  return sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64');
}

/** True only if `signatureBase64` is a valid signature over `bytes`. Never throws. */
export function verifyBytes(bytes, signatureBase64, publicKeyBase64) {
  try {
    const signature = Buffer.from(signatureBase64, 'base64');
    if (signature.length !== 64) return false;
    return verify(null, bytes, publicKeyFromBase64(publicKeyBase64), signature);
  } catch {
    return false;
  }
}

/** Lower-case hex sha256 — the exact form the feed's `sha256` field carries. */
export function sha256Hex(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

export const BUNDLE_SIG_NAME = 'bundle.sig';

/**
 * A FILE name excluded from signing AND the completeness check (macOS metadata + the sidecar).
 *
 * Deliberately a leaf-name rule only: callers must never use it to prune a directory subtree.
 * The security argument for skipping these names is that they are inert metadata the WebView
 * never executes — a *directory* that happens to be called `._cache` is not inert, and pruning
 * it would let an author ship unsigned, `<script src>`-able JS. Swift's `enumerateBundle`
 * descends unconditionally; this side must match.
 */
export function isIgnoredBundleFile(basename) {
  return basename === BUNDLE_SIG_NAME
    || basename === '.DS_Store'
    || basename === '.localized'
    || basename.startsWith('._');
}

/**
 * The single path-safety rule, applied identically when signing and when verifying — so the
 * signer can never produce a bundle its own verifier rejects. Mirrors Swift
 * `WidgetPathValidator.isValidEntry` + the `\\`-anywhere rule: no traversal, no absolute
 * path, no backslash, no C0/DEL control characters (Foundation truncates at NUL).
 */
export function isUnsafeBundlePath(path) {
  return typeof path !== 'string'
    || path.length === 0
    || path.includes('..')
    || path.startsWith('/')
    || path.includes('\\')
    // eslint-disable-next-line no-control-regex
    || /[\u0000-\u001F\u007F]/.test(path);
}

/** Base64 of the 32 raw public-key bytes, derived from a PKCS8 PEM private key. */
export function publicKeyBase64FromPrivatePem(privateKeyPem) {
  const der = createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'der' });
  return Buffer.from(der.subarray(-32)).toString('base64');
}

/** The exact UTF-8 bytes the bundle.sig signature covers. Mirror of Swift signatureManifest(for:). */
export function bundleSignatureMessage(sig) {
  const lines = [
    'neptunes-bundle-sig',
    String(sig.version),
    sig.algorithm,
    sig.authorPublicKey,
    ...sig.files.map((f) => `${f.sha256}  ${f.path}`),
  ];
  return Buffer.from(lines.join('\n') + '\n', 'utf8');
}

/** Recursive, POSIX-relative, NFC file paths in a bundle, minus ignore-list + symlinks. */
export function listBundleFiles(bundleDir) {
  const out = [];
  const walk = (dir, rel) => {
    for (const name of readdirSync(dir)) {
      const abs = join(dir, name);
      const st = lstatSync(abs);
      if (st.isSymbolicLink()) continue; // signer refuses to sign a symlink target
      const relPath = rel ? `${rel}/${name}` : name;
      // Directories are ALWAYS descended — the ignore list is a leaf-file rule (see
      // isIgnoredBundleFile). Filtering by name here would prune whole subtrees.
      if (st.isDirectory()) { walk(abs, relPath); continue; }
      if (st.isFile() && !isIgnoredBundleFile(name)) out.push(relPath.normalize('NFC'));
    }
  };
  walk(bundleDir, '');
  return out;
}

/** Sign every non-ignored file, write bundle.sig, and sync manifest.authorPublicKey. */
export function embedSignBundle(bundleDir, privateKeyPem) {
  const authorPublicKey = publicKeyBase64FromPrivatePem(privateKeyPem);
  const files = listBundleFiles(bundleDir).map((path) => {
    // Refuse at sign time whatever embedVerifyBundle would refuse, so we never emit a
    // bundle.sig this tool (or the app) cannot verify.
    if (isUnsafeBundlePath(path)) throw new Error(`unsafe path ${JSON.stringify(path)} — rename it before signing`);
    return { path, sha256: sha256Hex(readFileSync(join(bundleDir, ...path.split('/')))) };
  });
  files.sort((a, b) => Buffer.compare(Buffer.from(a.path, 'utf8'), Buffer.from(b.path, 'utf8')));
  const seen = new Set();
  for (const f of files) { if (seen.has(f.path)) throw new Error(`duplicate path ${f.path}`); seen.add(f.path); }

  const sig = {
    version: 1, algorithm: 'ed25519', authorPublicKey, files,
  };
  sig.signature = signBytes(bundleSignatureMessage(sig), privateKeyPem);

  // Sync manifest.authorPublicKey (inject or assert-match).
  const manifestPath = join(bundleDir, 'manifest.json');
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
  if (manifest.authorPublicKey && manifest.authorPublicKey !== authorPublicKey) {
    throw new Error(`manifest.authorPublicKey (${manifest.authorPublicKey}) != signing key (${authorPublicKey})`);
  }
  if (!manifest.authorPublicKey) {
    manifest.authorPublicKey = authorPublicKey;
    writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
    // manifest.json changed → recompute its hash and re-sign.
    return embedSignBundle(bundleDir, privateKeyPem);
  }
  writeFileSync(join(bundleDir, BUNDLE_SIG_NAME), JSON.stringify(sig, null, 2) + '\n');
  return sig;
}

/** Verify a signed bundle: signature first, then per-file hashes, then completeness. */
export function embedVerifyBundle(bundleDir) {
  const errors = [];
  const fail = (m) => { errors.push(m); return { valid: false, errors }; };

  let sig;
  try { sig = JSON.parse(readFileSync(join(bundleDir, BUNDLE_SIG_NAME), 'utf8')); }
  catch { return fail('bundle.sig missing or not JSON'); }
  // `null` and scalars parse fine, so the catch above never fires for them.
  if (typeof sig !== 'object' || sig === null) return fail('bundle.sig is not an object');
  if (sig.algorithm !== 'ed25519') return fail(`unsupported algorithm ${sig.algorithm}`);
  if (sig.version !== 1) return fail(`unsupported version ${sig.version}`);
  if (!Array.isArray(sig.files)) return fail('files[] missing');

  let manifest;
  try { manifest = JSON.parse(readFileSync(join(bundleDir, 'manifest.json'), 'utf8')); }
  catch { return fail('manifest.json missing or not JSON'); }
  if (typeof manifest !== 'object' || manifest === null) return fail('manifest.json is not an object');
  if (sig.authorPublicKey !== manifest.authorPublicKey) return fail('bundle.sig key != manifest key');

  // Entry shape, before anything indexes into an entry (Buffer.from(undefined) throws).
  for (const f of sig.files) {
    if (typeof f !== 'object' || f === null || typeof f.path !== 'string' || typeof f.sha256 !== 'string') {
      return fail('malformed files[] entry');
    }
  }

  // Well-formedness: sorted by UTF-8 bytes, unique.
  for (let i = 1; i < sig.files.length; i++) {
    const cmp = Buffer.compare(Buffer.from(sig.files[i - 1].path, 'utf8'), Buffer.from(sig.files[i].path, 'utf8'));
    if (cmp >= 0) return fail('files[] not sorted or has duplicates');
  }

  // Signature first — establishes files[] is trustworthy.
  if (!verifyBytes(bundleSignatureMessage(sig), sig.signature, sig.authorPublicKey)) return fail('signature invalid');

  // Per-file hashes.
  for (const f of sig.files) {
    if (isUnsafeBundlePath(f.path)) return fail(`unsafe path ${JSON.stringify(f.path)}`);
    let bytes;
    try { bytes = readFileSync(join(bundleDir, ...f.path.split('/'))); } catch { return fail(`missing ${f.path}`); }
    if (sha256Hex(bytes) !== f.sha256) return fail(`hash mismatch ${f.path}`);
  }

  // Completeness.
  const actual = new Set(listBundleFiles(bundleDir));
  const listed = new Set(sig.files.map((f) => f.path.normalize('NFC')));
  for (const p of actual) if (!listed.has(p)) return fail(`unlisted file ${p}`);
  for (const p of listed) if (!actual.has(p)) return fail(`listed-but-missing ${p}`);

  return { valid: errors.length === 0, errors };
}

/**
 * Write a keypair to `./.keys/` and `./public-keys/`, relative to the CURRENT
 * DIRECTORY.
 *
 * Deliberately not relative to this script. These paths used to be derived from
 * the script's own location, which was invisible while the tool only ever lived
 * at Scripts/widget-tools.mjs — but it is now published as a download, so a copy
 * saved in ~/Downloads wrote the author's private key to ~/Scripts/.keys/: a
 * directory they never chose, would not think to look in, and would not know to
 * back up. Losing that key means never being able to update the widget again,
 * because the id is pinned to it on first signed install.
 *
 * Resolved per call rather than at import, so the answer is "where you ran it".
 */
export function writeKeyPair(name, { force = false } = {}) {
  const privateDir = resolve(process.cwd(), '.keys');
  const publicDir = resolve(process.cwd(), 'public-keys');
  const privatePath = join(privateDir, `${name}.pem`);
  const publicPath = join(publicDir, `${name}.pub`);
  if (existsSync(privatePath) && !force) {
    throw new Error(`${privatePath} already exists — pass --force to overwrite (this destroys the old key)`);
  }
  const { publicKeyBase64, privateKeyPem } = generateKeyPair();
  mkdirSync(privateDir, { recursive: true });
  mkdirSync(publicDir, { recursive: true });
  writeFileSync(privatePath, privateKeyPem, { mode: 0o600 });
  writeFileSync(publicPath, `${publicKeyBase64}\n`);
  return { publicKeyBase64, privatePath, publicPath };
}

/** Read a committed .pub file (base64 raw public key, one line). */
export function readPublicKeyFile(path) {
  return readFileSync(path, 'utf8').trim();
}

/**
 * The manifest schema, embedded.
 *
 * This used to be read from SampleWidgets/manifest.schema.json, resolved against
 * this script's own location. That works inside the repo and not at all for the
 * published download, where `validate` died on a raw ENOENT stack trace before
 * checking anything — the single most useful command in the tool, broken for
 * every third-party author.
 *
 * Embedding keeps the download one self-contained file with no network access
 * required. It is a copy, so `the embedded manifest schema matches
 * SampleWidgets/manifest.schema.json` in widgetTools.test.js fails the build if
 * the two ever diverge; regenerate with Scripts/embed-manifest-schema.mjs.
 */
export const MANIFEST_SCHEMA = Object.freeze({
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://neptunesmac.app/schemas/nepget-manifest.schema.json",
    "title": "NepTunes .nepget widget manifest",
    "description": "Schema for manifest.json inside a .nepget bundle. Validate with: node widget-tools.mjs validate MyWidget.nepget",
    "type": "object",
    "additionalProperties": false,
    "required": [
      "manifestVersion",
      "id",
      "name",
      "version",
      "entry",
      "defaultSize"
    ],
    "properties": {
      "manifestVersion": {
        "const": 1
      },
      "id": {
        "type": "string",
        "pattern": "^[A-Za-z0-9]+(\\.[A-Za-z0-9-]+)+$",
        "description": "Reverse-domain identifier. Stable forever: it is the update-feed key and the TOFU pinning key."
      },
      "name": {
        "type": "string",
        "minLength": 1
      },
      "version": {
        "type": "string",
        "pattern": "^\\d+\\.\\d+\\.\\d+$",
        "description": "major.minor.patch, numeric only. Must increase for the app to offer an update."
      },
      "author": {
        "type": "string"
      },
      "description": {
        "type": "string"
      },
      "license": {
        "type": "string"
      },
      "homepage": {
        "type": "string",
        "pattern": "^https?://"
      },
      "minNepTunesVersion": {
        "type": "string",
        "pattern": "^\\d+\\.\\d+\\.\\d+$",
        "description": "If set and newer than the running app, the update is shown as blocked."
      },
      "authorPublicKey": {
        "type": "string",
        "pattern": "^[A-Za-z0-9+/]{43}=$",
        "description": "Base64 of the 32 raw bytes of the author's Ed25519 public key (from widget-tools.mjs keygen). No ed25519: prefix. Required when the bundle ships a bundle.sig, and must equal bundle.sig.authorPublicKey; declaring it without a bundle.sig makes the bundle refuse to install. Pinned on first install; a change forces re-consent."
      },
      "releaseNotes": {
        "type": "string",
        "description": "What changed in this version. Copied into the update feed and shown in the update consent sheet."
      },
      "entry": {
        "type": "string",
        "pattern": "\\.html$"
      },
      "preview": {
        "type": "string"
      },
      "icon": {
        "type": "string"
      },
      "resizable": {
        "type": "boolean",
        "description": "Whether the user may resize the window, by dragging its edges or a resize handle the widget draws itself. Defaults to true when omitted. Does not affect NepTunes.setSize, which a fixed widget may still call to size itself to its own content."
      },
      "defaultSize": {
        "$ref": "#/$defs/size"
      },
      "minSize": {
        "$ref": "#/$defs/size"
      },
      "maxSize": {
        "$ref": "#/$defs/size"
      },
      "permissions": {
        "type": "array",
        "items": {
          "enum": [
            "artwork",
            "playbackControl",
            "volumeControl",
            "love",
            "ratingControl",
            "shuffleRepeatControl",
            "playerActivation",
            "lastFm"
          ]
        }
      },
      "settings": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "schema"
        ],
        "properties": {
          "schema": {
            "type": "array",
            "items": {
              "$ref": "#/$defs/setting"
            }
          }
        }
      }
    },
    "$defs": {
      "size": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "width",
          "height"
        ],
        "properties": {
          "width": {
            "type": "integer",
            "minimum": 1
          },
          "height": {
            "type": "integer",
            "minimum": 1
          }
        }
      },
      "setting": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "type",
          "label"
        ],
        "properties": {
          "id": {
            "type": "string",
            "minLength": 1
          },
          "type": {
            "enum": [
              "checkbox",
              "slider",
              "color",
              "radio",
              "select",
              "text"
            ]
          },
          "label": {
            "type": "string",
            "minLength": 1
          },
          "default": {
            "type": [
              "boolean",
              "number",
              "string"
            ]
          },
          "min": {
            "type": "number"
          },
          "max": {
            "type": "number"
          },
          "step": {
            "type": "number"
          },
          "placeholder": {
            "type": "string"
          },
          "options": {
            "type": "array",
            "items": {
              "$ref": "#/$defs/option"
            }
          }
        }
      },
      "option": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "value",
          "label"
        ],
        "properties": {
          "value": {
            "type": "string"
          },
          "label": {
            "type": "string"
          }
        }
      }
    }
  });

export function loadManifestSchema() {
  return JSON.parse(JSON.stringify(MANIFEST_SCHEMA));
}

const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);

function jsonType(value) {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
  return typeof value;
}

function matchesType(value, type) {
  if (type === 'integer') return Number.isInteger(value);
  if (type === 'number') return typeof value === 'number';
  if (type === 'object') return isPlainObject(value);
  if (type === 'array') return Array.isArray(value);
  if (type === 'null') return value === null;
  return typeof value === type;
}

/*
 * Draft 2020-12 subset validator. Deliberately hand-rolled: the website has no
 * JSON-Schema dependency and this tool must run from a bare checkout with no
 * npm install. Supports exactly what manifest.schema.json uses — $ref into
 * $defs, type, const, enum, required, properties, additionalProperties:false,
 * items, pattern, minLength, minimum, maximum, minItems.
 */
export function schemaErrors(value, schema, root = schema, path = '$') {
  if (schema.$ref) {
    const target = schema.$ref.replace(/^#\//, '').split('/').reduce((o, k) => (o ? o[k] : undefined), root);
    if (!target) return [`${path}: unresolved $ref ${schema.$ref}`];
    return schemaErrors(value, target, root, path);
  }

  const errors = [];
  if ('const' in schema && value !== schema.const) {
    errors.push(`${path}: expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
  }
  if (schema.enum && !schema.enum.includes(value)) {
    errors.push(`${path}: ${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
  }
  if (schema.type) {
    const types = Array.isArray(schema.type) ? schema.type : [schema.type];
    if (!types.some((t) => matchesType(value, t))) {
      return [...errors, `${path}: expected ${types.join(' | ')}, got ${jsonType(value)}`];
    }
  }

  if (typeof value === 'string') {
    if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
      errors.push(`${path}: ${JSON.stringify(value)} does not match ${schema.pattern}`);
    }
    if (schema.minLength !== undefined && value.length < schema.minLength) {
      errors.push(`${path}: shorter than minLength ${schema.minLength}`);
    }
  }
  if (typeof value === 'number') {
    if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path}: below minimum ${schema.minimum}`);
    if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path}: above maximum ${schema.maximum}`);
  }
  if (Array.isArray(value)) {
    if (schema.minItems !== undefined && value.length < schema.minItems) {
      errors.push(`${path}: fewer than minItems ${schema.minItems}`);
    }
    if (schema.items) {
      value.forEach((item, i) => errors.push(...schemaErrors(item, schema.items, root, `${path}[${i}]`)));
    }
  }
  if (isPlainObject(value)) {
    for (const key of schema.required ?? []) {
      if (!(key in value)) errors.push(`${path}: missing required property "${key}"`);
    }
    const properties = schema.properties ?? {};
    for (const [key, sub] of Object.entries(properties)) {
      if (key in value) {
        errors.push(...schemaErrors(value[key], sub, root, path === '$' ? `$.${key}` : `${path}.${key}`));
      }
    }
    if (schema.additionalProperties === false) {
      for (const key of Object.keys(value)) {
        if (!(key in properties)) errors.push(`${path}: unknown property "${key}"`);
      }
    }
  }
  return errors;
}

/** Structural validation of a .nepget directory: schema + referenced files exist. */
export function validateBundle(bundleDir) {
  const manifestPath = join(bundleDir, 'manifest.json');
  if (!existsSync(manifestPath)) return { valid: false, errors: [`${manifestPath} not found`] };

  let manifest;
  try {
    manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
  } catch (error) {
    return { valid: false, errors: [`manifest.json is not valid JSON: ${error.message}`] };
  }

  const errors = schemaErrors(manifest, loadManifestSchema());
  if (typeof manifest.entry === 'string' && !existsSync(join(bundleDir, manifest.entry))) {
    errors.push(`entry "${manifest.entry}" not found in bundle`);
  }
  if (typeof manifest.preview === 'string' && !existsSync(join(bundleDir, manifest.preview))) {
    errors.push(`preview "${manifest.preview}" not found in bundle`);
  }
  if (typeof manifest.icon === 'string' && !existsSync(join(bundleDir, manifest.icon))) {
    errors.push(`icon "${manifest.icon}" not found in bundle`);
  }
  // An error, not a warning. This is the one combination the app rejects outright
  // (`declaresKeyWithoutSignature`), so passing it here would bless a bundle nobody can
  // install — claiming an author identity without proving it is worse than claiming none.
  // A bundle that declares no key and ships no signature is still fine: that is the legacy
  // unsigned case, which installs for an id that was never signed before.
  if (manifest.authorPublicKey && !existsSync(join(bundleDir, BUNDLE_SIG_NAME))) {
    errors.push(`manifest declares authorPublicKey but the bundle has no ${BUNDLE_SIG_NAME} — run embed-sign`);
  }
  return { valid: errors.length === 0, errors };
}

function usage() {
  console.error(`usage:
  widget-tools.mjs keygen [name] [--force]      once per author; writes ./.keys + ./public-keys
  widget-tools.mjs validate <Bundle.nepget>     manifest, paths, and the signature if present
  widget-tools.mjs embed-sign <Bundle.nepget> --key <private.pem>
  widget-tools.mjs embed-verify <Bundle.nepget> exactly what NepTunes checks on install and load`);
  return 2;
}

export function main(argv) {
  const [command, ...rest] = argv;
  switch (command) {
    case 'keygen': {
      const force = rest.includes('--force');
      const name = rest.find((a) => !a.startsWith('--')) ?? 'author';
      const { publicKeyBase64, privatePath, publicPath } = writeKeyPair(name, { force });
      // "back this up" rather than "gitignored": most readers of this line are now
      // outside this repo, and losing the key means never shipping an update for
      // the widget again — the id is pinned to it on first signed install.
      console.log(`private key -> ${privatePath} (mode 0600 — BACK THIS UP, it is how you ship updates)`);
      console.log(`public key  -> ${publicPath}`);
      console.log(publicKeyBase64);
      return 0;
    }
    // `sign` and `verify` used to be here: raw Ed25519 over an arbitrary file. They exist
    // for package-widgets.mjs to sign the update feed with the VENDOR release key, and it
    // imports signBytes/verifyBytes directly rather than shelling out — so nothing needed
    // them on the CLI. On a public download they were an attractive nuisance:
    // `sign MyWidget.nepget.zip --key …` produces a real-looking signature that is NOT the
    // one NepTunes checks. `embed-sign` is. Removing them leaves exactly the four commands
    // a widget author needs; the functions stay exported.
    case 'validate': {
      const [bundleDir] = rest;
      if (!bundleDir) return usage();
      const { valid, errors } = validateBundle(bundleDir);
      if (valid) {
        const sigPath = join(bundleDir, BUNDLE_SIG_NAME);
        if (existsSync(sigPath)) {
          const { valid: sigValid, errors: sigErrors } = embedVerifyBundle(bundleDir);
          if (!sigValid) errors.push(...sigErrors);
        }
        // The declares-a-key-without-a-signature case is now an error raised by
        // validateBundle itself, so it fails the command rather than printing a warning
        // beside a zero exit status.
      }
      if (errors.length === 0) {
        console.log(`OK ${basename(bundleDir)}`);
        return 0;
      }
      for (const error of errors) console.error(`  ${error}`);
      console.error(`FAILED ${basename(bundleDir)} (${errors.length} problem(s))`);
      return 1;
    }
    case 'embed-sign': {
      const keyIndex = rest.indexOf('--key');
      const dir = rest.find((a) => !a.startsWith('--') && rest.indexOf(a) !== keyIndex + 1);
      const keyPath = keyIndex === -1 ? undefined : rest[keyIndex + 1];
      if (!dir || !keyPath) return usage();
      const sig = embedSignBundle(dir, readFileSync(keyPath, 'utf8'));
      console.log(`signed ${basename(dir)} (${sig.files.length} files) as ${sig.authorPublicKey}`);
      return 0;
    }
    case 'embed-verify': {
      const [dir] = rest;
      if (!dir) return usage();
      const { valid, errors } = embedVerifyBundle(dir);
      if (valid) { console.log(`OK ${basename(dir)}`); return 0; }
      for (const e of errors) console.error(`  ${e}`);
      console.error(`FAILED ${basename(dir)}`);
      return 1;
    }
    default:
      return usage();
  }
}

// `import.meta.url` is always the RESOLVED real path, while `process.argv[1]` is whatever
// the user typed. Compare them without realpathSync and any symlink in the invocation makes
// the two differ, the CLI never runs, and node exits 0 having printed nothing — which for a
// signing tool is the worst available failure, because it is indistinguishable from success
// and the author ships an unsigned bundle. On macOS /tmp and /var are symlinks, so
// `node /tmp/widget-tools.mjs embed-sign …` hit this exactly.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  process.exit(main(process.argv.slice(2)));
}
