@biffo/cli 0.33.5 → 0.34.1

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.
package/core.version CHANGED
@@ -1 +1 @@
1
- 0.33.5
1
+ 0.34.1
package/dist/index.js CHANGED
@@ -697,12 +697,22 @@ var GitHubAdapter = class {
697
697
  log.info(`Created branch ${branch} from ${from}`);
698
698
  }
699
699
  /**
700
- * Read a file's decoded UTF-8 content at `ref`, or `undefined` if it is absent
701
- * (404) or is not a regular file (a directory or submodule).
700
+ * Read a file's decoded UTF-8 content at `ref` (the repo's default branch when
701
+ * omitted), or `undefined` if it is absent (404) or is not a regular file (a
702
+ * directory or submodule).
703
+ *
704
+ * Public because `biffo teardown` reads the sibling registry and the sibling
705
+ * marker file straight from GitHub (issue #306) — teardown must work from a
706
+ * machine that never cloned the project.
702
707
  */
703
708
  async getFileContent(org, repo, path, ref) {
704
709
  try {
705
- const { data } = await this.octokit.repos.getContent({ owner: org, repo, path, ref });
710
+ const { data } = await this.octokit.repos.getContent({
711
+ owner: org,
712
+ repo,
713
+ path,
714
+ ...ref ? { ref } : {}
715
+ });
706
716
  if (Array.isArray(data) || data.type !== "file" || typeof data.content !== "string") {
707
717
  return void 0;
708
718
  }
@@ -906,6 +916,37 @@ var GitHubAdapter = class {
906
916
  }
907
917
  }
908
918
  }
