@biffo/cli 0.142.3 → 0.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +255 -25
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7732,8 +7732,139 @@ siblingCommand.addCommand(siblingCheckIdentityCommand);
7732
7732
  // src/commands/check.ts
7733
7733
  import { Command as Command23 } from "commander";
7734
7734
 
7735
- // src/scripts/check-core-ownership.ts
7735
+ // src/scripts/check-branch-protection.ts
7736
+ import { Octokit as Octokit2 } from "@octokit/rest";
7736
7737
  import { execa as execa5 } from "execa";
7738
+
7739
+ // src/lib/branch-protection-audit.ts
7740
+ function auditBranch(branch, protection) {
7741
+ if (protection === null) {
7742
+ return [
7743
+ {
7744
+ branch,
7745
+ kind: "unprotected",
7746
+ detail: "no branch protection at all \u2014 direct pushes, force-pushes and merges with red or absent checks are all permitted"
7747
+ }
7748
+ ];
7749
+ }
7750
+ const findings = [];
7751
+ const checks = protection.required_status_checks;
7752
+ if (!checks || (checks.contexts ?? []).length === 0) {
7753
+ findings.push({
7754
+ branch,
7755
+ kind: "no-required-checks",
7756
+ detail: "protection exists but requires no status checks, so a PR is mergeable before any check has run"
7757
+ });
7758
+ } else if (checks.strict !== true) {
7759
+ findings.push({
7760
+ branch,
7761
+ kind: "not-strict",
7762
+ detail: "required checks are not strict, so a branch can merge without being up to date with the base"
7763
+ });
7764
+ }
7765
+ if (protection.allow_force_pushes?.enabled === true) {
7766
+ findings.push({ branch, kind: "force-push-allowed", detail: "force-pushes are permitted" });
7767
+ }
7768
+ if (protection.allow_deletions?.enabled === true) {
7769
+ findings.push({ branch, kind: "deletion-allowed", detail: "branch deletion is permitted" });
7770
+ }
7771
+ return findings;
7772
+ }
7773
+ function formatFindings(findings) {
7774
+ return findings.map((f) => ` ${f.branch}: ${f.detail}`).join("\n");
7775
+ }
7776
+
7777
+ // src/scripts/check-branch-protection.ts
7778
+ var BRANCHES = ["dev", "staging", "main"];
7779
+ function tokenFromEnv() {
7780
+ const token = process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"] ?? "";
7781
+ if (!token) {
7782
+ console.error(
7783
+ "\u2717 branch-protection guard: no GitHub token.\n Set GITHUB_TOKEN or GH_TOKEN. Reading protection needs admin on the repo,\n so a default read-only CI token is not enough \u2014 this guard is meant to be\n run deliberately, not on every PR."
7784
+ );
7785
+ process.exit(2);
7786
+ }
7787
+ return token;
7788
+ }
7789
+ async function resolveRepo(explicit) {
7790
+ if (explicit) {
7791
+ const [owner, repo] = explicit.split("/");
7792
+ if (!owner || !repo) {
7793
+ console.error(`\u2717 branch-protection guard: --repo must be "owner/name", got "${explicit}"`);
7794
+ process.exit(2);
7795
+ }
7796
+ return { owner, repo };
7797
+ }
7798
+ const { stdout } = await execa5("git", ["remote", "get-url", "origin"]);
7799
+ const m = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(stdout.trim());
7800
+ if (!m?.[1] || !m[2]) {
7801
+ console.error(
7802
+ `\u2717 branch-protection guard: cannot parse a GitHub repo from origin "${stdout.trim()}"`
7803
+ );
7804
+ process.exit(2);
7805
+ }
7806
+ return { owner: m[1], repo: m[2] };
7807
+ }
7808
+ async function runBranchProtectionCheck(explicitRepo) {
7809
+ const { owner, repo } = await resolveRepo(explicitRepo);
7810
+ const octokit = new Octokit2({
7811
+ auth: tokenFromEnv(),
7812
+ log: { debug: () => {
7813
+ }, info: () => {
7814
+ }, warn: () => {
7815
+ }, error: console.error }
7816
+ });
7817
+ const findings = [];
7818
+ const audited = [];
7819
+ for (const branch of BRANCHES) {
7820
+ try {
7821
+ await octokit.repos.getBranch({ owner, repo, branch });
7822
+ } catch (err) {
7823
+ if (err.status === 404) continue;
7824
+ throw err;
7825
+ }
7826
+ audited.push(branch);
7827
+ try {
7828
+ const { data } = await octokit.repos.getBranchProtection({ owner, repo, branch });
7829
+ findings.push(...auditBranch(branch, data));
7830
+ } catch (err) {
7831
+ if (err.status === 404) {
7832
+ findings.push(...auditBranch(branch, null));
7833
+ continue;
7834
+ }
7835
+ if (err.status === 403) {
7836
+ console.error(
7837
+ `\u2717 branch-protection guard: GitHub returned 403 reading ${owner}/${repo}.
7838
+ Either the token lacks admin, or this org/plan cannot protect private repos.
7839
+ The latter is the condition that caused #715 \u2014 protection was skipped at
7840
+ scaffold time and never revisited. It is a finding, not a reason to pass.`
7841
+ );
7842
+ process.exit(1);
7843
+ }
7844
+ throw err;
7845
+ }
7846
+ }
7847
+ if (audited.length === 0) {
7848
+ console.error(
7849
+ `\u2717 branch-protection guard: ${owner}/${repo} has none of ${BRANCHES.join("/")}.
7850
+ A repo with no dev branch has not been migrated (biffo-template#559).`
7851
+ );
7852
+ process.exit(1);
7853
+ }
7854
+ if (findings.length > 0) {
7855
+ console.error(`\u2717 branch-protection guard: ${owner}/${repo}
7856
+ `);
7857
+ console.error(formatFindings(findings));
7858
+ console.error(
7859
+ "\n Protection is applied once at scaffold time and skipped silently on a 403 (#715).\n Fix with the repo settings API, matching the policy \u2014 not the exact required\n checks, which legitimately differ per repo."
7860
+ );
7861
+ process.exit(1);
7862
+ }
7863
+ console.log(`\u2713 branch-protection guard: ${owner}/${repo} (${audited.join(", ")}) OK`);
7864
+ }
7865
+
7866
+ // src/scripts/check-core-ownership.ts
7867
+ import { execa as execa6 } from "execa";
7737
7868
  var BOLD = "\x1B[1m";
