← Files SniparaARCHIVED FILE

lib/build.mjs

10.9 KB · Nov 6, 2026 · 18:20 UTC

↓ Download file

import {
  cpSync,
  existsSync,
  lstatSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  readdirSync,
  realpathSync,
  renameSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { createHash } from "node:crypto";
import { endpoint, MCP_SERVER_NAME, validateConfig } from "./config.mjs";

const OWNER = "snipara-native-plugin-v1";
const json = (path, data) => writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
const inside = (parent, child) => {
  const rel = relative(parent, child);
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
};

function files(root, prefix = "") {
  return readdirSync(join(root, prefix))
    .sort()
    .flatMap((name) => {
      const path = join(prefix, name);
      const entry = lstatSync(join(root, path));
      if (entry.isSymbolicLink()) throw new Error("Plugin profiles cannot contain symlinks.");
      return entry.isDirectory() ? files(root, path) : [path];
    });
}

function assertOwnedOutput(root) {
  const marker = join(root, ".snipara-native-plugin.json");
  if (lstatSync(root).isSymbolicLink() || !existsSync(marker))
    throw new Error("Configure requires an owned Snipara marketplace output.");
  const receipt = JSON.parse(readFileSync(marker, "utf8"));
  if (
    receipt.owner !== OWNER ||
    !Array.isArray(receipt.files) ||
    files(root).some(
      (path) => path !== ".snipara-native-plugin.json" && !receipt.files.includes(path)
    )
  ) {
    throw new Error("Output contains unowned files; preserve them and use a new output directory.");
  }
}

function safeOutput(sourceRoot, outputRoot) {
  const source = realpathSync(sourceRoot);
  let ancestor = outputRoot;
  while (!existsSync(ancestor)) ancestor = dirname(ancestor);
  const output = resolve(realpathSync(ancestor), relative(ancestor, outputRoot));
  if (inside(source, output) || inside(output, source))
    throw new Error("Output cannot overlap the plugin source.");
  if (existsSync(outputRoot)) {
    if (lstatSync(outputRoot).isSymbolicLink()) throw new Error("Output cannot be a symlink.");
    const marker = join(outputRoot, ".snipara-native-plugin.json");
    if (
      readdirSync(outputRoot).length &&
      (!existsSync(marker) || JSON.parse(readFileSync(marker, "utf8")).owner !== OWNER)
    ) {
      throw new Error("Output is not an owned Snipara plugin marketplace; use an empty directory.");
    }
    if (readdirSync(outputRoot).length) assertOwnedOutput(outputRoot);
    for (const name of ["plugins", ".agents", ".claude-plugin"]) {
      if (existsSync(join(outputRoot, name)))
        throw new Error(
          "Owned output is replaced as a complete profile; choose a new output or use configure."
        );
    }
  }
  return output;
}

export function buildPlugin(
  sourceRoot,
  outputPath,
  inputConfig,
  replace = false,
  { bootstrap = false, marketplaceName = "snipara-local", desktopOnly = false } = {}
) {
  const config = validateConfig(inputConfig, !bootstrap);
  const outputRoot = resolve(outputPath);
  // Configure may replace only a complete, marker-owned marketplace, never a repo or installed cache.
  if (replace && existsSync(outputRoot)) {
    assertOwnedOutput(outputRoot);
    const source = realpathSync(sourceRoot);
    const output = realpathSync(outputRoot);
    const ownBundle = source === join(output, "plugins", "snipara");
    if (!ownBundle && (inside(output, source) || inside(source, output)))
      throw new Error("Output cannot overlap the plugin source.");
  } else safeOutput(sourceRoot, outputRoot);
  mkdirSync(dirname(outputRoot), { recursive: true });
  const staging = mkdtempSync(join(dirname(outputRoot), ".snipara-plugin-stage-"));
  try {
    const root = join(staging, "plugins", "snipara");
    mkdirSync(root, { recursive: true });
    for (const name of [
      "catalog",
      "lib",
      "scripts",
      "plugin.json",
      "README.md",
      "VERIFICATION.md",
    ]) {
      cpSync(join(sourceRoot, name), join(root, name), { recursive: true, dereference: false });
    }
    for (const name of [
      "assets",
      "listing.json",
      "review-cases.json",
      "DISTRIBUTION.md",
      "LICENSE",
      "NOTICE",
    ]) {
      if (existsSync(join(sourceRoot, name)))
        cpSync(join(sourceRoot, name), join(root, name), { recursive: true, dereference: false });
    }
    const manifest = JSON.parse(readFileSync(join(root, "plugin.json"), "utf8"));
    // A desktop submission must remain hook-free when the installed settings
    // skill builds or configures a project profile later.
    desktopOnly ||= manifest.extensions?.["com.openai"]?.requires_local_executor === true;
    const desktopMetadata = desktopOnly ? manifest.extensions?.["com.openai"] : null;
    if (desktopOnly) rmSync(join(root, "scripts", "session-start.sh"), { force: true });
    const baseVersion = manifest.version.replace(/-profile\.[a-f0-9]+$/, "");
    const fingerprint = createHash("sha256").update(JSON.stringify(config));
    if (desktopOnly) fingerprint.update("codex-desktop-without-hooks-v1");
    for (const directory of ["catalog", "lib", "scripts"]) {
      for (const path of files(root, directory))
        fingerprint.update(path).update(readFileSync(join(root, path)));
    }
    manifest.version = `${baseVersion}-profile.${fingerprint.digest("hex").slice(0, 12)}`;
    manifest.extensions = {
      "com.openai": {
        interface: {
          displayName: "Snipara",
          shortDescription: "Context, workflow, execution",
          ...(existsSync(join(root, "assets", "icon.png"))
            ? { logo: "./assets/icon.png", composerIcon: "./assets/icon.png" }
            : {}),
          ...(desktopMetadata?.interface || {}),
        },
        ...(desktopMetadata?.review ? { review: desktopMetadata.review } : {}),
        ...(desktopMetadata?.publication ? { publication: desktopMetadata.publication } : {}),
        ...(desktopOnly ? { requires_local_executor: true } : { hooks: "./hooks/hooks.json" }),
        ...(bootstrap ? { onboardingSkill: "./skills/settings/SKILL.md" } : {}),
      },
    };
    json(join(root, "plugin.json"), manifest);
    json(join(root, "config.json"), config);
    // Account OAuth selects each user's project; local runtime setup stays explicit.
    const url = config.connection === "account" || config.project ? endpoint(config) : null;
    json(join(root, "mcp.json"), {
      $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
      mcpServers: url
        ? {
            // Codex rejects unknown streamable-http fields, including the
            // portal auth extension. Native OAuth uses endpoint discovery.
            [MCP_SERVER_NAME]: { type: "streamable-http", url },
          }
        : {},
    });
    // Claude and older Codex loaders use the compatibility HTTP transport spelling.
    json(join(root, ".mcp.json"), {
      mcpServers: url ? { [MCP_SERVER_NAME]: { type: "http", url } } : {},
    });
    mkdirSync(join(root, ".claude-plugin"));
    mkdirSync(join(root, ".codex-plugin"));
    const identity = Object.fromEntries(
      Object.entries(manifest).filter(([key]) => !["$schema", "extensions"].includes(key))
    );
    json(join(root, ".claude-plugin", "plugin.json"), {
      ...identity,
      privacyPolicyUrl: "https://www.snipara.com/privacy",
      supportUrl: "https://www.snipara.com/contact",
      termsOfServiceUrl: "https://www.snipara.com/terms",
      documentationUrl: "https://github.com/Snipara/snipara-claude#readme",
    });
    json(join(root, ".codex-plugin", "plugin.json"), {
      ...identity,
      skills: "./skills/",
      mcpServers: "./.mcp.json",
      ...(desktopOnly ? { requires_local_executor: true } : { hooks: "./hooks/hooks.json" }),
      interface: manifest.extensions["com.openai"].interface,
      ...(bootstrap || desktopOnly
        ? {
            extensions: {
              "com.openai": {
                ...(desktopMetadata?.review ? { review: desktopMetadata.review } : {}),
                ...(desktopMetadata?.publication
                  ? { publication: desktopMetadata.publication }
                  : {}),
                ...(bootstrap ? { onboardingSkill: "./skills/settings/SKILL.md" } : {}),
              },
            },
          }
        : {}),
    });
    mkdirSync(join(root, "skills"));
    for (const name of [
      "settings",
      ...["snipara", "companion", "orchestrator"].filter((name) => config.modules[name]),
    ]) {
      cpSync(join(root, "catalog", name), join(root, "skills", name), { recursive: true });
    }
    if (!desktopOnly) {
      mkdirSync(join(root, "hooks"));
      json(join(root, "hooks", "hooks.json"), {
        hooks: Object.values(config.modules).some(Boolean)
          ? {
              SessionStart: [
                {
                  hooks: [
                    {
                      type: "command",
                      command: 'sh "${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh"',
                      timeout: 5,
                    },
                  ],
                },
              ],
            }
          : {},
      });
    }
    mkdirSync(join(staging, ".claude-plugin"));
    json(join(staging, ".claude-plugin", "marketplace.json"), {
      name: marketplaceName,
      owner: { name: manifest.author.name },
      metadata: {
        description:
          "Snipara project context, Companion workflows and optional Orchestrator execution.",
      },
      plugins: [
        {
          name: "snipara",
          source: "./plugins/snipara",
          description: manifest.description,
          version: manifest.version,
        },
      ],
    });
    mkdirSync(join(staging, ".agents", "plugins"), { recursive: true });
    json(join(staging, ".agents", "plugins", "marketplace.json"), {
      name: marketplaceName,
      interface: { displayName: "Snipara" },
      plugins: [
        {
          name: "snipara",
          source: { source: "local", path: "./plugins/snipara" },
          policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
          category: "Productivity",
        },
      ],
    });
    json(join(staging, ".snipara-native-plugin.json"), {
      owner: OWNER,
      schemaVersion: 1,
      files: files(staging),
    });
    // The old profile is retained until the replacement is complete.
    let backup;
    if (existsSync(outputRoot)) {
      backup = mkdtempSync(join(dirname(outputRoot), ".snipara-plugin-backup-"));
      renameSync(outputRoot, join(backup, "profile"));
    }
    try {
      renameSync(staging, outputRoot);
    } catch (error) {
      if (backup) renameSync(join(backup, "profile"), outputRoot);
      throw error;
    }
    if (backup) rmSync(backup, { recursive: true });
    return {
      output: outputRoot,
      plugin: join(outputRoot, "plugins", "snipara"),
      project: config.project,
      modules: config.modules,
      reloadRequired: true,
    };
  } finally {
    if (existsSync(staging)) rmSync(staging, { recursive: true });
  }
}

SHA-256: 8904f912012182cdb349724b2e88b54410eff54250519bbbe233e95016062aa0