919
+ /**
920
+ * Does this repository exist (and is it visible to our token)?
921
+ *
922
+ * Used by `biffo teardown` to tell "sibling registered, repo since deleted"
923
+ * apart from "sibling repo still live", which decide different teardown paths.
924
+ */
925
+ async repoExists(org, repo) {
926
+ try {
927
+ await this.octokit.repos.get({ owner: org, repo });
928
+ return true;
929
+ } catch (err) {
930
+ if (err.status === 404) return false;
931
+ throw err;
932
+ }
933
+ }
934
+ /**
935
+ * Head branch names of the repo's open pull requests.
936
+ *
937
+ * Teardown uses this to spot siblings whose registration PR has not merged
938
+ * yet (branch `biffo/register-sibling-<name>`) — they exist as real repos and
939
+ * real AWS resources, but nothing has landed in the merged registry.
940
+ */
941
+ async listOpenPullRequests(org, repo) {
942
+ const prs = await this.octokit.paginate(this.octokit.pulls.list, {
943
+ owner: org,
944
+ repo,
945
+ state: "open",
946
+ per_page: 100
947
+ });
948
+ return prs.map((pr) => ({ number: pr.number, headRef: pr.head.ref }));
949
+ }
909
950
  /**
910
951
  * Read a repository Actions variable's value, or `undefined` if it isn't set.
911
952
  * A 404 means the variable doesn't exist on the repo (not an error) — used to
@@ -5814,6 +5855,129 @@ import { GetCallerIdentityCommand as GetCallerIdentityCommand3, STSClient as STS
5814
5855
  import chalk20 from "chalk";
5815
5856
  import { Command as Command22 } from "commander";
5816
5857
  import inquirer8 from "inquirer";
5858
+
5859
+ // src/lib/sibling-teardown.ts
5860
+ var SiblingResolutionError = class extends Error {
5861
+ constructor(message) {
5862
+ super(message);
5863
+ this.name = "SiblingResolutionError";
5864
+ }
5865
+ };
5866
+ var REGISTRY_ENVIRONMENTS = ["dev", "staging", "prod"];
5867
+ function registryPath(environment) {
5868
+ return `infra/environments/${environment}/siblings.auto.tfvars.json`;
5869
+ }
5870
+ var REGISTRATION_BRANCH_PREFIX = "biffo/register-sibling-";
5871
+ function parseSiblingBucketDomain(domain) {
5872
+ const host = /^(?<bucket>[a-z0-9][a-z0-9.-]*)\.s3(?:\.[a-z0-9-]+)?\.amazonaws\.com$/.exec(domain);
5873
+ const bucket = host?.groups?.["bucket"];
5874
+ if (!bucket) return null;
5875
+ const parts = /^(?<project>.+)-(?<env>dev|staging|prod)-site-(?<account>\d{12})$/.exec(bucket);
5876
+ const project = parts?.groups?.["project"];
5877
+ const env = parts?.groups?.["env"];
5878
+ const account = parts?.groups?.["account"];
5879
+ if (!project || !env || !account) return null;
5880
+ return { projectName: project, environment: env, accountId: account };
5881
+ }
5882
+ function parseRegistry(contents) {
5883
+ if (contents === void 0 || contents.trim() === "") return [];
5884
+ let parsed;
5885
+ try {
5886
+ parsed = JSON.parse(contents);
5887
+ } catch {
5888
+ throw new SiblingResolutionError(
5889
+ "A siblings.auto.tfvars.json in the core repo is not valid JSON. Refusing to tear down while the sibling registry cannot be read \u2014 fix or remove the file and re-run."
5890
+ );
5891
+ }
5892
+ const origins = parsed.sibling_origins;
5893
+ if (origins === void 0) return [];
5894
+ if (!Array.isArray(origins)) {
5895
+ throw new SiblingResolutionError(
5896
+ "sibling_origins in the core repo is not a list. Refusing to tear down while the sibling registry cannot be read."
5897
+ );
5898
+ }
5899
+ return origins;
5900
+ }
5901
+ function collectSiblings(sources) {
5902
+ const byPrefix = /* @__PURE__ */ new Map();
5903
+ for (const source of sources) {
5904
+ for (const entry of source.entries) {
5905
+ if (!entry?.name || !entry.bucket_regional_domain) {
5906
+ throw new SiblingResolutionError(
5907
+ `A sibling entry in ${registryPath(source.environment)} is missing name or bucket_regional_domain. Refusing to tear down while a registered sibling cannot be identified \u2014 it would be left behind, still billing.`
5908
+ );
5909
+ }
5910
+ const parsed = parseSiblingBucketDomain(entry.bucket_regional_domain);
5911
+ if (!parsed) {
5912
+ throw new SiblingResolutionError(
5913
+ `Could not work out which project owns the sibling "${entry.name}" from its bucket "${entry.bucket_regional_domain}" (in ${registryPath(source.environment)}).
5914
+ Refusing to guess \u2014 tear that sibling down by hand, remove its entry, then re-run.`
5915
+ );
5916
+ }
5917
+ const existing = byPrefix.get(entry.name);
5918
+ if (existing) {
5919
+ if (existing.projectName !== parsed.projectName) {
5920
+ throw new SiblingResolutionError(
5921
+ `The sibling "${entry.name}" maps to two different projects across environments ("${existing.projectName}" and "${parsed.projectName}"). Refusing to guess which repo to delete \u2014 resolve the registry by hand and re-run.`
5922
+ );
5923
+ }
5924
+ if (!existing.environments.includes(parsed.environment)) {
5925
+ existing.environments.push(parsed.environment);
5926
+ }
5927
+ if (source.pendingRegistrationPr === void 0) {
5928
+ existing.registered = true;
5929
+ delete existing.pendingRegistrationPr;
5930
+ }
5931
+ continue;
5932
+ }
5933
+ byPrefix.set(entry.name, {
5934
+ pathPrefix: entry.name,
5935
+ projectName: parsed.projectName,
5936
+ environments: [parsed.environment],
5937
+ accountId: parsed.accountId,
5938
+ registered: source.pendingRegistrationPr === void 0,
5939
+ ...source.pendingRegistrationPr !== void 0 ? { pendingRegistrationPr: source.pendingRegistrationPr } : {}
5940
+ });
5941
+ }
5942
+ }
5943
+ return [...byPrefix.values()].sort((a, b) => a.pathPrefix.localeCompare(b.pathPrefix));
5944
+ }
5945
+ var SIBLING_MARKER_FILE = "biffo.sibling.json";
5946
+ function markerMatches(marker, coreProjectName, sibling) {
5947
+ if (!marker) return false;
5948
+ if (marker.core_project !== coreProjectName) return false;
5949
+ return marker.path_prefix === sibling.pathPrefix || marker.name === sibling.projectName;
5950
+ }
5951
+ async function resolveSiblingRepos(github, coreOrg, coreProjectName, siblings) {
5952
+ const resolved = [];
5953
+ for (const sibling of siblings) {
5954
+ const repo = sibling.projectName;
5955
+ if (!await github.repoExists(coreOrg, repo)) {
5956
+ resolved.push({ ...sibling, org: coreOrg, repo, repoState: "gone" });
5957
+ continue;
5958
+ }
5959
+ const raw = await github.getFileContent(coreOrg, repo, SIBLING_MARKER_FILE);
5960
+ let marker = null;
5961
+ if (raw !== void 0) {
5962
+ try {
5963
+ marker = JSON.parse(raw);
5964
+ } catch {
5965
+ marker = null;
5966
+ }
5967
+ }
5968
+ if (!markerMatches(marker, coreProjectName, sibling)) {
5969
+ throw new SiblingResolutionError(
5970
+ `Refusing to tear down: ${coreOrg}/${repo} is registered as the sibling "${sibling.pathPrefix}" of "${coreProjectName}", but that repo does not carry a matching ${SIBLING_MARKER_FILE}.
5971
+ It may be an unrelated repo that happens to share the name. Deleting a repo cannot be undone, so nothing has been deleted.
5972
+ Delete ${coreOrg}/${repo} by hand if it really is the sibling, remove its entry from the core repo's siblings.auto.tfvars.json, then re-run biffo teardown.`
5973
+ );
5974
+ }
5975
+ resolved.push({ ...sibling, org: coreOrg, repo, repoState: "present" });
5976
+ }
5977
+ return resolved;
5978
+ }
5979
+
5980
+ // src/commands/teardown.ts
5817
5981
  var teardownCommand = new Command22("teardown").description(
5818
5982
  "Destroy all infrastructure then remove the repo, IAM role, and state bucket \u2014 single command"
5819
5983
  ).option("--project <name>", "Project name to tear down (reads session if omitted)").option("--skip-destroy", "Skip terraform destroy (only use if infrastructure is already gone)").option(
@@ -5912,6 +6076,21 @@ var teardownCommand = new Command22("teardown").description(
5912
6076
  const allDeployed = options.skipDestroy ? [] : await aws.listDeployedEnvironments(stateBucket).catch(() => []);
5913
6077
  const deployedEnvs = allDeployed.filter((e) => e !== "global");
5914
6078
  const hasGlobal = allDeployed.includes("global");
6079
+ let siblings = [];
6080
+ if (org && repo) {
6081
+ try {
6082
+ siblings = await discoverSiblings(github, org, repo, projectName);
6083
+ if (!options.skipDestroy) {
6084
+ await assertSiblingsAreDestroyable(github, siblings);
6085
+ }
6086
+ } catch (err) {
6087
+ if (err instanceof SiblingResolutionError) {
6088
+ log.error(err.message);
6089
+ process.exit(1);
6090
+ }
6091
+ throw err;
6092
+ }
6093
+ }
5915
6094
  console.log(chalk20.red.bold(" This will permanently delete:\n"));
5916
6095
  if (deployedEnvs.length > 0 || hasGlobal) {
5917
6096
  console.log(chalk20.red(" Infrastructure (via GitHub Actions terraform destroy):"));
@@ -5925,6 +6104,9 @@ var teardownCommand = new Command22("teardown").description(
5925
6104
  }
5926
6105
  console.log();
5927
6106
  }
6107
+ for (const line of formatSiblingPlan(siblings, options.skipDestroy === true)) {
6108
+ console.log(line);
6109
+ }
5928
6110
  console.log(chalk20.red(" Biffo resources:"));
5929
6111
  console.log(` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${org}/${repo}`)}`);
5930
6112
  console.log(
@@ -5934,11 +6116,22 @@ var teardownCommand = new Command22("teardown").description(
5934
6116
  ` ${chalk20.red("\u2717")} S3 bucket ${chalk20.bold(stateBucket)} (all versions)`
5935
6117
  );
5936
6118
  console.log(` ${chalk20.red("\u2717")} Local session file`);
6119
+ if (options.skipDestroy) {
6120
+ console.log();
6121
+ console.log(
6122
+ chalk20.yellow(
6123
+ " --skip-destroy: NO terraform destroy runs, for this project or any sibling.\n Everything above is deleted, but any infrastructure still standing is left\n standing \u2014 and, with the state buckets gone, orphaned."
6124
+ )
6125
+ );
6126
+ }
5937
6127
  console.log();
5938
6128
  if (!await confirmTeardown(projectName, options)) {
5939
6129
  log.warn("Teardown cancelled \u2014 project name did not match");
5940
6130
  return;
5941
6131
  }
6132
+ if (!options.skipDestroy && siblings.length > 0) {
6133
+ await destroySiblingInfrastructure(github, siblings);
6134
+ }
5942
6135
  if (deployedEnvs.length > 0) {
5943
6136
  const actionsUrl = `https://github.com/${org}/${repo}/actions`;
5944
6137
  for (const env of deployedEnvs) {
@@ -6006,6 +6199,32 @@ Destroying ${env} infrastructure (20\u201330 min for first run)...`);
6006
6199
  log.warn("destroy-global.yml not found \u2014 skipping global infrastructure teardown");
6007
6200
  }
6008
6201
  }
6202
+ for (const sibling of siblings) {
6203
+ if (sibling.repoState === "gone") {
6204
+ log.warn(
6205
+ `Sibling "${sibling.pathPrefix}" (${sibling.org}/${sibling.repo}) no longer has a repo \u2014 leaving its IAM role and Terraform state bucket in place so its infrastructure can still be reclaimed:
6206
+ IAM role biffo-github-actions-${sibling.projectName}
6207
+ S3 bucket ${sibling.projectName}-terraform-state-${sibling.accountId}`
6208
+ );
6209
+ continue;
6210
+ }
6211
+ await github.deleteRepo(sibling.org, sibling.repo).catch((err) => {
6212
+ log.warn(
6213
+ `Could not delete sibling repo ${sibling.org}/${sibling.repo} (skipping): ${err.message}`
6214
+ );
6215
+ });
6216
+ await aws.teardownOidcRole(sibling.projectName).catch((err) => {
6217
+ log.warn(
6218
+ `Could not delete sibling IAM role for ${sibling.projectName} (skipping): ${err.message}`
6219
+ );
6220
+ });
6221
+ await aws.teardownTerraformBackend(sibling.projectName).catch((err) => {
6222
+ log.warn(
6223
+ `Could not delete sibling state bucket for ${sibling.projectName} (skipping): ${err.message}`
6224
+ );
6225
+ });
6226
+ log.success(`Sibling ${sibling.org}/${sibling.repo} removed`);
6227
+ }
6009
6228
  await github.deleteRepo(org, repo).catch((err) => {
6010
6229
  log.warn(`Could not delete repo (skipping): ${err.message}`);
6011
6230
  });
@@ -6021,6 +6240,124 @@ Destroying ${env} infrastructure (20\u201330 min for first run)...`);
6021
6240
  console.log(" All biffo resources have been removed.\n");
6022
6241
  }
