← Files WixARCHIVED FILE

skills/wix-headless-kit/install/templates.mjs

10.2 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Where the shipped code is. The verticals live in the skill's repository as a skill of their own,
// `skills/wix-headless-templates/` (each with its code layers, its tools and a composed project
// with a lockfile), not in this skill's folder: this skill stays small and the code is fetched
// once, when first needed.
//
//   node <SKILL_ROOT>/install/templates.mjs [--refresh]      # prints {templates, source, verticals}
//
// Resolution, in order:
//   1. the sibling skill folder, `<SKILL_ROOT>/../wix-headless-templates/`: a checkout of the repository,
//      or a plugin install that carries both skills (every plugin manifest lists the templates skill).
//   2. the cache `<SKILL_ROOT>/templates/`, filled by an earlier call (`--refresh` refetches).
//   3. a fetch: a sparse, shallow clone of `skills/wix-headless-templates/` from the repository the skill was installed
//      from (skills-lock.json's `source`, default wix/skills), at the branch or tag the install
//      named (its `ref`, falling back to the default branch when that ref no longer exists), else
//      the repository's default branch: a skill installed from the repository tracks the repository,
//      so the templates match the kit beside them. A kit that came from a package (no skills-lock.json
//      above it) fetches at the release tag in `install/pins.json` (`templates.ref`, kept equal to the
//      package version by the release flow), the code it was released with; the package carries the
//      templates skill beside the kit anyway, so that fetch is the rare path.
//      `WIX_HEADLESS_FAST_TEMPLATES_REF=<branch|tag|sha>` overrides all of it.
// The cache stays with the project: its `.gitignore` leaves out only the composed `project/`
// folders (the scaffolds with their lockfiles, read once, at create or attach) and the repository
// tooling, so the code layers, playbooks, seeds and readers are committed at the commit the project
// was built from, recorded in `.source`. A caller that needs a `project/` folder passes it as
// `need`; when a committed copy lacks it, that part is fetched at the same commit.
import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

export const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const DEFAULT_REPO = "https://github.com/wix/skills.git";
/** The templates skill's folder in the repository. */
export const TEMPLATES_PATH = "skills/wix-headless-templates";
/** Pinned versions, shared by the install scripts; `templates.ref` is the release tag the fetch defaults to. */
export const PINS = JSON.parse(readFileSync(new URL("./pins.json", import.meta.url), "utf8"));

const git = (args, opts = {}) => spawnSync("git", args, { encoding: "utf8", timeout: 180_000, ...opts });

// The repository and ref the skill was installed from: the nearest skills-lock.json above the
// skill folder, its entry for this skill. "wix/skills" → the default branch of that repository;
// "https://github.com/owner/repo/tree/<ref>" → that ref.
export function installSource() {
  const skill = SKILL_ROOT.split("/").pop();
  let dir = SKILL_ROOT;
  for (let i = 0; i < 6; i++) {
    const p = join(dir, "skills-lock.json");
    if (existsSync(p)) {
      try {
        const entry = JSON.parse(readFileSync(p, "utf8")).skills?.[skill];
        if (typeof entry?.source === "string" && entry.source) {
          const parsed = parseSource(entry.source);
          // `ref` is the branch or tag the install named (skills-lock.json v1 keeps it beside `source`)
          return { ...parsed, ref: parsed.ref ?? (typeof entry.ref === "string" && entry.ref ? entry.ref : null), fromLock: true };
        }
      } catch { /* fall through to the default */ }
      break;
    }
    const up = dirname(dir);
    if (up === dir) break;
    dir = up;
  }
  return { repo: DEFAULT_REPO, ref: null, fromLock: false };
}

