@biffo/cli 0.41.9 → 0.41.11

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 (3) hide show
  1. package/core.version +1 -1
  2. package/dist/index.js +384 -215
  3. package/package.json +1 -1
package/core.version CHANGED
@@ -1 +1 @@
1
- 0.41.9
1
+ 0.41.11
package/dist/index.js CHANGED
@@ -51,12 +51,13 @@ function findTemplateRoot(startDir) {
51
51
  dir = parent;
52
52
  }
53
53
  }
54
- function resolveTemplateRoot(fromDir) {
55
- const start = fromDir ?? dirname(fileURLToPath(import.meta.url));
54
+ function resolveTemplateRoot(options = {}) {
55
+ const start = options.fromDir ?? dirname(fileURLToPath(import.meta.url));
56
56
  const root = findTemplateRoot(start);
57
57
  if (!root) {
58
+ const guidance = options.guidance ?? "Point this command at a biffo-template checkout at the target version.";
58
59
  throw new Error(
59
- `Could not locate a Biffo template root (a directory with ${CORE_MANIFEST_FILE} and core.version) above ${start}. Pass --template <path> to a biffo-template checkout at the target version.`
60
+ `Could not locate a Biffo template root (a directory with ${CORE_MANIFEST_FILE} and core.version) above ${start}. ` + guidance
60
61
  );
61
62
  }
62
63
  return root;
@@ -236,6 +237,7 @@ var log = {
236
237
  };
237
238
 
238
239
  // src/commands/core-diff.ts
240
+ var MISSING_TEMPLATE_ROOT_GUIDANCE = "Pass --template <path> to a biffo-template checkout, e.g. `biffo core diff --template /path/to/biffo-template`.";
239
241
  var coreDiffCommand = new Command("diff").description(
240
242
  "Show which template-owned files an upgrade would change in this instance (read-only)"
241
243
  ).option("--cwd <path>", "Instance repo root to inspect (defaults to the current directory)").option(
@@ -253,7 +255,7 @@ var coreDiffCommand = new Command("diff").description(
253
255
  }
254
256
  });
255
257
  async function runCoreDiff(options) {
256
- const templateRoot = options.templateRoot ?? resolveTemplateRoot();
258
+ const templateRoot = options.templateRoot ?? resolveTemplateRoot({ guidance: MISSING_TEMPLATE_ROOT_GUIDANCE });
257
259
  const manifest = readCoreManifest(templateRoot);
258
260
  const templateVersion = readCoreVersionFile(join3(templateRoot, "core.version"));
259
261
  const instanceVersion = readInstanceCoreVersion(options.cwd);
@@ -833,6 +835,39 @@ var GitHubAdapter = class {
833
835
  log.info(`Committed ${files.map((f) => f.path).join(", ")} to ${branch}`);
834
836
  return commit.sha;
835
837
  }
838
+ /** The commit SHA at the head of `branch`. */
839
+ async getBranchSha(org, repo, branch) {
840
+ const ref = await this.waitForRef(org, repo, `heads/${branch}`, 12e4, 3e3);
841
+ return ref.object.sha;
842
+ }
843
+ /**
844
+ * Point `branch` at `sha` via a fast-forward-only ref update (issue #329).
845
+ *
846
+ * `sha` must be an ancestor-descendant of the branch's current head:
847
+ * GitHub's `updateRef` rejects a non-fast-forward move unless `force` is set,
848
+ * and it is deliberately left unset here. The only caller is `writeInstanceFiles`,
849
+ * moving a freshly-created branch onto the instance commit built on that
850
+ * branch's own shared base — always a fast-forward. If it ever isn't, failing
851
+ * loudly is the correct outcome, not clobbering history.
852
+ *
853
+ * Idempotent: a no-op (no API write) when `branch` is already at `sha`, so a
854
+ * resumed init neither errors nor rewrites anything.
855
+ */
856
+ async fastForwardBranch(org, repo, branch, sha) {
857
+ const current = await this.getBranchSha(org, repo, branch);
858
+ if (current === sha) {
859
+ log.info(`${branch} already at ${sha.slice(0, 7)} \u2014 skipping`);
860
+ return;
861
+ }
862
+ await this.octokit.git.updateRef({
863
+ owner: org,
864
+ repo,
865
+ ref: `heads/${branch}`,
866
+ sha,
867
+ force: false
868
+ });
869
+ log.info(`Fast-forwarded ${branch} to ${sha.slice(0, 7)}`);
870
+ }
836
871
  async setDefaultBranch(org, repo, branch) {
837
872
  await this.octokit.repos.update({ owner: org, repo, default_branch: branch });
838
873
  log.info(`Default branch set to ${branch}`);
@@ -1508,6 +1543,7 @@ function materializeTemplateAtTag(repo, version, git = defaultGit) {
1508
1543
  }
1509
1544
 
1510
1545
  // src/commands/core-upgrade.ts
1546
+ var MISSING_TEMPLATE_ROOT_GUIDANCE2 = "Pass --template-repo <path> to a biffo-template git checkout, e.g. `biffo core upgrade --template-repo /path/to/biffo-template`.";
1511
1547
  var coreUpgradeCommand = new Command3("upgrade").description("Three-way-merge template-owned files for a core upgrade; preview it or open a PR").option("--cwd <path>", "Instance repo root to upgrade (defaults to the current directory)").option(
1512
1548
  "--template-repo <path>",
1513
1549
  "Path to a biffo-template git checkout whose core-v* tags supply the base/target trees (defaults to the template this CLI ships with)"
@@ -1570,7 +1606,7 @@ async function runCoreUpgrade(options, deps = defaultDeps()) {
1570
1606
  }
1571
1607
  async function runCoreUpgradeResolved(options, deps, cleanups) {
1572
1608
  const materialize = deps.materialize ?? materializeTemplateAtTag;
1573
- const templateRepo = options.templateRepo ? resolve3(options.templateRepo) : resolveTemplateRoot();
1609
+ const templateRepo = options.templateRepo ? resolve3(options.templateRepo) : resolveTemplateRoot({ guidance: MISSING_TEMPLATE_ROOT_GUIDANCE2 });
1574
1610
  const instanceVersion = readInstanceCoreVersion(options.cwd);
1575
1611
  let theirsDir;
1576
1612
  let toVersion;
@@ -2701,6 +2737,287 @@ function registerNonInteractive(command) {
2701
2737
  return command;
2702
2738
  }
2703
2739
 
2740
+ // src/lib/root-sibling.ts
2741
+ var ROOT_SIBLING_NAME = "app";
2742
+ var RESERVED_SIBLING_NAMES = ["admin", "login", ROOT_SIBLING_NAME];
2743
+ function isRootPathPrefix(pathPrefix) {
2744
+ return pathPrefix === "";
2745
+ }
2746
+ function registryNameFor(pathPrefix) {
2747
+ return isRootPathPrefix(pathPrefix) ? ROOT_SIBLING_NAME : pathPrefix;
2748
+ }
2749
+ function displayPath(pathPrefix) {
2750
+ return isRootPathPrefix(pathPrefix) ? "/" : `/${pathPrefix}`;
2751
+ }
2752
+ function basePathFor(pathPrefix) {
2753
+ return isRootPathPrefix(pathPrefix) ? "" : `/${pathPrefix}`;
2754
+ }
2755
+ function rootSiblingProjectName(coreProjectName) {
2756
+ return `${coreProjectName}-app`;
2757
+ }
2758
+ function bucketRegionalDomain(bucketName, region) {
2759
+ return region === "us-east-1" ? `${bucketName}.s3.amazonaws.com` : `${bucketName}.s3.${region}.amazonaws.com`;
2760
+ }
2761
+ function siteBucketName(projectName, environment, accountId) {
2762
+ return `${projectName}-${environment}-site-${accountId}`;
2763
+ }
2764
+ function upsertSiblingOrigin(existing, entry) {
2765
+ return [...existing.filter((s) => s.name !== entry.name), entry];
2766
+ }
2767
+ function serializeRegistry(origins) {
2768
+ return JSON.stringify({ sibling_origins: origins }, null, 2) + "\n";
2769
+ }
2770
+
2771
+ // src/lib/sibling-teardown.ts
2772
+ var SiblingResolutionError = class extends Error {
2773
+ constructor(message) {
2774
+ super(message);
2775
+ this.name = "SiblingResolutionError";
2776
+ }
2777
+ };
2778
+ var REGISTRY_ENVIRONMENTS = ["dev", "staging", "prod"];
2779
+ function registryPath(environment) {
2780
+ return `infra/environments/${environment}/siblings.auto.tfvars.json`;
2781
+ }
2782
+ var REGISTRATION_BRANCH_PREFIX = "biffo/register-sibling-";
2783
+ function parseSiblingBucketDomain(domain) {
2784
+ const host = /^(?<bucket>[a-z0-9][a-z0-9.-]*)\.s3(?:\.[a-z0-9-]+)?\.amazonaws\.com$/.exec(domain);
2785
+ const bucket = host?.groups?.["bucket"];
2786
+ if (!bucket) return null;
2787
+ const parts = /^(?<project>.+)-(?<env>dev|staging|prod)-site-(?<account>\d{12})$/.exec(bucket);
2788
+ const project = parts?.groups?.["project"];
2789
+ const env = parts?.groups?.["env"];
2790
+ const account = parts?.groups?.["account"];
2791
+ if (!project || !env || !account) return null;
2792
+ return { projectName: project, environment: env, accountId: account };
2793
+ }
2794
+ function parseRegistry(contents) {
2795
+ if (contents === void 0 || contents.trim() === "") return [];
2796
+ let parsed;
2797
+ try {
2798
+ parsed = JSON.parse(contents);
2799
+ } catch {
2800
+ throw new SiblingResolutionError(
2801
+ "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."
2802
+ );
2803
+ }
2804
+ const origins = parsed.sibling_origins;
2805
+ if (origins === void 0) return [];
2806
+ if (!Array.isArray(origins)) {
2807
+ throw new SiblingResolutionError(
2808
+ "sibling_origins in the core repo is not a list. Refusing to tear down while the sibling registry cannot be read."
2809
+ );
2810
+ }
2811
+ return origins;
2812
+ }
2813
+ function collectSiblings(sources) {
2814
+ const byPrefix = /* @__PURE__ */ new Map();
2815
+ for (const source of sources) {
2816
+ for (const entry of source.entries) {
2817
+ if (!entry?.name || !entry.bucket_regional_domain) {
2818
+ throw new SiblingResolutionError(
2819
+ `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.`
2820
+ );
2821
+ }
2822
+ const parsed = parseSiblingBucketDomain(entry.bucket_regional_domain);
2823
+ if (!parsed) {
2824
+ throw new SiblingResolutionError(
2825
+ `Could not work out which project owns the sibling "${entry.name}" from its bucket "${entry.bucket_regional_domain}" (in ${registryPath(source.environment)}).
2826
+ Refusing to guess \u2014 tear that sibling down by hand, remove its entry, then re-run.`
2827
+ );
2828
+ }
2829
+ const existing = byPrefix.get(entry.name);
2830
+ if (existing) {
2831
+ if (existing.projectName !== parsed.projectName) {
2832
+ throw new SiblingResolutionError(
2833
+ `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.`
2834
+ );
2835
+ }
2836
+ if (!existing.environments.includes(parsed.environment)) {
2837
+ existing.environments.push(parsed.environment);
2838
+ }
2839
+ if (source.pendingRegistrationPr === void 0) {
2840
+ existing.registered = true;
2841
+ delete existing.pendingRegistrationPr;
2842
+ }
2843
+ continue;
2844
+ }
2845
+ byPrefix.set(entry.name, {
2846
+ pathPrefix: entry.name,
2847
+ projectName: parsed.projectName,
2848
+ environments: [parsed.environment],
2849
+ accountId: parsed.accountId,
2850
+ registered: source.pendingRegistrationPr === void 0,
2851
+ ...source.pendingRegistrationPr !== void 0 ? { pendingRegistrationPr: source.pendingRegistrationPr } : {}
2852
+ });
2853
+ }
2854
+ }
2855
+ return [...byPrefix.values()].sort((a, b) => a.pathPrefix.localeCompare(b.pathPrefix));
2856
+ }
2857
+ var SIBLING_MARKER_FILE = "biffo.sibling.json";
2858
+ function markerMatches(marker, coreProjectName, sibling) {
2859
+ if (!marker) return false;
2860
+ if (marker.core_project !== coreProjectName) return false;
2861
+ if (marker.path_prefix === sibling.pathPrefix) return true;
2862
+ if (marker.path_prefix === "" && sibling.pathPrefix === ROOT_SIBLING_NAME) return true;
2863
+ return marker.name === sibling.projectName;
2864
+ }
2865
+ async function resolveSiblingRepos(github, coreOrg, coreProjectName, siblings) {
2866
+ const resolved = [];
2867
+ for (const sibling of siblings) {
2868
+ const repo = sibling.projectName;
2869
+ if (!await github.repoExists(coreOrg, repo)) {
2870
+ resolved.push({ ...sibling, org: coreOrg, repo, repoState: "gone" });
2871
+ continue;
2872
+ }
2873
+ const raw = await github.getFileContent(coreOrg, repo, SIBLING_MARKER_FILE);
2874
+ let marker = null;
2875
+ if (raw !== void 0) {
2876
+ try {
2877
+ marker = JSON.parse(raw);
2878
+ } catch {
2879
+ marker = null;
2880
+ }
2881
+ }
2882
+ if (!markerMatches(marker, coreProjectName, sibling)) {
2883
+ throw new SiblingResolutionError(
2884
+ `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}.
2885
+ It may be an unrelated repo that happens to share the name. Deleting a repo cannot be undone, so nothing has been deleted.
2886
+ 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.`
2887
+ );
2888
+ }
2889
+ resolved.push({ ...sibling, org: coreOrg, repo, repoState: "present" });
2890
+ }
2891
+ return resolved;
2892
+ }
2893
+ async function discoverSiblings(github, coreOrg, coreRepo, coreProjectName) {
2894
+ const sources = [];
2895
+ for (const env of REGISTRY_ENVIRONMENTS) {
2896
+ const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env));
2897
+ sources.push({ environment: env, entries: parseRegistry(contents) });
2898
+ }
2899
+ const openPrs = await github.listOpenPullRequests(coreOrg, coreRepo);
2900
+ for (const pr of openPrs) {
2901
+ if (!pr.headRef.startsWith(REGISTRATION_BRANCH_PREFIX)) continue;
2902
+ for (const env of REGISTRY_ENVIRONMENTS) {
2903
+ const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env), pr.headRef);
2904
+ sources.push({
2905
+ environment: env,
2906
+ entries: parseRegistry(contents),
2907
+ pendingRegistrationPr: pr.number
2908
+ });
2909
+ }
2910
+ }
2911
+ const discovered = collectSiblings(sources);
2912
+ return resolveSiblingRepos(github, coreOrg, coreProjectName, discovered);
2913
+ }
2914
+
2915
+ // src/lib/sibling-wiring.ts
2916
+ function cloudfrontDistributionArn(accountId, distributionId) {
2917
+ return `arn:aws:cloudfront::${accountId}:distribution/${distributionId}`;
2918
+ }
2919
+ var REQUIRED_CORE_OUTPUTS = [
2920
+ "cognito_user_pool_id",
2921
+ "cognito_client_id",
2922
+ "api_gateway_url",
2923
+ "portal_url",
2924
+ "cloudfront_distribution_id"
2925
+ ];
2926
+ function coreWiringFromOutputs(outputs, coreAccountId, environment) {
2927
+ const missing = REQUIRED_CORE_OUTPUTS.filter((k) => !outputs[k]?.trim());
2928
+ if (missing.length > 0) {
2929
+ throw new Error(
2930
+ `Cannot wire siblings for ${environment}: the core deploy did not expose ${missing.join(", ")} in its Terraform outputs. A sibling wired without these would build a frontend pointing at no API/Cognito and skip its S3 bucket policy (so / returns 403). Confirm the core actually deployed ${environment} before wiring.`
2931
+ );
2932
+ }
2933
+ const distributionId = outputs["cloudfront_distribution_id"];
2934
+ return {
2935
+ identity: {
2936
+ cognitoUserPoolId: outputs["cognito_user_pool_id"],
2937
+ cognitoClientId: outputs["cognito_client_id"],
2938
+ apiUrl: outputs["api_gateway_url"],
2939
+ portalUrl: outputs["portal_url"]
2940
+ },
2941
+ cdn: {
2942
+ distributionId,
2943
+ distributionArn: cloudfrontDistributionArn(coreAccountId, distributionId)
2944
+ }
2945
+ };
2946
+ }
2947
+ async function setSiblingCoreIdentity(github, org, repo, env, identity) {
2948
+ await github.setEnvVariable(
2949
+ org,
2950
+ repo,
2951
+ env,
2952
+ "CORE_COGNITO_USER_POOL_ID",
2953
+ identity.cognitoUserPoolId
2954
+ );
2955
+ await github.setEnvVariable(org, repo, env, "CORE_COGNITO_CLIENT_ID", identity.cognitoClientId);
2956
+ await github.setEnvVariable(org, repo, env, "CORE_API_URL", identity.apiUrl);
2957
+ await github.setEnvVariable(org, repo, env, "CORE_PORTAL_URL", identity.portalUrl);
2958
+ await github.setEnvVariable(
2959
+ org,
2960
+ repo,
2961
+ env,
2962
+ "CORS_ORIGINS_JSON",
2963
+ JSON.stringify([identity.portalUrl])
2964
+ );
2965
+ }
2966
+ async function wireSiblingEnvironment(github, org, repo, env, wiring) {
2967
+ await setSiblingCoreIdentity(github, org, repo, env, wiring.identity);
2968
+ await github.setEnvVariable(
2969
+ org,
2970
+ repo,
2971
+ env,
2972
+ "PARENT_CLOUDFRONT_DISTRIBUTION_ARN",
2973
+ wiring.cdn.distributionArn
2974
+ );
2975
+ await github.setEnvVariable(
2976
+ org,
2977
+ repo,
2978
+ env,
2979
+ "PARENT_CLOUDFRONT_DISTRIBUTION_ID",
2980
+ wiring.cdn.distributionId
2981
+ );
2982
+ }
2983
+ async function wireSiblingsAfterCoreDeploy(github, coreOrg, coreRepo, coreProjectName, environment, wiring, siblingGithubToken) {
2984
+ const siblings = await discoverSiblings(github, coreOrg, coreRepo, coreProjectName);
2985
+ const result = { wired: [], gone: [], skippedEnv: [] };
2986
+ for (const sibling of siblings) {
2987
+ const slug = `${sibling.org}/${sibling.repo}`;
2988
+ if (sibling.repoState === "gone") {
2989
+ result.gone.push(slug);
2990
+ continue;
2991
+ }
2992
+ await github.setRepoSecret(
2993
+ sibling.org,
2994
+ sibling.repo,
2995
+ "SIBLING_GITHUB_TOKEN",
2996
+ siblingGithubToken
2997
+ );
2998
+ if (!sibling.environments.includes(environment)) {
2999
+ result.skippedEnv.push(slug);
3000
+ continue;
3001
+ }
3002
+ await wireSiblingEnvironment(github, sibling.org, sibling.repo, environment, wiring);
3003
+ result.wired.push(slug);
3004
+ }
3005
+ return result;
3006
+ }
3007
+ function formatWiringResult(environment, result) {
3008
+ const lines = [];
3009
+ for (const slug of result.wired) {
3010
+ lines.push(` Wired sibling ${slug} (${environment}): CORE_*, CORS, PARENT_CLOUDFRONT_*, token`);
3011
+ }
3012
+ for (const slug of result.skippedEnv) {
3013
+ lines.push(` Sibling ${slug}: token set; not registered for ${environment}, env vars skipped`);
3014
+ }
3015
+ for (const slug of result.gone) {
3016
+ lines.push(` Sibling ${slug}: registered but repo not found \u2014 skipped`);
3017
+ }
3018
+ return lines;
3019
+ }
3020
+
2704
3021
  // src/commands/deploy.ts
2705
3022
  var deployCommand = new Command9("deploy").description("Deploy infrastructure and application to an environment").argument("<environment>", "Target environment: dev | staging | prod").option("--infra-only", "Deploy infrastructure only, skip application build").option("--app-only", "Deploy application only, skip Terraform").option("-p, --project <name>", "Project name (overrides biffo.config.json in current directory)").option("-c, --config <path>", "Path to biffo.config.json").option("-y, --yes", "Skip deployment target confirmation").action(
2706
3023
  async (environment, options) => {
@@ -2968,8 +3285,9 @@ async function runDeploy(github, aws, config, environment, options = {}) {
2968
3285
  }
2969
3286
  const reportStep = totalSteps;
2970
3287
  log.step(reportStep, totalSteps, "Reading deployment outputs...");
3288
+ let outputs;
2971
3289
  try {
2972
- const outputs = await aws.readTerraformOutputs(stateBucket, stateKey);
3290
+ outputs = await aws.readTerraformOutputs(stateBucket, stateKey);
2973
3291
  console.log(chalk8.bold("\n Deploy complete!\n"));
2974
3292
  if (outputs.portal_url) console.log(` Portal: ${chalk8.cyan(outputs.portal_url)}`);
2975
3293
  if (outputs.api_gateway_url)
@@ -3006,6 +3324,50 @@ async function runDeploy(github, aws, config, environment, options = {}) {
3006
3324
  console.log(` Actions: ${actionsUrl}
3007
3325
  `);
3008
3326
  }
3327
+ await wireSiblingsIfAny(github, config, environment, awsConfig2.account_id, outputs, options.token);
3328
+ }
3329
+ async function wireSiblingsIfAny(github, config, environment, coreAccountId, outputs, token) {
3330
+ const { org, repo } = config.source_control.config;
3331
+ if (!outputs) {
3332
+ log.warn(
3333
+ `Skipped wiring siblings: this run produced no readable Terraform outputs (e.g. --app-only without a prior infra apply). Re-run \`biffo deploy ${environment}\` once the core infrastructure is applied so its siblings can be wired.`
3334
+ );
3335
+ return;
3336
+ }
3337
+ if (!token) {
3338
+ log.warn(
3339
+ "Skipped wiring siblings: no GitHub token available to set the SIBLING_GITHUB_TOKEN secret."
3340
+ );
3341
+ return;
3342
+ }
3343
+ try {
3344
+ const wiring = coreWiringFromOutputs(outputs, coreAccountId, environment);
3345
+ const result = await wireSiblingsAfterCoreDeploy(
3346
+ github,
3347
+ org,
3348
+ repo,
3349
+ config.project.name,
3350
+ environment,
3351
+ wiring,
3352
+ token
3353
+ );
3354
+ const lines = formatWiringResult(environment, result);
3355
+ if (lines.length > 0) {
3356
+ console.log(chalk8.dim("\n Siblings:"));
3357
+ for (const line of lines) console.log(chalk8.dim(line));
3358
+ console.log();
3359
+ }
3360
+ } catch (err) {
3361
+ if (err instanceof SiblingResolutionError) {
3362
+ log.error(`Could not wire siblings: ${err.message}`);
3363
+ } else {
3364
+ log.error(`Failed to wire siblings to the deployed core: ${err.message}`);
3365
+ }
3366
+ log.error(
3367
+ ` The core deployed, but at least one sibling was not wired. Fix the cause and re-run \`biffo deploy ${environment}\` \u2014 wiring is idempotent.`
3368
+ );
3369
+ process.exit(1);
3370
+ }
3009
3371
  }
3010
3372
  function resolveGithubToken() {
3011
3373
  if (process.env["GITHUB_TOKEN"]) return process.env["GITHUB_TOKEN"];
@@ -3507,37 +3869,6 @@ async function resolveRepoIds(github, config) {
3507
3869
  }
3508
3870
  }
3509
3871
 
3510
- // src/lib/root-sibling.ts
3511
- var ROOT_SIBLING_NAME = "app";
3512
- var RESERVED_SIBLING_NAMES = ["admin", "login", ROOT_SIBLING_NAME];
3513
- function isRootPathPrefix(pathPrefix) {
3514
- return pathPrefix === "";
3515
- }
3516
- function registryNameFor(pathPrefix) {
3517
- return isRootPathPrefix(pathPrefix) ? ROOT_SIBLING_NAME : pathPrefix;
3518
- }
3519
- function displayPath(pathPrefix) {
3520
- return isRootPathPrefix(pathPrefix) ? "/" : `/${pathPrefix}`;
3521
- }
3522
- function basePathFor(pathPrefix) {
3523
- return isRootPathPrefix(pathPrefix) ? "" : `/${pathPrefix}`;
3524
- }
3525
- function rootSiblingProjectName(coreProjectName) {
3526
- return `${coreProjectName}-app`;
3527
- }
3528
- function bucketRegionalDomain(bucketName, region) {
3529
- return region === "us-east-1" ? `${bucketName}.s3.amazonaws.com` : `${bucketName}.s3.${region}.amazonaws.com`;
3530
- }
3531
- function siteBucketName(projectName, environment, accountId) {
3532
- return `${projectName}-${environment}-site-${accountId}`;
3533
- }
3534
- function upsertSiblingOrigin(existing, entry) {
3535
- return [...existing.filter((s) => s.name !== entry.name), entry];
3536
- }
3537
- function serializeRegistry(origins) {
3538
- return JSON.stringify({ sibling_origins: origins }, null, 2) + "\n";
3539
- }
3540
-
3541
3872
  // src/config/sibling-schema.ts
3542
3873
  import { z as z4 } from "zod";
3543
3874
  var SiblingConfigSchema = z4.object({
@@ -3774,12 +4105,11 @@ async function runSiblingCreateCommand(name, options) {
3774
4105
  `
3775
4106
  Next steps:
3776
4107
  1. Merge the registration PR above \u2014 until it merges, baseurl.com${displayPath(pathPrefix)} won't route anywhere.
3777
- 2. Add a SIBLING_GITHUB_TOKEN secret to this new repo (a PAT with repo scope) \u2014 needed by its
3778
- deploy workflow to export Terraform outputs as environment variables, same as the core project.
4108
+ 2. Run \`biffo deploy <env>\` on the CORE project (or re-run it if already deployed). That wires
4109
+ this sibling automatically: the SIBLING_GITHUB_TOKEN secret, the CORE_* identity variables,
4110
+ and \u2014 once the registration PR has merged and the core has redeployed \u2014
4111
+ PARENT_CLOUDFRONT_DISTRIBUTION_ARN. No manual variable/secret setup is needed (issue #337).
3779
4112
  3. Push to \`dev\` (or run the Deploy workflow manually) to provision this sibling's own AWS resources.
3780
- 4. Once the registration PR has ALSO merged and the core project has redeployed, set
3781
- PARENT_CLOUDFRONT_DISTRIBUTION_ARN on this repo and re-run its Deploy workflow \u2014 see this
3782
- repo's README, "The two-phase CDN registration".
3783
4113
  `
3784
4114
  );
3785
4115
  }
@@ -3801,7 +4131,6 @@ async function runSiblingCreate(github, aws, coreAws, git, config, session, opti
3801
4131
  "Core project isn't deployed yet \u2014 deferring its identity (CORE_COGNITO_*, CORE_API_URL)"
3802
4132
  );
3803
4133
  session.outputs.coreIdentity = {};
3804
- markSiblingStepComplete(session, "resolve_core_identity");
3805
4134
  } else if (!session.completedSteps.includes("resolve_core_identity")) {
3806
4135
  log.step(2, totalSteps, "Resolving core project's identity...");
3807
4136
  session.outputs.coreIdentity = await resolveCoreIdentity(
@@ -4060,23 +4389,7 @@ async function configureSiblingGithub(github, config, coreConfig, session, coreI
4060
4389
  for (const env of config.environments) {
4061
4390
  const identity = coreIdentity[env];
4062
4391
  if (!identity) continue;
4063
- await github.setEnvVariable(
4064
- org,
4065
- repo,
4066
- env,
4067
- "CORE_COGNITO_USER_POOL_ID",
4068
- identity.cognitoUserPoolId
4069
- );
4070
- await github.setEnvVariable(org, repo, env, "CORE_COGNITO_CLIENT_ID", identity.cognitoClientId);
4071
- await github.setEnvVariable(org, repo, env, "CORE_API_URL", identity.apiUrl);
4072
- await github.setEnvVariable(org, repo, env, "CORE_PORTAL_URL", identity.portalUrl);
4073
- await github.setEnvVariable(
4074
- org,
4075
- repo,
4076
- env,
4077
- "CORS_ORIGINS_JSON",
4078
- JSON.stringify([identity.portalUrl])
4079
- );
4392
+ await setSiblingCoreIdentity(github, org, repo, env, identity);
4080
4393
  }
4081
4394
  if (session.outputs.oidcRoleArn) {
4082
4395
  await github.setRepoSecret(org, repo, "SIBLING_OIDC_ROLE_ARN", session.outputs.oidcRoleArn);
@@ -4320,12 +4633,7 @@ var initCommand = new Command12("init").description("Scaffold a new project from
4320
4633
  Platform: https://github.com/${org}/${repo}`);
4321
4634
  console.log(` Application: https://github.com/${org}/${appRepo} (serves /)`);
4322
4635
  console.log(
4323
- `
4324
- Next:
4325
- 1. Clone the platform repo and run its first deploy \u2014 /admin and /login come up with it.
4326
- 2. Then deploy the application repo. Until it deploys, / has no content and 404s;
4327
- that window is expected.
4328
- `
4636
+ "\n Next:\n 1. Clone the platform repo and run `biffo deploy dev` \u2014 /admin and /login come up, and\n the same deploy wires the application repo (its CORE_* identity, the parent CloudFront\n ARN, and the SIBLING_GITHUB_TOKEN secret) automatically.\n 2. Then run the application repo's Deploy workflow \u2014 no manual variable/secret setup is\n needed. Until it deploys, / has no content and 404s; that window is expected.\n"
4329
4637
  );
4330
4638
  }
4331
4639
  );
@@ -4539,7 +4847,12 @@ function appSiblingRegistryFiles(config) {
4539
4847
  }));
4540
4848
  }
4541
4849
  var INSTANCE_CONFIG_FILE = "biffo.config.json";
4542
- var INSTANCE_FILE_BRANCHES = ["main", "dev", "staging"];
4850
+ var INSTANCE_FILE_BASE_BRANCH = "main";
4851
+ var INSTANCE_FILE_FOLLOWER_BRANCHES = ["dev", "staging"];
4852
+ var INSTANCE_FILE_BRANCHES = [
4853
+ INSTANCE_FILE_BASE_BRANCH,
4854
+ ...INSTANCE_FILE_FOLLOWER_BRANCHES
4855
+ ];
4543
4856
  async function writeInstanceFiles(github, org, repo, config) {
4544
4857
  const files = [
4545
4858
  { path: INSTANCE_CORE_FILE, content: serializeInstanceCoreVersion(getLatestCoreVersion()) },
@@ -4547,8 +4860,10 @@ async function writeInstanceFiles(github, org, repo, config) {
4547
4860
  ...config ? appSiblingRegistryFiles(config) : []
4548
4861
  ];
4549
4862
  const message = config ? `chore: record core version, register the ${ROOT_SIBLING_NAME} sibling, and drop the template ${INSTANCE_CONFIG_FILE}` : `chore: record core version and drop the template ${INSTANCE_CONFIG_FILE}`;
4550
- for (const branch of INSTANCE_FILE_BRANCHES) {
4551
- await github.commitFiles(org, repo, branch, files, message);
4863
+ const committed = await github.commitFiles(org, repo, INSTANCE_FILE_BASE_BRANCH, files, message);
4864
+ const sharedSha = committed ?? await github.getBranchSha(org, repo, INSTANCE_FILE_BASE_BRANCH);
4865
+ for (const branch of INSTANCE_FILE_FOLLOWER_BRANCHES) {
4866
+ await github.fastForwardBranch(org, repo, branch, sharedSha);
4552
4867
  }
4553
4868
  }
4554
4869
  function printPlan2(config) {
@@ -6160,131 +6475,6 @@ import { GetCallerIdentityCommand as GetCallerIdentityCommand3, STSClient as STS
6160
6475
  import chalk20 from "chalk";
6161
6476
  import { Command as Command22 } from "commander";
6162
6477
  import inquirer8 from "inquirer";
6163
-
6164
- // src/lib/sibling-teardown.ts
6165
- var SiblingResolutionError = class extends Error {
6166
- constructor(message) {
6167
- super(message);
6168
- this.name = "SiblingResolutionError";
6169
- }
6170
- };
6171
- var REGISTRY_ENVIRONMENTS = ["dev", "staging", "prod"];
6172
- function registryPath(environment) {
6173
- return `infra/environments/${environment}/siblings.auto.tfvars.json`;
6174
- }
6175
- var REGISTRATION_BRANCH_PREFIX = "biffo/register-sibling-";
6176
- function parseSiblingBucketDomain(domain) {
6177
- const host = /^(?<bucket>[a-z0-9][a-z0-9.-]*)\.s3(?:\.[a-z0-9-]+)?\.amazonaws\.com$/.exec(domain);
6178
- const bucket = host?.groups?.["bucket"];
6179
- if (!bucket) return null;
6180
- const parts = /^(?<project>.+)-(?<env>dev|staging|prod)-site-(?<account>\d{12})$/.exec(bucket);
6181
- const project = parts?.groups?.["project"];
6182
- const env = parts?.groups?.["env"];
6183
- const account = parts?.groups?.["account"];
6184
- if (!project || !env || !account) return null;
6185
- return { projectName: project, environment: env, accountId: account };
6186
- }
6187
- function parseRegistry(contents) {
6188
- if (contents === void 0 || contents.trim() === "") return [];
6189
- let parsed;
6190
- try {
6191
- parsed = JSON.parse(contents);
6192
- } catch {
6193
- throw new SiblingResolutionError(
6194
- "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."
6195
- );
6196
- }
6197
- const origins = parsed.sibling_origins;
6198
- if (origins === void 0) return [];
6199
- if (!Array.isArray(origins)) {
6200
- throw new SiblingResolutionError(
6201
- "sibling_origins in the core repo is not a list. Refusing to tear down while the sibling registry cannot be read."
6202
- );
6203
- }
6204
- return origins;
6205
- }
6206
- function collectSiblings(sources) {
6207
- const byPrefix = /* @__PURE__ */ new Map();
6208
- for (const source of sources) {
6209
- for (const entry of source.entries) {
6210
- if (!entry?.name || !entry.bucket_regional_domain) {
6211
- throw new SiblingResolutionError(
6212
- `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.`
6213
- );
6214
- }
6215
- const parsed = parseSiblingBucketDomain(entry.bucket_regional_domain);
6216
- if (!parsed) {
6217
- throw new SiblingResolutionError(
6218
- `Could not work out which project owns the sibling "${entry.name}" from its bucket "${entry.bucket_regional_domain}" (in ${registryPath(source.environment)}).
6219
- Refusing to guess \u2014 tear that sibling down by hand, remove its entry, then re-run.`
6220
- );
6221
- }
6222
- const existing = byPrefix.get(entry.name);
6223
- if (existing) {
6224
- if (existing.projectName !== parsed.projectName) {
6225
- throw new SiblingResolutionError(
6226
- `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.`
6227
- );
6228
- }
6229
- if (!existing.environments.includes(parsed.environment)) {
6230
- existing.environments.push(parsed.environment);
6231
- }
6232
- if (source.pendingRegistrationPr === void 0) {
6233
- existing.registered = true;
6234
- delete existing.pendingRegistrationPr;
6235
- }
6236
- continue;
6237
- }
6238
- byPrefix.set(entry.name, {
6239
- pathPrefix: entry.name,
6240
- projectName: parsed.projectName,
6241
- environments: [parsed.environment],
6242
- accountId: parsed.accountId,
6243
- registered: source.pendingRegistrationPr === void 0,
6244
- ...source.pendingRegistrationPr !== void 0 ? { pendingRegistrationPr: source.pendingRegistrationPr } : {}
6245
- });
6246
- }
6247
- }
6248
- return [...byPrefix.values()].sort((a, b) => a.pathPrefix.localeCompare(b.pathPrefix));
6249
- }
6250
- var SIBLING_MARKER_FILE = "biffo.sibling.json";
6251
- function markerMatches(marker, coreProjectName, sibling) {
6252
- if (!marker) return false;
6253
- if (marker.core_project !== coreProjectName) return false;
6254
- if (marker.path_prefix === sibling.pathPrefix) return true;
6255
- if (marker.path_prefix === "" && sibling.pathPrefix === ROOT_SIBLING_NAME) return true;
6256
- return marker.name === sibling.projectName;
6257
- }
6258
- async function resolveSiblingRepos(github, coreOrg, coreProjectName, siblings) {
6259
- const resolved = [];
6260
- for (const sibling of siblings) {
6261
- const repo = sibling.projectName;
6262
- if (!await github.repoExists(coreOrg, repo)) {
6263
- resolved.push({ ...sibling, org: coreOrg, repo, repoState: "gone" });
6264
- continue;
6265
- }
6266
- const raw = await github.getFileContent(coreOrg, repo, SIBLING_MARKER_FILE);
6267
- let marker = null;
6268
- if (raw !== void 0) {
6269
- try {
6270
- marker = JSON.parse(raw);
6271
- } catch {
6272
- marker = null;
6273
- }
6274
- }
6275
- if (!markerMatches(marker, coreProjectName, sibling)) {
6276
- throw new SiblingResolutionError(
6277
- `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}.
6278
- It may be an unrelated repo that happens to share the name. Deleting a repo cannot be undone, so nothing has been deleted.
6279
- 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.`
6280
- );
6281
- }
6282
- resolved.push({ ...sibling, org: coreOrg, repo, repoState: "present" });
6283
- }
6284
- return resolved;
6285
- }
6286
-
6287
- // src/commands/teardown.ts
6288
6478
  var teardownCommand = new Command22("teardown").description(
6289
6479
  "Destroy all infrastructure then remove the repo, IAM role, and state bucket \u2014 single command"
6290
6480
  ).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(
@@ -6594,27 +6784,6 @@ Destroying sibling ${sibling.org}/${sibling.repo} (${env})...`);
6594
6784
  }
6595
6785
  var SIBLING_DESTROY_WORKFLOW = "destroy-infra.yml";
6596
6786
  var SIBLING_DESTROY_WORKFLOW_PATH = `.github/workflows/${SIBLING_DESTROY_WORKFLOW}`;
6597
- async function discoverSiblings(github, coreOrg, coreRepo, coreProjectName) {
6598
- const sources = [];
6599
- for (const env of REGISTRY_ENVIRONMENTS) {
6600
- const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env));
6601
- sources.push({ environment: env, entries: parseRegistry(contents) });
6602
- }
6603
- const openPrs = await github.listOpenPullRequests(coreOrg, coreRepo);
6604
- for (const pr of openPrs) {
6605
- if (!pr.headRef.startsWith(REGISTRATION_BRANCH_PREFIX)) continue;
6606
- for (const env of REGISTRY_ENVIRONMENTS) {
6607
- const contents = await github.getFileContent(coreOrg, coreRepo, registryPath(env), pr.headRef);
6608
- sources.push({
6609
- environment: env,
6610
- entries: parseRegistry(contents),
6611
- pendingRegistrationPr: pr.number
6612
- });
6613
- }
6614
- }
6615
- const discovered = collectSiblings(sources);
6616
- return resolveSiblingRepos(github, coreOrg, coreProjectName, discovered);
6617
- }
6618
6787
  async function assertSiblingsAreDestroyable(github, siblings) {
6619
6788
  const missing = [];
6620
6789
  for (const sibling of siblings) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.41.9",
3
+ "version": "0.41.11",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",