craftz
#!/usr/bin/env node

The craftz command

What a repository runs, and what an agent reads the rules with. Every subcommand works from the installed package, so the version that checks a repository is the version it reads, and nothing is fetched but what GitHub says about the repository.

craftz rules prints the rules, and craftz rules <id> prints one, which is what a finding names. It works like prose rules, so an agent that knows one knows the other.

craftz check runs every rule on a folder's files, the current one unless another is named. It prints what has drifted and exits 1, like pnpm drift ../markz does for one folder, with no settings read. craftz fix writes every fix in the files, the same ones pnpm align runs, then lists the files it changed, checks again and prints what's left to do by hand. A fix can move versions, so pnpm install comes next, then pnpm run fix for the format.

craftz settings writes what the rules set outside the files, on GitHub and on a site's Worker, for the repository the folder's origin names, then reads it back. --check only reads. Classic branch protection on main isn't craftz's, so removing it asks first, or takes --yes where there's no one to ask. It needs gh logged in as the owner, and cf for a Worker.

craftz status reads what's live: CI on main, the open pull requests, a package's version on npm, and a site's latest build and addresses, as status.ts says. --log prints the latest build's log too. It exits 1 when anything has failed.

craftz deploy deploys a site that's already built, as its ship script, which the Worker's deploy command runs. What it runs follows from the deploy axis. A static site uploads the Build Output with cf deploy --prebuilt, and an app runs wrangler deploy, which is held until cf can deploy one. Each tool is the repository's own dependency, so the version the rules check is the one that deploys.

craftz pr opens a pull request that merges itself, as pr.ts says, and craftz gone deletes the branches that merged, as gone.ts says. A repository runs them as pnpm pr and pnpm gone.

import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { type Target, checkSettings, writeSettings } from "./apply.ts";
import { gone } from "./gone.ts";
import { pr } from "./pr.ts";
import { buildLog, status } from "./status.ts";
import { OWNER, type Pkg, here } from "./reference.ts";
import { fixFiles } from "./rules.ts";
import { cloudflareLogin, githubLogin, hasClassic } from "./settings.ts";
import { RULES_DOC, stated } from "./stated.ts";
import { type Finding, HELD, checkFolder } from "./survey.ts";
import { workerIn } from "./worker.ts";

const HELP = `Usage: craftz <command>

  rules [id]   prints every rule, or one
  check [dir]  runs every rule on a folder, the current one by default
  fix [dir]    writes every fix, then checks what's left
  settings [dir] [--check] [--yes]
               writes the settings on GitHub and Cloudflare, then reads them back
  status [dir] [--log]
               reads what's live: CI, pull requests, npm, the site's build and addresses
  deploy       deploys the site the current folder built, as its ship script
  pr "<title>" [--label <label>] [--body <text>] [--wait]
               opens a pull request that merges itself once CI passes
  gone         deletes the local branches that merged`;

const [command, arg, ...rest] = process.argv.slice(2);

const lines = (f: Finding): string[] => f.drift.map((d) => (f.rule ? `${f.rule}: ${d}` : d));

// Prints a folder's findings, and returns whether anything has drifted.
async function check(dir: string): Promise<boolean> {
  const { findings } = await checkFolder(dir);
  const sees = new Map(stated().map((s) => [s.id, s.see]));
  const drift = findings.filter((f) => !f.held && f.drift.length);
  console.log(drift.length ? `✗ ${dir}` : `✓ ${dir}`);
  for (const line of drift.flatMap(lines)) console.log(`    ${line}`);
  for (const see of new Set(drift.map((f) => sees.get(f.rule ?? "")))) {
    if (see) console.log(`    see: ${see}`);
  }
  for (const f of findings.filter((f) => f.held)) {
    console.log(`    held: ${lines(f).join("; ")}\n      ${f.held}`);
  }
  for (const f of findings.filter((f) => f.unchecked))
    console.log(`    not checked: ${f.unchecked}`);
  return drift.length > 0;
}

// The repository the folder's origin names, which has to be the owner's.
function target(dir: string): Target {
  let origin = "";
  try {
    origin = execFileSync("git", ["-C", dir, "remote", "get-url", "origin"], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "ignore"],
    }).trim();
  } catch {}
  const repo = new RegExp(`github\\.com[:/]${OWNER}/([^/]+?)(?:\\.git)?$`).exec(origin)?.[1];
  if (!repo) {
    console.error(`craftz: ${dir} has no origin on GitHub under ${OWNER}.`);
    process.exit(1);
  }
  const file = (name: string): string =>
    existsSync(join(dir, name)) ? readFileSync(join(dir, name), "utf8") : "";
  const pkg = JSON.parse(file("package.json") || "{}") as Pkg;
  const site = workerIn(dir);
  return { repo, pkg, site, cloudflare: site && cloudflareLogin() };
}