function parseSource(src) {
  const m = src.match(/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s#@]+?)(?:\.git)?(?:\/tree\/([^\s]+))?$/);
  if (!m) return { repo: DEFAULT_REPO, ref: null };
  return { repo: `https://github.com/${m[1]}/${m[2]}.git`, ref: m[3] ?? null };
}

export function listVerticals(dir) {
  return readdirSync(dir, { withFileTypes: true })
    .filter((d) => d.isDirectory() && d.name !== "shared" && d.name !== "blank" && existsSync(join(dir, d.name, "app")))
    .map((d) => d.name)
    .sort();
}

export function templatesDir({ refresh = false, need = null } = {}) {
  const checkout = resolve(SKILL_ROOT, "..", "wix-headless-templates");
  if (existsSync(join(checkout, "shared", "app"))) return checkout;
  const cache = join(SKILL_ROOT, "templates");
  if (!refresh && existsSync(join(cache, "shared", "app"))) {
    if (need && !existsSync(join(cache, need))) fetchIgnoredPart(cache, need);
    return cache;
  }
  fetchTemplates(cache);
  return cache;
}

// What a project's repository does not carry: the composed scaffolds (heavy, read once) and the
// repository's own tooling. Everything else in the cache is committed with the project.
const IGNORED = ["*/project/", "blank/", "compose.mjs"];

// A committed copy lacks the ignored parts. Fetch them at the commit the copy came from, so the
// scaffold a create copies matches the code committed beside it; the committed files are untouched.
function fetchIgnoredPart(cache, need) {
  const src = templatesSource(cache);
  const { repo } = installSource();
  const r = cloneSparse(src.repo ?? repo, src.commit ?? process.env.WIX_HEADLESS_FAST_TEMPLATES_REF ?? null);
  const tmp = r.tmp;
  if (r.status !== 0 || !existsSync(join(tmp, TEMPLATES_PATH, need))) {
    rmSync(tmp, { recursive: true, force: true });
    throw new Error(`could not fetch ${TEMPLATES_PATH}/${need} from ${src.repo ?? repo}${src.commit ? ` @ ${src.commit.slice(0, 7)}` : ""}: ${(r.stderr || r.stdout || "not in the clone").trim().slice(-300)}`);
  }
  for (const part of ["blank", ...readdirSync(join(tmp, TEMPLATES_PATH), { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync(join(tmp, TEMPLATES_PATH, d.name, "project"))).map((d) => `${d.name}/project`)]) {
    const from = join(tmp, TEMPLATES_PATH, part);
    if (existsSync(from) && !existsSync(join(cache, part))) cpSync(from, join(cache, part), { recursive: true });
  }
  rmSync(tmp, { recursive: true, force: true });
}

export function templatesSource(dir) {
  const p = join(dir, ".source");
  if (existsSync(p)) { try { return JSON.parse(readFileSync(p, "utf8")); } catch { /* below */ } }
  return { checkout: dir };
}

function fetchTemplates(cache) {
  const { repo, ref: lockRef, fromLock } = installSource();
  const envRef = process.env.WIX_HEADLESS_FAST_TEMPLATES_REF || null;
  // Installed from the repository (a lock above the skill) and no ref named: the default branch, so
  // the templates match the kit. No lock at all is a package install: the release tag it shipped with.
  const pinnedRef = fromLock ? null : (PINS.templates?.ref || null);
  let ref = envRef || lockRef || pinnedRef || null;
  let r = cloneSparse(repo, ref);
  // The branch the skill was installed from can be gone by the time the code is first needed
  // (merged and deleted). The lock's ref then falls back to the default branch; an explicit env
  // ref does not.
  let fellBack = false;
  if (r.status !== 0 && ref && !envRef && /not found|couldn't find remote ref|unknown revision/i.test(r.stderr || "")) {
    // (the pinned tag, too: a fork or a pre-release checkout may not carry it)
    rmSync(r.tmp, { recursive: true, force: true });
    fellBack = true; ref = null;
    r = cloneSparse(repo, null);
  }
  const tmp = r.tmp;
  if (r.error?.code === "ENOENT") {
    rmSync(tmp, { recursive: true, force: true });
    throw new Error("git is not installed or not on PATH; the shipped code is fetched with a git clone of the skill's repository");
  }
  if (r.status !== 0 || !existsSync(join(tmp, TEMPLATES_PATH, "shared", "app"))) {
    rmSync(tmp, { recursive: true, force: true });
    throw new Error(`could not fetch ${TEMPLATES_PATH}/ from ${repo}${ref ? ` @ ${ref}` : ""}: ${(r.stderr || r.stdout || `no ${TEMPLATES_PATH}/shared/app in the clone`).trim().slice(-400)}`);
  }
  const commit = git(["-C", tmp, "rev-parse", "HEAD"]).stdout.trim();
  rmSync(cache, { recursive: true, force: true });
  mkdirSync(dirname(cache), { recursive: true });
  try { renameSync(join(tmp, TEMPLATES_PATH), cache); }
  catch { cpSync(join(tmp, TEMPLATES_PATH), cache, { recursive: true }); }
  rmSync(tmp, { recursive: true, force: true });
  writeFileSync(join(cache, ".gitignore"), IGNORED.join("\n") + "\n");
  writeFileSync(join(cache, ".source"), JSON.stringify({ repo, ref: ref ?? (fellBack ? `default branch (${lockRef || pinnedRef} not found)` : "default branch"), commit, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
}

// A sparse, shallow clone of the templates skill's folder at a ref (branch, tag, or commit id) into a temp dir.
function cloneSparse(repo, ref) {
  const tmp = mkdtempSync(join(tmpdir(), "wix-headless-kit-templates-"));
  let r;
  if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
    // a commit: shallow-fetch just it (GitHub serves any reachable commit by id)
    for (const args of [["init", "-q", tmp], ["-C", tmp, "remote", "add", "origin", repo], ["-C", tmp, "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", ref], ["-C", tmp, "sparse-checkout", "set", TEMPLATES_PATH], ["-C", tmp, "checkout", "-q", "FETCH_HEAD"]]) {
      r = git(args);
      if (r.status !== 0) break;
    }
  } else {
    r = git(["clone", "--quiet", "--depth", "1", "--filter=blob:none", "--sparse", ...(ref ? ["-b", ref] : []), repo, tmp]);
    if (r.status === 0) r = git(["-C", tmp, "sparse-checkout", "set", TEMPLATES_PATH]);
  }
  return { ...r, tmp };
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  try {
    const dir = templatesDir({ refresh: process.argv.includes("--refresh") });
    console.log(JSON.stringify({ templates: dir, source: templatesSource(dir), verticals: listVerticals(dir) }));
  } catch (e) {
    console.log(JSON.stringify({ error: e.message }));
    process.exit(1);
  }
}

SHA-256: daa306135af561683ad698ee66062cc775147f1c95268b932ece3ecef1798e3c