b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
401 lines
12 KiB
JavaScript
Executable File
401 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Sync templates from this monorepo to the standalone emdash-cms/templates repo.
|
|
*
|
|
* - Clones emdash-cms/templates to a temp directory
|
|
* - Copies each template, excluding build artifacts
|
|
* - Resolves workspace:* and catalog: versions to real published versions
|
|
* - Commits and pushes a branch, then opens a PR
|
|
*
|
|
* Usage:
|
|
* node scripts/sync-templates-repo.mjs # full run: clone, sync, PR
|
|
* node scripts/sync-templates-repo.mjs --dry-run # sync to temp dir, print diff, don't push
|
|
* node scripts/sync-templates-repo.mjs --local /path/to/repo # sync to a local checkout
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
readdirSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, "..");
|
|
const TEMPLATES_DIR = join(ROOT, "templates");
|
|
const REPO = "emdash-cms/templates";
|
|
|
|
const TEMPLATES = [
|
|
"blog",
|
|
"blog-cloudflare",
|
|
"marketing",
|
|
"marketing-cloudflare",
|
|
"portfolio",
|
|
"portfolio-cloudflare",
|
|
"starter",
|
|
"starter-cloudflare",
|
|
];
|
|
|
|
const EXCLUDE = new Set([
|
|
"node_modules",
|
|
"dist",
|
|
".astro",
|
|
".emdash",
|
|
"CHANGELOG.md",
|
|
// AGENTS-template.md is the canonical per-template body that the
|
|
// sync-template-skills.sh script concatenates with scripts/agents-base.md
|
|
// to produce the final AGENTS.md. The public templates repo only needs
|
|
// the generated AGENTS.md.
|
|
"AGENTS-template.md",
|
|
]);
|
|
|
|
const RE_NON_WHITESPACE_START = /^\S/;
|
|
const RE_CATALOG_ENTRY = /^\s+"?([^"]+)"?:\s+(.+)$/;
|
|
|
|
function parseCatalog() {
|
|
const yaml = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
|
|
const catalog = {};
|
|
let inCatalog = false;
|
|
for (const line of yaml.split("\n")) {
|
|
if (line.startsWith("catalog:")) {
|
|
inCatalog = true;
|
|
continue;
|
|
}
|
|
if (inCatalog && RE_NON_WHITESPACE_START.test(line)) break;
|
|
if (!inCatalog) continue;
|
|
|
|
const match = line.match(RE_CATALOG_ENTRY);
|
|
if (match) catalog[match[1]] = match[2];
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
function getRootPackageManager() {
|
|
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
if (!pkg.packageManager) {
|
|
throw new Error("Root package.json is missing a packageManager field");
|
|
}
|
|
return pkg.packageManager;
|
|
}
|
|
|
|
function collectWorkspaceVersions() {
|
|
const versions = {};
|
|
const dirs = [join(ROOT, "packages"), join(ROOT, "packages/plugins")];
|
|
for (const base of dirs) {
|
|
if (!existsSync(base)) continue;
|
|
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const pkgPath = join(base, entry.name, "package.json");
|
|
if (!existsSync(pkgPath)) continue;
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
if (pkg.name && pkg.version) {
|
|
versions[pkg.name] = pkg.version;
|
|
}
|
|
}
|
|
}
|
|
return versions;
|
|
}
|
|
|
|
function resolveDeps(deps, catalog, workspace) {
|
|
if (!deps) return deps;
|
|
const resolved = {};
|
|
for (const [name, version] of Object.entries(deps)) {
|
|
if (version === "catalog:") {
|
|
resolved[name] = catalog[name] || version;
|
|
} else if (version.startsWith("workspace:")) {
|
|
resolved[name] = workspace[name] ? `^${workspace[name]}` : version;
|
|
} else {
|
|
resolved[name] = version;
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function transformPackageJson(srcPath, catalog, workspace, packageManager) {
|
|
const pkg = JSON.parse(readFileSync(srcPath, "utf8"));
|
|
pkg.packageManager = packageManager;
|
|
pkg.dependencies = resolveDeps(pkg.dependencies, catalog, workspace);
|
|
pkg.devDependencies = resolveDeps(pkg.devDependencies, catalog, workspace);
|
|
if (pkg.peerDependencies) {
|
|
pkg.peerDependencies = resolveDeps(pkg.peerDependencies, catalog, workspace);
|
|
if (Object.keys(pkg.peerDependencies).length === 0) delete pkg.peerDependencies;
|
|
}
|
|
if (pkg.optionalDependencies) {
|
|
pkg.optionalDependencies = resolveDeps(pkg.optionalDependencies, catalog, workspace);
|
|
if (Object.keys(pkg.optionalDependencies).length === 0) delete pkg.optionalDependencies;
|
|
}
|
|
// package.json uses 2-space indentation (npm/pnpm convention) and is
|
|
// excluded from the formatter — never emit tabs here.
|
|
return JSON.stringify(pkg, null, 2) + "\n";
|
|
}
|
|
|
|
/**
|
|
* Generate the standalone template's pnpm-workspace.yaml.
|
|
*
|
|
* This is NOT checked into the monorepo source: templates/* are workspace
|
|
* members here, and a nested pnpm-workspace.yaml would make pnpm treat each
|
|
* as its own workspace root and break workspace:* resolution. It only makes
|
|
* sense in the standalone repo, so the sync synthesizes it.
|
|
*
|
|
* `allowBuilds` (pnpm >=10.26 / pnpm 11; onlyBuiltDependencies was removed
|
|
* in pnpm 11) approves only the native builds each runtime needs and marks
|
|
* the rest reviewed so strictDepBuilds does not fail the install. The
|
|
* security settings harden scaffolded sites against supply-chain attacks.
|
|
*/
|
|
function pnpmWorkspaceYaml(template) {
|
|
const allowBuilds = template.endsWith("-cloudflare")
|
|
? " esbuild: true\n workerd: true\n better-sqlite3: false\n sharp: false"
|
|
: " esbuild: true\n better-sqlite3: true\n sharp: true\n workerd: false";
|
|
return `# Build scripts: allow only what each runtime needs; rest false so
|
|
# strictDepBuilds (pnpm >=10.26 / 11) passes.
|
|
allowBuilds:
|
|
${allowBuilds}
|
|
|
|
# Supply-chain hardening: cooldown on brand-new releases (EmDash exempt)
|
|
# and no non-registry transitive sources. (No trustPolicy: no-downgrade --
|
|
# it trips on upstream provenance regressions we don't control.)
|
|
minimumReleaseAge: 1440
|
|
minimumReleaseAgeExclude:
|
|
- emdash
|
|
- "@emdash-cms/*"
|
|
blockExoticSubdeps: true
|
|
`;
|
|
}
|
|
|
|
function lexists(p) {
|
|
try {
|
|
lstatSync(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function copyDirRecursive(src, dest) {
|
|
mkdirSync(dest, { recursive: true });
|
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
const srcPath = join(src, entry.name);
|
|
const destPath = join(dest, entry.name);
|
|
if (entry.isSymbolicLink()) {
|
|
if (lexists(destPath)) unlinkSync(destPath);
|
|
symlinkSync(readlinkSync(srcPath), destPath);
|
|
} else if (entry.isDirectory()) {
|
|
copyDirRecursive(srcPath, destPath);
|
|
} else {
|
|
cpSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyTemplateDir(src, dest) {
|
|
mkdirSync(dest, { recursive: true });
|
|
|
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
if (EXCLUDE.has(entry.name)) continue;
|
|
// Don't overwrite target-only files
|
|
if (entry.name === "README.md") continue;
|
|
// package.json is handled separately
|
|
if (entry.name === "package.json") continue;
|
|
|
|
const srcPath = join(src, entry.name);
|
|
const destPath = join(dest, entry.name);
|
|
|
|
if (entry.isSymbolicLink()) {
|
|
if (lexists(destPath)) unlinkSync(destPath);
|
|
symlinkSync(readlinkSync(srcPath), destPath);
|
|
} else if (entry.isDirectory()) {
|
|
if (existsSync(destPath)) rmSync(destPath, { recursive: true });
|
|
copyDirRecursive(srcPath, destPath);
|
|
} else {
|
|
cpSync(srcPath, destPath);
|
|
}
|
|
}
|
|
|
|
// Remove files in dest that don't exist in src (except preserved ones).
|
|
// package.json and pnpm-workspace.yaml are generated by the sync, not
|
|
// copied from src, so they must not be pruned here.
|
|
const preserved = new Set(["README.md", "package.json", "pnpm-workspace.yaml"]);
|
|
for (const entry of readdirSync(dest, { withFileTypes: true })) {
|
|
if (preserved.has(entry.name)) continue;
|
|
if (EXCLUDE.has(entry.name)) continue;
|
|
const srcPath = join(src, entry.name);
|
|
if (!existsSync(srcPath)) {
|
|
const destPath = join(dest, entry.name);
|
|
rmSync(destPath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
function git(args, cwd) {
|
|
return execFileSync("git", args, { encoding: "utf8", stdio: "pipe", cwd }).trim();
|
|
}
|
|
|
|
// --- main ---
|
|
|
|
const args = process.argv.slice(2);
|
|
const dryRun = args.includes("--dry-run");
|
|
const localIdx = args.indexOf("--local");
|
|
const localPath = localIdx !== -1 ? args[localIdx + 1] : null;
|
|
if (localIdx !== -1 && !localPath) {
|
|
console.error("Error: --local requires a path argument");
|
|
process.exit(1);
|
|
}
|
|
|
|
const catalog = parseCatalog();
|
|
const workspace = collectWorkspaceVersions();
|
|
const packageManager = getRootPackageManager();
|
|
|
|
console.log("Workspace packages:");
|
|
for (const [name, version] of Object.entries(workspace)) {
|
|
console.log(` ${name} = ${String(version)}`);
|
|
}
|
|
console.log("");
|
|
|
|
let targetDir;
|
|
let tempDir;
|
|
|
|
if (localPath) {
|
|
targetDir = resolve(localPath);
|
|
if (!existsSync(join(targetDir, ".git"))) {
|
|
console.error(`Error: ${targetDir} is not a git repository`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
tempDir = mkdtempSync(join(tmpdir(), "emdash-templates-"));
|
|
console.log(`Cloning ${REPO} to ${tempDir}...`);
|
|
execFileSync("gh", ["repo", "clone", REPO, tempDir, "--", "--depth", "1"], {
|
|
stdio: "pipe",
|
|
});
|
|
// Configure git credential helper so push works with GH_TOKEN
|
|
execFileSync("gh", ["auth", "setup-git"], { stdio: "pipe" });
|
|
targetDir = tempDir;
|
|
}
|
|
|
|
try {
|
|
for (const template of TEMPLATES) {
|
|
const srcDir = join(TEMPLATES_DIR, template);
|
|
const destDir = join(targetDir, template);
|
|
|
|
if (!existsSync(srcDir)) {
|
|
console.log(`Skipping ${template} (not in monorepo)`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`Syncing ${template}`);
|
|
copyTemplateDir(srcDir, destDir);
|
|
|
|
const srcPkg = join(srcDir, "package.json");
|
|
if (existsSync(srcPkg)) {
|
|
writeFileSync(
|
|
join(destDir, "package.json"),
|
|
transformPackageJson(srcPkg, catalog, workspace, packageManager),
|
|
);
|
|
console.log(" Transformed package.json");
|
|
}
|
|
|
|
writeFileSync(join(destDir, "pnpm-workspace.yaml"), pnpmWorkspaceYaml(template));
|
|
console.log(" Wrote pnpm-workspace.yaml");
|
|
}
|
|
|
|
// Also sync screenshots.json
|
|
const screenshotsJson = join(TEMPLATES_DIR, "screenshots.json");
|
|
if (existsSync(screenshotsJson)) {
|
|
cpSync(screenshotsJson, join(targetDir, "screenshots.json"));
|
|
console.log("\nSynced screenshots.json");
|
|
}
|
|
|
|
console.log("");
|
|
|
|
const diff = git(["diff", "--stat"], targetDir);
|
|
const untracked = git(["ls-files", "--others", "--exclude-standard"], targetDir);
|
|
if (!diff && !untracked) {
|
|
console.log("No changes to sync.");
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log("Changes:");
|
|
console.log(diff);
|
|
console.log("");
|
|
|
|
if (dryRun) {
|
|
console.log("Dry run — not pushing.");
|
|
if (tempDir) {
|
|
console.log(`Temp dir preserved at: ${tempDir}`);
|
|
tempDir = undefined; // preserve for inspection
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// Get the emdash version for the branch/commit message
|
|
const emdashVersion = workspace["emdash"] || "unknown";
|
|
const branch = `sync/emdash-v${emdashVersion}`;
|
|
|
|
// Configure git identity in CI
|
|
if (process.env.CI) {
|
|
git(["config", "user.name", "github-actions[bot]"], targetDir);
|
|
git(["config", "user.email", "github-actions[bot]@users.noreply.github.com"], targetDir);
|
|
}
|
|
|
|
git(["checkout", "-B", branch], targetDir);
|
|
git(["add", "-A"], targetDir);
|
|
git(["commit", "-m", `chore: sync templates from emdash v${emdashVersion}`], targetDir);
|
|
git(["push", "--force", "-u", "origin", branch], targetDir);
|
|
|
|
console.log(`Pushed branch: ${branch}`);
|
|
|
|
const prBody = [
|
|
"## Summary",
|
|
"",
|
|
`Synced templates from [emdash v${emdashVersion}](https://github.com/emdash-cms/emdash).`,
|
|
"",
|
|
"Auto-generated by `scripts/sync-templates-repo.mjs`.",
|
|
].join("\n");
|
|
|
|
// Reuse existing PR if one is already open for this branch
|
|
const existingPrs = JSON.parse(
|
|
execFileSync(
|
|
"gh",
|
|
["pr", "list", "--repo", REPO, "--head", branch, "--state", "open", "--json", "url"],
|
|
{ encoding: "utf8", stdio: "pipe", cwd: targetDir },
|
|
),
|
|
);
|
|
|
|
if (existingPrs.length > 0) {
|
|
console.log(`PR already exists: ${existingPrs[0].url}`);
|
|
} else {
|
|
const prUrl = execFileSync(
|
|
"gh",
|
|
[
|
|
"pr",
|
|
"create",
|
|
"--repo",
|
|
REPO,
|
|
"--head",
|
|
branch,
|
|
"--title",
|
|
`chore: sync templates from emdash v${emdashVersion}`,
|
|
"--body",
|
|
prBody,
|
|
],
|
|
{ encoding: "utf8", stdio: "pipe", cwd: targetDir },
|
|
).trim();
|
|
|
|
console.log(`PR: ${prUrl}`);
|
|
}
|
|
} finally {
|
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
|
}
|