// Deploys the folder's site with the tool its deploy axis names, and returns the exit code.
function deploy(dir: string): number {
  const site = workerIn(dir);
  if (!site) {
    console.error(`craftz deploy: ${dir} has no Worker config, so there's nothing to deploy.`);
    return 1;
  }
  const [tool, ...args] =
    site.deploys === "static" ? ["cf", "deploy", "--prebuilt"] : ["wrangler", "deploy"];
  const bin = join(dir, "node_modules", ".bin", tool ?? "");
  if (!existsSync(bin)) {
    console.error(`craftz deploy: ${site.name} is ${site.deploys}, so it needs ${tool} installed.`);
    return 1;
  }
  console.log(`craftz deploy: ${site.name}, ${site.deploys}, with ${tool} ${args.join(" ")}`);
  return spawnSync(bin, args, { cwd: dir, stdio: "inherit" }).status ?? 1;
}

// Prints what's live for a repository, and returns whether anything has failed.
async function live(dir: string, log: boolean): Promise<boolean> {
  const { repo, pkg, site } = target(dir);
  const { lines, build } = await status(repo, pkg, site);
  const failed = lines.some((line) => !line.ok);
  console.log(failed ? `✗ ${repo}` : `✓ ${repo}`);
  const width = Math.max(...lines.map((line) => line.what.length));
  for (const line of lines) {
    console.log(`    ${line.ok ? "✓" : "✗"} ${line.what.padEnd(width)}  ${line.found}`);
  }
  if (log && build) console.log(`\n${buildLog(build).join("\n")}`);
  return failed;
}

async function ask(question: string): Promise<boolean> {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  const answer = await rl.question(`${question} [y/N] `);
  rl.close();
  return /^y(es)?$/i.test(answer.trim());
}

// Prints a repository's settings, and returns whether any is still off.
async function settings(dir: string, checking: boolean, yes: boolean): Promise<boolean> {
  if (!githubLogin()) {
    console.error("craftz settings needs gh logged in as the owner.");
    process.exit(1);
  }
  const it = target(dir);
  let found: string[];
  if (checking) {
    found = await checkSettings(it);
  } else {
    const removing =
      hasClassic(it.repo) &&
      (yes ||
        (process.stdin.isTTY &&
          (await ask(
            `${it.repo} has classic branch protection on main, which craftz didn't create. Remove it?`,
          ))));
    found = await writeSettings(it, removing);
  }
  const off = found.filter((line) => !line.startsWith("fixed: "));
  console.log(off.length ? `✗ ${it.repo}` : `✓ ${it.repo}`);
  for (const line of found) console.log(`    ${line}`);
  return off.length > 0;
}

if (command === "rules" && !rest.length) {
  if (!arg) {
    process.stdout.write(readFileSync(RULES_DOC, "utf8"));
  } else {
    const rule = stated().find((s) => s.id === arg);
    if (!rule) {
      console.error(`craftz: no rule named ${arg}. \`craftz rules\` prints them all.`);
      process.exit(1);
    }
    console.log(`${rule.id}, under ${rule.part}\n\n${rule.text}`);
  }
} else if (command === "check" && !rest.length) {
  process.exitCode = (await check(resolve(arg ?? "."))) ? 1 : 0;
} else if (command === "fix" && !rest.length) {
  const dir = resolve(arg ?? ".");
  const changed = fixFiles(dir, here, HELD[basename(dir)]);
  if (!changed.length) console.log("Changed nothing.\n");
  else {
    console.log("Changed:");
    for (const file of changed) console.log(`    ${file}`);
    console.log("Run pnpm install, then pnpm run fix.\n");
  }
  process.exitCode = (await check(dir)) ? 1 : 0;
} else if (command === "settings") {
  const flags = [arg, ...rest].filter((it) => it?.startsWith("--"));
  const dirs = [arg, ...rest].filter((it) => it !== undefined && !it.startsWith("--"));
  if (dirs.length > 1 || flags.some((f) => f !== "--check" && f !== "--yes")) {
    console.error(HELP);
    process.exit(1);
  }
  const off = await settings(
    resolve(dirs[0] ?? "."),
    flags.includes("--check"),
    flags.includes("--yes"),
  );
  process.exitCode = off ? 1 : 0;
} else if (command === "status") {
  const flags = [arg, ...rest].filter((it) => it?.startsWith("--"));
  const dirs = [arg, ...rest].filter((it) => it !== undefined && !it.startsWith("--"));
  if (dirs.length > 1 || flags.some((f) => f !== "--log")) {
    console.error(HELP);
    process.exit(1);
  }
  process.exitCode = (await live(resolve(dirs[0] ?? "."), flags.includes("--log"))) ? 1 : 0;
} else if (command === "deploy" && !arg) {
  process.exitCode = deploy(process.cwd());
} else if (command === "pr") {
  await pr([arg, ...rest].filter((it) => it !== undefined));
} else if (command === "gone" && !arg) {
  gone();
} else if (command === "-h" || command === "--help") {
  console.log(HELP);
} else {
  console.error(HELP);
  process.exit(1);
}