7738
7869
  var DIM = "\x1B[2m";
7739
7870
  var RED = "\x1B[31m";
@@ -7744,7 +7875,7 @@ async function runOwnershipCheck(argv) {
7744
7875
  const stagedFlag = args.indexOf("--staged");
7745
7876
  const staged = stagedFlag !== -1;
7746
7877
  const messageFile = staged ? args[stagedFlag + 1] : void 0;
7747
- const root = (await execa5("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
7878
+ const root = (await execa6("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
7748
7879
  if (!isInstanceRepo(root)) {
7749
7880
  console.log("\u2713 core ownership guard: skipped \u2014 this is the template, which owns these paths.");
7750
7881
  return;
@@ -7753,11 +7884,11 @@ async function runOwnershipCheck(argv) {
7753
7884
  let deletedFiles = [];
7754
7885
  let commitMessage = "";
7755
7886
  if (staged) {
7756
- const { stdout } = await execa5("git", ["diff", "--cached", "--name-status"], { cwd: root });
7887
+ const { stdout } = await execa6("git", ["diff", "--cached", "--name-status"], { cwd: root });
7757
7888
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
7758
7889
  if (messageFile) {
7759
- const { readFileSync: readFileSync25, existsSync: existsSync32 } = await import("fs");
7760
- if (existsSync32(messageFile)) commitMessage = readFileSync25(messageFile, "utf8");
7890
+ const { readFileSync: readFileSync25, existsSync: existsSync34 } = await import("fs");
7891
+ if (existsSync34(messageFile)) commitMessage = readFileSync25(messageFile, "utf8");
7761
7892
  }
7762
7893
  } else {
7763
7894
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -7765,18 +7896,18 @@ async function runOwnershipCheck(argv) {
7765
7896
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
7766
7897
  process.exit(2);
7767
7898
  }
7768
- await execa5("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
7769
- const { stdout } = await execa5("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
7899
+ await execa6("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
7900
+ const { stdout } = await execa6("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
7770
7901
  cwd: root
7771
7902
  });
7772
7903
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
7773
- const { stdout: log2 } = await execa5("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
7904
+ const { stdout: log2 } = await execa6("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
7774
7905
  cwd: root,
7775
7906
  reject: false
7776
7907
  });
7777
7908
  commitMessage = log2;
7778
7909
  }
7779
- const { stdout: gitBranch } = await execa5("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
7910
+ const { stdout: gitBranch } = await execa6("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
7780
7911
  cwd: root,
7781
7912
  reject: false
7782
7913
  });
@@ -7857,12 +7988,103 @@ ${BOLD}If the divergence is deliberate${OFF}
7857
7988
  process.exit(1);
7858
7989
  }
7859
7990
 
7991
+ // src/scripts/check-plugin-collisions.ts
7992
+ import { existsSync as existsSync32 } from "fs";
7993
+ import { join as join32 } from "path";
7994
+ import { execa as execa7 } from "execa";
7995
+
7996
+ // src/lib/plugin-collision-guard.ts
7997
+ import { existsSync as existsSync31, readdirSync as readdirSync13, statSync as statSync7 } from "fs";
7998
+ import { join as join31 } from "path";
7999
+ var PYTEST_SPECIAL = /* @__PURE__ */ new Set(["conftest.py"]);
8000
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([".venv", "node_modules", "__pycache__", ".git", "dist", "build"]);
8001
+ function subdirectories(dir) {
8002
+ if (!existsSync31(dir)) return [];
8003
+ return readdirSync13(dir).filter((entry) => {
8004
+ if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) return false;
8005
+ try {
8006
+ return statSync7(join31(dir, entry)).isDirectory();
8007
+ } catch {
8008
+ return false;
8009
+ }
8010
+ });
8011
+ }
8012
+ function regularPackagesOf(pluginDir2) {
8013
+ return subdirectories(pluginDir2).filter((name) => existsSync31(join31(pluginDir2, name, "__init__.py"))).sort();
8014
+ }
8015
+ function bareTestModulesOf(pluginDir2) {
8016
+ const testsDir = join31(pluginDir2, "tests");
8017
+ if (!existsSync31(testsDir)) return [];
8018
+ if (existsSync31(join31(testsDir, "__init__.py"))) return [];
8019
+ return readdirSync13(testsDir).filter((f) => f.endsWith(".py") && !PYTEST_SPECIAL.has(f)).sort();
8020
+ }
8021
+ function findCollisions(servicesDir, pluginDirs) {
8022
+ const plugins = (pluginDirs ?? subdirectories(servicesDir)).filter((name) => !name.startsWith("_")).filter((name) => name !== "api").sort();
8023
+ const collisions = [];
8024
+ const gather = (kind, namesOf) => {
8025
+ const claims = /* @__PURE__ */ new Map();
8026
+ for (const plugin of plugins) {
8027
+ for (const name of namesOf(join31(servicesDir, plugin))) {
8028
+ claims.set(name, [...claims.get(name) ?? [], plugin]);
8029
+ }
8030
+ }
8031
+ for (const [name, owners] of [...claims.entries()].sort()) {
8032
+ if (owners.length > 1) collisions.push({ kind, name, plugins: owners.sort() });
8033
+ }
8034
+ };
8035
+ gather("regular-package", regularPackagesOf);
8036
+ gather("test-module", bareTestModulesOf);
8037
+ return collisions;
8038
+ }
8039
+ function formatCollisions(collisions) {
8040
+ const lines = [];
8041
+ for (const c of collisions) {
8042
+ if (c.kind === "regular-package") {
8043
+ lines.push(
8044
+ ` regular package '${c.name}' is defined by: ${c.plugins.join(", ")}`,
8045
+ ` Regular packages do not merge across sys.path \u2014 the first found shadows`,
8046
+ ` the rest, and the plugin that breaks is whichever loaded second.`,
8047
+ ` Fix: load these modules by file path instead of making '${c.name}' a`,
8048
+ ` package (drop its __init__.py), or give it a plugin-scoped name.`
8049
+ );
8050
+ } else {
8051
+ lines.push(
8052
+ ` test module '${c.name}' is shipped by: ${c.plugins.join(", ")}`,
8053
+ ` pytest's prepend import mode imports test modules by bare basename, so`,
8054
+ ` only one of these can be collected.`,
8055
+ ` Fix: prefix it with the plugin name, e.g. '${c.plugins[0]}_${c.name}'.`
8056
+ );
8057
+ }
8058
+ }
8059
+ return lines.join("\n");
8060
+ }
8061
+
8062
+ // src/scripts/check-plugin-collisions.ts
8063
+ async function runPluginCollisionCheck() {
8064
+ const root = (await execa7("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
8065
+ const servicesDir = join32(root, "services");
8066
+ if (!existsSync32(servicesDir)) {
8067
+ console.log("\u2713 plugin collision guard: no services/ directory \u2014 nothing to compare");
8068
+ return;
8069
+ }
8070
+ const collisions = findCollisions(servicesDir);
8071
+ if (collisions.length > 0) {
8072
+ console.error("\u2717 plugin collision guard: two plugins claim the same importable name\n");
8073
+ console.error(formatCollisions(collisions));
8074
+ console.error(
8075
+ "\nThis breaks the plugin that loads *second*, which is usually the one that\nwas already working. See biffo-template#688."
8076
+ );
8077
+ process.exit(1);
8078
+ }
8079
+ console.log("\u2713 plugin collision guard: OK");
8080
+ }
8081
+
7860
8082
  // src/scripts/check-plugin-terraform.ts
7861
- import { execa as execa6 } from "execa";
8083
+ import { execa as execa8 } from "execa";
7862
8084
 
7863
8085
  // src/lib/plugin-terraform-guard.ts
7864
- import { existsSync as existsSync31, readFileSync as readFileSync24, readdirSync as readdirSync13 } from "fs";
7865
- import { dirname as dirname9, join as join31, relative as relative6, sep as sep3 } from "path";
8086
+ import { existsSync as existsSync33, readFileSync as readFileSync24, readdirSync as readdirSync14 } from "fs";
8087
+ import { dirname as dirname9, join as join33, relative as relative6, sep as sep3 } from "path";
7866
8088
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
7867
8089
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
7868
8090
  function findPluginManifests(root) {
@@ -7870,16 +8092,16 @@ function findPluginManifests(root) {
7870
8092
  const walk = (dir) => {
7871
8093
  let entries;
7872
8094
  try {
7873
- entries = readdirSync13(dir, { withFileTypes: true });
8095
+ entries = readdirSync14(dir, { withFileTypes: true });
7874
8096
  } catch {
7875
8097
  return;
7876
8098
  }
7877
8099
  for (const entry of entries) {
7878
8100
  if (entry.isDirectory()) {
7879
8101
  if (SKIP_DIRS.has(entry.name)) continue;
7880
- walk(join31(dir, entry.name));
8102
+ walk(join33(dir, entry.name));
7881
8103
  } else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
7882
- found.push(relative6(root, join31(dir, entry.name)).split(sep3).join("/"));
8104
+ found.push(relative6(root, join33(dir, entry.name)).split(sep3).join("/"));
7883
8105
  }
7884
8106
  }
7885
8107
  };
@@ -7904,14 +8126,14 @@ function readSubscriptions(absManifestPath) {
7904
8126
  }
7905
8127
  function checkPluginTerraform(root) {
7906
8128
  const violations = [];
7907
- const coreManifest = existsSync31(join31(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
8129
+ const coreManifest = existsSync33(join33(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
7908
8130
  for (const manifest of findPluginManifests(root)) {
7909
8131
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
7910
- const absManifest = join31(root, manifest);
8132
+ const absManifest = join33(root, manifest);
7911
8133
  const subscriptions = readSubscriptions(absManifest);
7912
8134
  if (subscriptions === null) continue;
7913
8135
  const pluginDir2 = dirname9(absManifest);
7914
- if (existsSync31(join31(pluginDir2, "terraform"))) continue;
8136
+ if (existsSync33(join33(pluginDir2, "terraform"))) continue;
7915
8137
  const relPluginDir = relative6(root, pluginDir2).split(sep3).join("/");
7916
8138
  violations.push({
7917
8139
  manifest,
@@ -7931,7 +8153,7 @@ function formatViolations(violations) {
7931
8153
 
7932
8154
  // src/scripts/check-plugin-terraform.ts
7933
8155
  async function runPluginTerraformCheck() {
7934
- const root = (await execa6("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
8156
+ const root = (await execa8("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
7935
8157
  const violations = checkPluginTerraform(root);
7936
8158
  if (violations.length > 0) {
7937
8159
  console.error("\u2717 plugin Terraform guard: event subscriptions with no infrastructure\n");
@@ -7942,7 +8164,7 @@ async function runPluginTerraformCheck() {
7942
8164
  }
7943
8165
 
7944
8166
  // src/scripts/check-release-subject.ts
7945
- import { execa as execa7 } from "execa";
8167
+ import { execa as execa9 } from "execa";
7946
8168
 
7947
8169
  // src/lib/release-version.ts
7948
8170
  var MINOR_TYPES = /* @__PURE__ */ new Set(["feat"]);
@@ -7980,13 +8202,13 @@ async function runReleaseSubjectCheck(argv) {
7980
8202
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
7981
8203
  process.exit(2);
7982
8204
  }
7983
- const root = (await execa7("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
7984
- await execa7("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
7985
- const { stdout } = await execa7("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
8205
+ const root = (await execa9("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
8206
+ await execa9("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
8207
+ const { stdout } = await execa9("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
7986
8208
  cwd: root
7987
8209
  });
7988
8210
  const changedFiles = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
7989
- const subject = process.env["PR_TITLE"]?.trim() || (await execa7("git", ["log", "-1", "--format=%s"], { cwd: root })).stdout.trim();
8211
+ const subject = process.env["PR_TITLE"]?.trim() || (await execa9("git", ["log", "-1", "--format=%s"], { cwd: root })).stdout.trim();
7990
8212
  const manifest = readCoreManifest(root);
7991
8213
  const { unparseable, bump, templateOwnedChanges, skippedAsInstance } = checkReleaseSubject(
7992
8214
  changedFiles,
@@ -8031,7 +8253,7 @@ async function runReleaseSubjectCheck(argv) {
8031
8253
 
8032
8254
  // src/commands/check.ts
8033
8255
  var checkCommand = new Command23("check").description(
8034
- "Repo guards (ownership, release subject, plugin terraform) \u2014 run in CI and git hooks"
8256
+ "Repo guards (ownership, release subject, plugin terraform, plugin collisions) run in CI and git hooks, plus out-of-band audits (branch protection)"
8035
8257
  );
8036
8258
  checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
8037
8259
  await runOwnershipCheck(rawArgsAfter("ownership"));
@@ -8039,9 +8261,17 @@ checkCommand.command("ownership").description("Refuse changes to template-owned
8039
8261
  checkCommand.command("release-subject").description("Require a Conventional Commits PR title on template-owned changes (#423)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").action(async () => {
8040
8262
  await runReleaseSubjectCheck(rawArgsAfter("release-subject"));
8041
8263
  });
8264
+ checkCommand.command("plugin-collisions").description("Refuse two vendored plugins claiming the same importable name (#688)").action(async () => {
8265
+ await runPluginCollisionCheck();
8266
+ });
8042
8267
  checkCommand.command("plugin-terraform").description("Verify every template-owned plugin declaring infra ships a Terraform module").action(async () => {
8043
8268
  await runPluginTerraformCheck();
8044
8269
  });
8270
+ checkCommand.command("branch-protection").description(
8271
+ "Verify dev/staging/main are actually protected \u2014 scaffolding skips this on a 403 (#715)"
8272
+ ).option("--repo <owner/name>", "Repo to audit; defaults to this checkout's origin remote").action(async (opts) => {
8273
+ await runBranchProtectionCheck(opts.repo);
8274
+ });
8045
8275
  function rawArgsAfter(subcommand) {
8046
8276
  const at = process.argv.indexOf(subcommand);
8047
8277
  return at === -1 ? [] : process.argv.slice(at + 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.142.3",
3
+ "version": "0.144.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",