6023
6242
  );
6243
+ async function destroySiblingInfrastructure(github, siblings) {
6244
+ for (const sibling of siblings) {
6245
+ if (sibling.repoState !== "present") continue;
6246
+ const actionsUrl = `https://github.com/${sibling.org}/${sibling.repo}/actions`;
6247
+ for (const env of sibling.environments) {
6248
+ const envBranch = { dev: "dev", staging: "staging", prod: "main" };
6249
+ const branch = envBranch[env] ?? "dev";
6250
+ log.info(`
6251
+ Destroying sibling ${sibling.org}/${sibling.repo} (${env})...`);
6252
+ log.info(` Watch live: ${actionsUrl}`);
6253
+ const baselineId = await github.getLatestWorkflowRunId(
6254
+ sibling.org,
6255
+ sibling.repo,
6256
+ SIBLING_DESTROY_WORKFLOW
6257
+ );
6258
+ await github.triggerWorkflow(
6259
+ sibling.org,
6260
+ sibling.repo,
6261
+ SIBLING_DESTROY_WORKFLOW,
6262
+ { environment: env },
6263
+ branch
6264
+ );
6265
+ const result = await github.waitForWorkflowRun(
6266
+ sibling.org,
6267
+ sibling.repo,
6268
+ SIBLING_DESTROY_WORKFLOW,
6269
+ baselineId,
6270
+ 36e5,
6271
+ 3e4,
6272
+ branch
6273
+ );
6274
+ if (result.conclusion !== "success") {
6275
+ log.error(
6276
+ `Sibling destroy ${result.conclusion ?? "failed"} for ${sibling.org}/${sibling.repo} (${env}).`
6277
+ );
6278
+ log.error(` Run details: ${actionsUrl}/runs/${result.id}`);
6279
+ log.error(
6280
+ " Nothing else has been destroyed or deleted \u2014 this project and every other sibling are still intact.\n Fix the sibling and re-run biffo teardown, or use --skip-destroy."
6281
+ );
6282
+ process.exit(1);
6283
+ }
6284
+ log.success(`Sibling ${sibling.org}/${sibling.repo} (${env}) infrastructure destroyed`);
6285
+ }
6286
+ }
6287
+ }
6288
+ var SIBLING_DESTROY_WORKFLOW = "destroy-infra.yml";
6289
+ var SIBLING_DESTROY_WORKFLOW_PATH = `.github/workflows/${SIBLING_DESTROY_WORKFLOW}`;
6290
+ async function discoverSiblings(github, coreOrg, coreRepo, coreProjectName) {
6291
+ const sources = [];
6292
+ for (const env of REGISTRY_ENVIRONMENTS) {
6293
+ const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env));
6294
+ sources.push({ environment: env, entries: parseRegistry(contents) });
6295
+ }
6296
+ const openPrs = await github.listOpenPullRequests(coreOrg, coreRepo);
6297
+ for (const pr of openPrs) {
6298
+ if (!pr.headRef.startsWith(REGISTRATION_BRANCH_PREFIX)) continue;
6299
+ for (const env of REGISTRY_ENVIRONMENTS) {
6300
+ const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env), pr.headRef);
6301
+ sources.push({
6302
+ environment: env,
6303
+ entries: parseRegistry(contents),
6304
+ pendingRegistrationPr: pr.number
6305
+ });
6306
+ }
6307
+ }
6308
+ const discovered = collectSiblings(sources);
6309
+ return resolveSiblingRepos(github, coreOrg, coreProjectName, discovered);
6310
+ }
6311
+ async function assertSiblingsAreDestroyable(github, siblings) {
6312
+ const missing = [];
6313
+ for (const sibling of siblings) {
6314
+ if (sibling.repoState !== "present") continue;
6315
+ const workflow = await github.getFileContent(
6316
+ sibling.org,
6317
+ sibling.repo,
6318
+ SIBLING_DESTROY_WORKFLOW_PATH
6319
+ );
6320
+ if (workflow === void 0) missing.push(`${sibling.org}/${sibling.repo}`);
6321
+ }
6322
+ if (missing.length > 0) {
6323
+ throw new SiblingResolutionError(
6324
+ `These sibling repos have no ${SIBLING_DESTROY_WORKFLOW_PATH}, so their AWS resources cannot be destroyed:
6325
+ ` + missing.map((r) => ` ${r}`).join("\n") + "\n Nothing has been deleted. Either add that workflow to each repo (copy it from biffo's sibling skeleton), destroy those siblings by hand, or re-run with --skip-destroy to delete the repos and leave their infrastructure standing."
6326
+ );
6327
+ }
6328
+ }
6329
+ function formatSiblingPlan(siblings, skipDestroy) {
6330
+ if (siblings.length === 0) return [];
6331
+ const lines = [chalk20.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
6332
+ for (const s of siblings) {
6333
+ const envs = s.environments.join(", ");
6334
+ if (s.repoState === "gone") {
6335
+ lines.push(
6336
+ ` ${chalk20.yellow("!")} ${chalk20.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
6337
+ );
6338
+ continue;
6339
+ }
6340
+ lines.push(
6341
+ ` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${s.org}/${s.repo}`)} (routed at /${s.pathPrefix})`
6342
+ );
6343
+ lines.push(
6344
+ ` ${chalk20.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
6345
+ );
6346
+ lines.push(
6347
+ ` ${chalk20.red("\u2717")} IAM role ${chalk20.bold(`biffo-github-actions-${s.projectName}`)}`
6348
+ );
6349
+ lines.push(
6350
+ ` ${chalk20.red("\u2717")} S3 bucket ${chalk20.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
6351
+ );
6352
+ if (s.pendingRegistrationPr !== void 0) {
6353
+ lines.push(
6354
+ chalk20.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
6355
+ );
6356
+ }
6357
+ }
6358
+ lines.push("");
6359
+ return lines;
6360
+ }
6024
6361
  function pickSoleProjectConfig() {
6025
6362
  const configs = listProjectConfigs();
6026
6363
  if (configs.length > 1 && isNonInteractive()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.33.5",
3
+ "version": "0.34.1",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",