@biffo/cli 0.97.0 → 0.99.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 +341 -78
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command25 } from "commander";
4
+ import { Command as Command26 } from "commander";
5
5
 
6
6
  // src/commands/core.ts
7
7
  import { Command as Command4 } from "commander";
@@ -819,7 +819,7 @@ var GitHubAdapter = class {
819
819
  } catch (err) {
820
820
  if (err.status !== 404) throw err;
821
821
  }
822
- await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
822
+ await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
823
823
  }
824
824
  throw new Error(
825
825
  `Branch "${branch}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -834,7 +834,7 @@ var GitHubAdapter = class {
834
834
  } catch (err) {
835
835
  if (err.status !== 404) throw err;
836
836
  }
837
- await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
837
+ await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
838
838
  }
839
839
  throw new Error(
840
840
  `Ref "${ref}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -1039,7 +1039,7 @@ var GitHubAdapter = class {
1039
1039
  }
1040
1040
  if (status !== 404 || Date.now() >= deadline) throw err;
1041
1041
  log.info("Branch protection endpoint not yet ready, retrying...");
1042
- await new Promise((resolve18) => setTimeout(resolve18, protectionIntervalMs));
1042
+ await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
1043
1043
  }
1044
1044
  }
1045
1045
  }
@@ -1265,7 +1265,7 @@ var GitHubAdapter = class {
1265
1265
  } catch (err) {
1266
1266
  if (err.status !== 404 || Date.now() >= deadline) throw err;
1267
1267
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
1268
- await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1268
+ await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
1269
1269
  }
1270
1270
  }
1271
1271
  }
@@ -1284,7 +1284,7 @@ var GitHubAdapter = class {
1284
1284
  } catch (err) {
1285
1285
  if (err.status !== 404 || Date.now() >= deadline) throw err;
1286
1286
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
1287
- await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1287
+ await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
1288
1288
  }
1289
1289
  }
1290
1290
  }
@@ -1308,7 +1308,7 @@ var GitHubAdapter = class {
1308
1308
  } else {
1309
1309
  log.info(" Waiting for run to be queued...");
1310
1310
  }
1311
- await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1311
+ await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
1312
1312
  }
1313
1313
  throw new Error(
1314
1314
  `Workflow ${workflowId} did not complete within ${timeoutMs / 1e3 / 60} minutes`
@@ -2094,26 +2094,82 @@ function declaredVariables(moduleDir) {
2094
2094
  }
2095
2095
  return names;
2096
2096
  }
2097
+ var USER_FACING_PLUGIN_OUTPUTS = [
2098
+ "function_url_domain",
2099
+ "frontend_bucket_regional_domain",
2100
+ "frontend_bucket_name"
2101
+ ];
2102
+ function rootPluginOutputName(pluginName, output) {
2103
+ return `plugin_${pluginName}_${output}`;
2104
+ }
2105
+ function pluginOutputsFromRoot(pluginName, rootOutputs) {
2106
+ const flat = {};
2107
+ for (const key of USER_FACING_PLUGIN_OUTPUTS) {
2108
+ const value = rootOutputs[rootPluginOutputName(pluginName, key)];
2109
+ if (value !== void 0) flat[key] = value;
2110
+ }
2111
+ return flat;
2112
+ }
2113
+ function declaredOutputs(moduleDir) {
2114
+ const names = /* @__PURE__ */ new Set();
2115
+ let entries;
2116
+ try {
2117
+ entries = readdirSync3(moduleDir, { withFileTypes: true });
2118
+ } catch {
2119
+ return names;
2120
+ }
2121
+ for (const entry of entries) {
2122
+ if (!entry.isFile() || !entry.name.endsWith(".tf")) continue;
2123
+ let contents;
2124
+ try {
2125
+ contents = readFileSync7(join10(moduleDir, entry.name), "utf8");
2126
+ } catch {
2127
+ continue;
2128
+ }
2129
+ for (const match of contents.matchAll(/^\s*output\s+"([^"]+)"/gm)) {
2130
+ names.add(match[1]);
2131
+ }
2132
+ }
2133
+ return names;
2134
+ }
2097
2135
  function renderArguments(args, indent) {
2098
2136
  const width = Math.max(...args.map(([key]) => key.length));
2099
2137
  return args.map(([key, value]) => `${indent}${key.padEnd(width)} = ${value}`).join("\n");
2100
2138
  }
2101
- function renderModuleBlock(pluginName, declared, handler, source) {
2139
+ function renderModuleBlock(pluginName, declared, handler, source, declaredOutputNames) {
2102
2140
  const args = standardArguments(pluginName, handler).filter(([key]) => declared.has(key));
2103
2141
  const quoted = JSON.stringify(pluginName);
2104
- return [
2142
+ const outputBlock = (name, description, attr) => [
2143
+ "",
2144
+ `output "${name}" {`,
2145
+ ` description = "${description}"`,
2146
+ ` value = try(module.plugin_${pluginName}[${quoted}].${attr}, null)`,
2147
+ "}"
2148
+ ];
2149
+ const lines = [
2105
2150
  `module "plugin_${pluginName}" {`,
2106
2151
  ` source = "${source}"`,
2107
2152
  ` for_each = contains(var.enabled_plugins, ${quoted}) ? { ${quoted} = true } : {}`,
2108
2153
  "",
2109
2154
  renderArguments(args, " "),
2110
2155
  "}",
2111
- "",
2112
- `output "plugin_${pluginName}_function_arn" {`,
2113
- ` description = "Lambda ARN of the ${pluginName} plugin, or null when it is not in enabled_plugins."`,
2114
- ` value = try(module.plugin_${pluginName}[${quoted}].function_arn, null)`,
2115
- "}"
2116
- ].join("\n");
2156
+ ...outputBlock(
2157
+ `plugin_${pluginName}_function_arn`,
2158
+ `Lambda ARN of the ${pluginName} plugin, or null when it is not in enabled_plugins.`,
2159
+ "function_arn"
2160
+ )
2161
+ ];
2162
+ for (const output of USER_FACING_PLUGIN_OUTPUTS) {
2163
+ if (!declaredOutputNames.has(output)) continue;
2164
+ lines.push(
2165
+ ...outputBlock(
2166
+ rootPluginOutputName(pluginName, output),
2167
+ `${output.replace(/_/g, " ")} of the ${pluginName} plugin (ADR-0018 register step), or null when it is not enabled.`,
2168
+ output
2169
+ )
2170
+ );
2171
+ }
2172
+ return lines.join("\n");
2117
2173
  }
2118
2174
  var GENERATED_HEADER = `# ---------------------------------------------------------------------------
2119
2175
  # GENERATED FILE \u2014 DO NOT EDIT BY HAND.
@@ -2143,7 +2199,8 @@ function renderGeneratedTerraform(plugins) {
2143
2199
  p.name,
2144
2200
  p.declaredVariables,
2145
2201
  p.handler ?? DEFAULT_PLUGIN_HANDLER,
2146
- p.source ?? THIRD_PARTY_TERRAFORM(p.name)
2202
+ p.source ?? THIRD_PARTY_TERRAFORM(p.name),
2203
+ p.declaredOutputs ?? /* @__PURE__ */ new Set()
2147
2204
  )
2148
2205
  );
2149
2206
  return `${GENERATED_HEADER}
@@ -2165,6 +2222,7 @@ function syncPluginTerraform(cwd) {
2165
2222
  return {
2166
2223
  name,
2167
2224
  declaredVariables: declaredVariables(moduleDir),
2225
+ declaredOutputs: declaredOutputs(moduleDir),
2168
2226
  source: pluginModuleSource(cwd, name)
2169
2227
  };
2170
2228
  });
@@ -2862,7 +2920,7 @@ var AwsAdapter = class {
2862
2920
  const code = err.Code;
2863
2921
  if (code === "OperationAborted" && attempt < maxAttempts) {
2864
2922
  log.info(` Waiting for S3 to release "${bucketName}"... (${attempt}/${maxAttempts})`);
2865
- await new Promise((resolve18) => setTimeout(resolve18, retryDelayMs));
2923
+ await new Promise((resolve19) => setTimeout(resolve19, retryDelayMs));
2866
2924
  } else if (code === "OperationAborted") {
2867
2925
  return false;
2868
2926
  } else {
@@ -5977,7 +6035,7 @@ async function promptForConfig(awsAccountId, awsRegion, awsProfile) {
5977
6035
  }
5978
6036
 
5979
6037
  // src/commands/plugin.ts
5980
- import { Command as Command20 } from "commander";
6038
+ import { Command as Command21 } from "commander";
5981
6039
 
5982
6040
  // src/commands/plugin-create.ts
5983
6041
  import { existsSync as existsSync23, readFileSync as readFileSync17 } from "fs";
@@ -6135,6 +6193,22 @@ var RouteDefSchema = z6.object({
6135
6193
  });
6136
6194
  }
6137
6195
  });
6196
+ var USER_PATH_SEGMENT = /^[a-z][a-z0-9_-]*$/;
6197
+ var HANDLER_IMPORT_PATH = /^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)+$/;
6198
+ var REL_DIR = /^[\w][\w./-]*$/;
6199
+ var NON_EMPTY_GROUP = "required_group must be a non-empty Cognito group name.";
6200
+ var UserIngressSchema = z6.object({
6201
+ path: z6.string().regex(USER_PATH_SEGMENT, "must be a single lowercase path segment, e.g. api").default("api"),
6202
+ required_group: z6.string().min(1, NON_EMPTY_GROUP),
6203
+ handler: z6.string().regex(HANDLER_IMPORT_PATH, "must be a dotted import path, e.g. 'ideation.app.handler'")
6204
+ }).strict();
6205
+ var UserFrontendSchema = z6.object({
6206
+ dir: z6.string().regex(
6207
+ REL_DIR,
6208
+ "must be a repo-relative path with no leading slash or traversal, e.g. web/dist"
6209
+ ),
6210
+ required_group: z6.string().min(1, NON_EMPTY_GROUP)
6211
+ }).strict();
6138
6212
  var PluginManifestSchema = z6.object({
6139
6213
  name: z6.string().regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase kebab-case slug"),
6140
6214
  version: z6.string().regex(/^\d+\.\d+\.\d+$/, "must be a full semver, e.g. 1.2.3"),
@@ -6149,7 +6223,11 @@ var PluginManifestSchema = z6.object({
6149
6223
  // lib/plugin-terraform-guard.ts. Kept loose deliberately: the authoritative
6150
6224
  // schema is the registry's, and this consumer only needs to count them.
6151
6225
  event_subscriptions: z6.array(z6.object({ source: z6.string(), detail_type: z6.string() }).passthrough()).default([]),
6152
- required_core_version: z6.string().default(">=0.0.0")
6226
+ required_core_version: z6.string().default(">=0.0.0"),
6227
+ // ADR-0018 user-facing surfaces. Optional: a plugin without them is an
6228
+ // ordinary (data/event/CRUD) plugin.
6229
+ user_ingress: UserIngressSchema.optional(),
6230
+ user_frontend: UserFrontendSchema.optional()
6153
6231
  }).superRefine((manifest, ctx) => {
6154
6232
  const tableNames = new Set(manifest.tables.map((t) => t.name));
6155
6233
  for (const route of manifest.api_routes) {
@@ -6817,6 +6895,28 @@ async function runPluginInstall(target, options, deps) {
6817
6895
  console.log(chalk15.dim(` git push`));
6818
6896
  console.log(chalk15.dim(` biffo deploy <environment> --app-only
6819
6897
  `));
6898
+ if (manifest.user_ingress || manifest.user_frontend) {
6899
+ const group = manifest.user_ingress?.required_group ?? manifest.user_frontend?.required_group;
6900
+ console.log(chalk15.bold(` "${pluginName}" is a user-facing plugin (ADR-0018).`));
6901
+ console.log(
6902
+ ` It serves an authenticated${group ? ` ${group}-gated` : ""} app at <base>/${pluginName}/ and an api at <base>/${pluginName}/api/*.`
6903
+ );
6904
+ console.log(
6905
+ " Two more steps route it onto the shared CloudFront (apply is pipeline-only):\n"
6906
+ );
6907
+ console.log(
6908
+ " 1. After the deploy above applies, register its CDN origins from the plugin module outputs, then redeploy so the CDN gains its behaviours:"
6909
+ );
6910
+ console.log(chalk15.dim(` biffo plugin wire ${pluginName} <environment>`));
6911
+ console.log(chalk15.dim(` git push && biffo deploy <environment>`));
6912
+ console.log(" 2. Build and sync its frontend to the bucket that first apply created");
6913
+ console.log(
6914
+ chalk15.dim(
6915
+ ` (the plugin's own deploy workflow does this \u2014 see ${relTargetDir}/.github/workflows/).
6916
+ `
6917
+ )
6918
+ );
6919
+ }
6820
6920
  } finally {
6821
6921
  source.cleanup();
6822
6922
  }
@@ -7278,24 +7378,187 @@ function printDryRun6(entry, currentVersion) {
7278
7378
  `);
7279
7379
  }
7280
7380
 
7381
+ // src/commands/plugin-wire.ts
7382
+ import { existsSync as existsSync30, readFileSync as readFileSync23 } from "fs";
7383
+ import { resolve as resolve17 } from "path";
7384
+ import chalk20 from "chalk";
7385
+ import { Command as Command20 } from "commander";
7386
+
7387
+ // src/lib/plugin-origin.ts
7388
+ import { existsSync as existsSync29, readFileSync as readFileSync22, writeFileSync as writeFileSync9 } from "fs";
7389
+ import { join as join30 } from "path";
7390
+ var PLUGIN_API_TFVARS = "plugin-apis.auto.tfvars.json";
7391
+ var SIBLINGS_TFVARS = "siblings.auto.tfvars.json";
7392
+ function upsertPluginApiOrigin(existing, entry) {
7393
+ return [...existing.filter((p) => p.name !== entry.name), entry];
7394
+ }
7395
+ function serializePluginApiRegistry(origins) {
7396
+ return JSON.stringify({ plugin_api_origins: origins }, null, 2) + "\n";
7397
+ }
7398
+ function readArray(path, key) {
7399
+ if (!existsSync29(path)) return [];
7400
+ try {
7401
+ const parsed = JSON.parse(readFileSync22(path, "utf8"));
7402
+ const arr = parsed[key];
7403
+ return Array.isArray(arr) ? arr : [];
7404
+ } catch {
7405
+ return [];
7406
+ }
7407
+ }
7408
+ function registerUserFacingPlugin(cwd, environment, reg) {
7409
+ const envDir = join30(cwd, "infra", "environments", environment);
7410
+ const apiPath = join30(envDir, PLUGIN_API_TFVARS);
7411
+ const siblingPath = join30(envDir, SIBLINGS_TFVARS);
7412
+ const apis = upsertPluginApiOrigin(readArray(apiPath, "plugin_api_origins"), {
7413
+ name: reg.name,
7414
+ function_url_domain: reg.functionUrlDomain
7415
+ });
7416
+ writeFileSync9(apiPath, serializePluginApiRegistry(apis));
7417
+ const siblings = upsertSiblingOrigin(readArray(siblingPath, "sibling_origins"), {
7418
+ name: reg.name,
7419
+ bucket_regional_domain: reg.bucketRegionalDomain
7420
+ });
7421
+ writeFileSync9(siblingPath, serializeRegistry(siblings));
7422
+ return [
7423
+ `infra/environments/${environment}/${PLUGIN_API_TFVARS}`,
7424
+ `infra/environments/${environment}/${SIBLINGS_TFVARS}`
7425
+ ];
7426
+ }
7427
+ var REQUIRED_PLUGIN_OUTPUTS = [
7428
+ "function_url_domain",
7429
+ "frontend_bucket_regional_domain",
7430
+ "frontend_bucket_name"
7431
+ ];
7432
+ function wireUserFacingPluginFromOutputs(cwd, environment, name, outputs) {
7433
+ const missing = REQUIRED_PLUGIN_OUTPUTS.filter((k) => !outputs[k]?.trim());
7434
+ if (missing.length > 0) {
7435
+ throw new Error(
7436
+ `Cannot register the user-facing plugin "${name}" for ${environment}: its Terraform apply did not expose ${missing.join(", ")}. A registration written without these would route ${name}/api/* and ${name}/* at nothing (the frontend 403s, the api 502s). Confirm the plugin module applied for ${environment} before wiring.`
7437
+ );
7438
+ }
7439
+ const registeredPaths = registerUserFacingPlugin(cwd, environment, {
7440
+ name,
7441
+ functionUrlDomain: outputs["function_url_domain"],
7442
+ bucketRegionalDomain: outputs["frontend_bucket_regional_domain"]
7443
+ });
7444
+ return { registeredPaths, frontendBucketName: outputs["frontend_bucket_name"] };
7445
+ }
7446
+
7447
+ // src/commands/plugin-wire.ts
7448
+ var VALID_ENVIRONMENTS2 = ["dev", "staging", "prod"];
7449
+ async function runPluginWire(pluginName, environment, config, deps, cwd = process.cwd()) {
7450
+ const awsConfig2 = config.cloud.config;
7451
+ const stateBucket = awsConfig2.tf_state_bucket ?? `${config.project.name}-terraform-state-${awsConfig2.account_id}`;
7452
+ const stateKey = `${environment}/terraform.tfstate`;
7453
+ log.info(`Reading Terraform outputs from s3://${stateBucket}/${stateKey}...`);
7454
+ const outputs = await deps.readOutputs(stateBucket, stateKey);
7455
+ const pluginOutputs = pluginOutputsFromRoot(pluginName, outputs);
7456
+ const targets = wireUserFacingPluginFromOutputs(cwd, environment, pluginName, pluginOutputs);
7457
+ console.log(chalk20.bold(`
7458
+ Registered "${pluginName}" CDN origins for ${environment}
7459
+ `));
7460
+ for (const path of targets.registeredPaths) console.log(` ${chalk20.green("~")} ${path}`);
7461
+ console.log(
7462
+ "\n Commit these and redeploy so the CDN gains its behaviours (apply is pipeline-only):"
7463
+ );
7464
+ console.log(chalk20.dim(` git add ${targets.registeredPaths.join(" ")}`));
7465
+ console.log(chalk20.dim(` git commit -m "infra(cdn): route ${pluginName} (${environment})"`));
7466
+ console.log(chalk20.dim(` git push && biffo deploy ${environment}
7467
+ `));
7468
+ console.log(
7469
+ ` Then build and sync the frontend to its bucket: ${chalk20.bold(targets.frontendBucketName)}`
7470
+ );
7471
+ console.log(
7472
+ chalk20.dim(
7473
+ " (the plugin's own deploy workflow does this \u2014 it reads the bucket from the same outputs.)\n"
7474
+ )
7475
+ );
7476
+ return targets;
7477
+ }
7478
+ var pluginWireCommand = new Command20("wire").description(
7479
+ "Register a user-facing plugin's CDN origins from its deployed outputs (ADR-0018 register step): biffo plugin wire <name> <environment>"
7480
+ ).argument("<name>", "Plugin name, e.g. ideation").argument("<environment>", "Target environment: dev | staging | prod").option("-p, --project <name>", "Project name (overrides biffo.config.json in current directory)").option("-c, --config <path>", "Path to biffo.config.json").action(
7481
+ async (name, environment, options) => {
7482
+ if (!VALID_ENVIRONMENTS2.includes(environment)) {
7483
+ log.error(
7484
+ `Unknown environment: ${environment}. Must be one of: ${VALID_ENVIRONMENTS2.join(", ")}`
7485
+ );
7486
+ process.exit(1);
7487
+ }
7488
+ const config = await resolveConfig4(options);
7489
+ const aws = new AwsAdapter(config);
7490
+ try {
7491
+ await runPluginWire(name, environment, config, {
7492
+ readOutputs: (bucket, key) => aws.readTerraformOutputs(bucket, key)
7493
+ });
7494
+ } catch (err) {
7495
+ log.error(err.message);
7496
+ process.exit(1);
7497
+ }
7498
+ }
7499
+ );
7500
+ async function resolveConfig4(options) {
7501
+ if (options.config) {
7502
+ const raw = JSON.parse(readFileSync23(resolve17(options.config), "utf8"));
7503
+ const result = BiffoConfigSchema.safeParse(raw);
7504
+ if (!result.success) {
7505
+ log.error(`Invalid config at ${options.config}:`);
7506
+ result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
7507
+ process.exit(1);
7508
+ }
7509
+ return result.data;
7510
+ }
7511
+ if (options.project) {
7512
+ const cfg = loadProjectConfig(options.project);
7513
+ if (!cfg) {
7514
+ log.error(
7515
+ `Project "${options.project}" not found in ~/.biffo/projects/. Run biffo init first or pass --config <path>.`
7516
+ );
7517
+ process.exit(1);
7518
+ }
7519
+ return cfg;
7520
+ }
7521
+ const localConfigPath = resolve17(process.cwd(), "biffo.config.json");
7522
+ if (existsSync30(localConfigPath)) {
7523
+ const raw = JSON.parse(readFileSync23(localConfigPath, "utf8"));
7524
+ const result = BiffoConfigSchema.safeParse(raw);
7525
+ if (result.success) return result.data;
7526
+ if (isTemplatePlaceholderConfig(raw)) {
7527
+ log.warn(
7528
+ `Ignoring ${localConfigPath} \u2014 it is the unsubstituted Biffo template placeholder, not this project's config.`
7529
+ );
7530
+ } else {
7531
+ log.error(`Invalid config at ${localConfigPath}:`);
7532
+ result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
7533
+ log.error("Refusing to fall back to a saved project while a local config file is present.");
7534
+ process.exit(1);
7535
+ }
7536
+ }
7537
+ log.error(
7538
+ "No config found. Run inside a Biffo project, or pass --project <name> / --config <path>."
7539
+ );
7540
+ return process.exit(1);
7541
+ }
7542
+
7281
7543
  // src/commands/plugin.ts
7282
- var pluginCommand = new Command20("plugin").description("Manage Biffo plugins");
7544
+ var pluginCommand = new Command21("plugin").description("Manage Biffo plugins");
7283
7545
  pluginCommand.addCommand(pluginCreateCommand);
7284
7546
  pluginCommand.addCommand(pluginListCommand);
7285
7547
  pluginCommand.addCommand(pluginInstallCommand);
7548
+ pluginCommand.addCommand(pluginWireCommand);
7286
7549
  pluginCommand.addCommand(pluginUninstallCommand);
7287
7550
  pluginCommand.addCommand(pluginUpgradeCommand);
7288
7551
  pluginCommand.addCommand(pluginSyncMigrationsCommand);
7289
7552
  pluginCommand.addCommand(pluginInfoCommand);
7290
7553
 
7291
7554
  // src/commands/sibling.ts
7292
- import { Command as Command22 } from "commander";
7555
+ import { Command as Command23 } from "commander";
7293
7556
 
7294
7557
  // src/commands/sibling-check-identity.ts
7295
- import { existsSync as existsSync29, readFileSync as readFileSync22 } from "fs";
7296
- import { resolve as resolve17 } from "path";
7297
- import chalk20 from "chalk";
7298
- import { Command as Command21 } from "commander";
7558
+ import { existsSync as existsSync31, readFileSync as readFileSync24 } from "fs";
7559
+ import { resolve as resolve18 } from "path";
7560
+ import chalk21 from "chalk";
7561
+ import { Command as Command22 } from "commander";
7299
7562
 
7300
7563
  // src/lib/sibling-identity-check.ts
7301
7564
  function checkSiblingIdentity(envs) {
@@ -7345,19 +7608,19 @@ function checkSiblingIdentity(envs) {
7345
7608
  }
7346
7609
 
7347
7610
  // src/commands/sibling-check-identity.ts
7348
- var VALID_ENVIRONMENTS2 = ["dev", "staging", "prod"];
7611
+ var VALID_ENVIRONMENTS3 = ["dev", "staging", "prod"];
7349
7612
  var SIBLING_CORE_POOL_VAR = "CORE_COGNITO_USER_POOL_ID";
7350
- var siblingCheckIdentityCommand = new Command21("check-identity").description(
7613
+ var siblingCheckIdentityCommand = new Command22("check-identity").description(
7351
7614
  "Detect when a core's Cognito pool has drifted from its published identity document or any sibling's baked-in CORE_COGNITO_USER_POOL_ID (#400). Run from the core repo; exits non-zero on drift so a scheduled/CI run goes red."
7352
7615
  ).option("--env <environment>", "Only check this environment (default: dev, staging, prod)").option("-p, --project <name>", "Project name (overrides biffo.config.json in current directory)").option("-c, --config <path>", "Path to biffo.config.json").action(async (options) => {
7353
- if (options.env && !VALID_ENVIRONMENTS2.includes(options.env)) {
7616
+ if (options.env && !VALID_ENVIRONMENTS3.includes(options.env)) {
7354
7617
  log.error(
7355
- `Unknown environment: ${options.env}. Must be one of: ${VALID_ENVIRONMENTS2.join(", ")}`
7618
+ `Unknown environment: ${options.env}. Must be one of: ${VALID_ENVIRONMENTS3.join(", ")}`
7356
7619
  );
7357
7620
  process.exit(1);
7358
7621
  }
7359
- const environments = options.env ? [options.env] : [...VALID_ENVIRONMENTS2];
7360
- const config = await resolveConfig4(options);
7622
+ const environments = options.env ? [options.env] : [...VALID_ENVIRONMENTS3];
7623
+ const config = await resolveConfig5(options);
7361
7624
  const { org, repo } = config.source_control.config;
7362
7625
  const awsConfig2 = config.cloud.config;
7363
7626
  const stateBucket = awsConfig2.tf_state_bucket ?? `${config.project.name}-terraform-state-${awsConfig2.account_id}`;
@@ -7367,7 +7630,7 @@ var siblingCheckIdentityCommand = new Command21("check-identity").description(
7367
7630
  github: new GitHubAdapter(token),
7368
7631
  fetchIdentityDoc: fetchPublishedIdentity
7369
7632
  };
7370
- console.log(chalk20.bold(`
7633
+ console.log(chalk21.bold(`
7371
7634
  Biffo \u2014 Sibling identity check (${config.project.name})
7372
7635
  `));
7373
7636
  let result;
@@ -7454,16 +7717,16 @@ function printIdentityReport(result) {
7454
7717
  }
7455
7718
  if (result.ok) {
7456
7719
  log.success(
7457
- chalk20.green(
7720
+ chalk21.green(
7458
7721
  "\u2713 identity consistent \u2014 every published document and sibling backend matches the live pool"
7459
7722
  )
7460
7723
  );
7461
7724
  return;
7462
7725
  }
7463
- log.error(chalk20.red(`\u2718 ${String(result.findings.length)} identity drift finding(s):`));
7726
+ log.error(chalk21.red(`\u2718 ${String(result.findings.length)} identity drift finding(s):`));
7464
7727
  for (const f of result.findings) {
7465
7728
  console.error(
7466
- chalk20.red(
7729
+ chalk21.red(
7467
7730
  ` [${f.environment}] ${f.subject}: ${FINDING_LABEL[f.kind]}
7468
7731
  expected (live pool): ${f.expected}
7469
7732
  found: ${f.actual ?? "(unset)"}`
@@ -7484,9 +7747,9 @@ async function fetchPublishedIdentity(portalUrl) {
7484
7747
  return null;
7485
7748
  }
7486
7749
  }
7487
- async function resolveConfig4(options) {
7750
+ async function resolveConfig5(options) {
7488
7751
  if (options.config) {
7489
- const raw = JSON.parse(readFileSync22(resolve17(options.config), "utf8"));
7752
+ const raw = JSON.parse(readFileSync24(resolve18(options.config), "utf8"));
7490
7753
  const result = BiffoConfigSchema.safeParse(raw);
7491
7754
  if (!result.success) {
7492
7755
  log.error(`Invalid config at ${options.config}:`);
@@ -7505,9 +7768,9 @@ async function resolveConfig4(options) {
7505
7768
  }
7506
7769
  return cfg;
7507
7770
  }
7508
- const localConfigPath = resolve17(process.cwd(), "biffo.config.json");
7509
- if (existsSync29(localConfigPath)) {
7510
- const raw = JSON.parse(readFileSync22(localConfigPath, "utf8"));
7771
+ const localConfigPath = resolve18(process.cwd(), "biffo.config.json");
7772
+ if (existsSync31(localConfigPath)) {
7773
+ const raw = JSON.parse(readFileSync24(localConfigPath, "utf8"));
7511
7774
  const result = BiffoConfigSchema.safeParse(raw);
7512
7775
  if (result.success) return result.data;
7513
7776
  if (isTemplatePlaceholderConfig(raw)) {
@@ -7543,14 +7806,14 @@ async function resolveConfig4(options) {
7543
7806
  }
7544
7807
 
7545
7808
  // src/commands/sibling.ts
7546
- var siblingCommand = new Command22("sibling").description(
7809
+ var siblingCommand = new Command23("sibling").description(
7547
7810
  "Create and manage sibling apps that share a Biffo core project (ADR-0007)"
7548
7811
  );
7549
7812
  siblingCommand.addCommand(siblingCreateCommand);
7550
7813
  siblingCommand.addCommand(siblingCheckIdentityCommand);
7551
7814
 
7552
7815
  // src/commands/check.ts
7553
- import { Command as Command23 } from "commander";
7816
+ import { Command as Command24 } from "commander";
7554
7817
 
7555
7818
  // src/scripts/check-core-ownership.ts
7556
7819
  import { execa as execa5 } from "execa";
@@ -7576,8 +7839,8 @@ async function runOwnershipCheck(argv) {
7576
7839
  const { stdout } = await execa5("git", ["diff", "--cached", "--name-status"], { cwd: root });
7577
7840
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
7578
7841
  if (messageFile) {
7579
- const { readFileSync: readFileSync24, existsSync: existsSync31 } = await import("fs");
7580
- if (existsSync31(messageFile)) commitMessage = readFileSync24(messageFile, "utf8");
7842
+ const { readFileSync: readFileSync26, existsSync: existsSync33 } = await import("fs");
7843
+ if (existsSync33(messageFile)) commitMessage = readFileSync26(messageFile, "utf8");
7581
7844
  }
7582
7845
  } else {
7583
7846
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -7681,8 +7944,8 @@ ${BOLD}If the divergence is deliberate${OFF}
7681
7944
  import { execa as execa6 } from "execa";
7682
7945
 
7683
7946
  // src/lib/plugin-terraform-guard.ts
7684
- import { existsSync as existsSync30, readFileSync as readFileSync23, readdirSync as readdirSync12 } from "fs";
7685
- import { dirname as dirname9, join as join30, relative as relative6, sep as sep3 } from "path";
7947
+ import { existsSync as existsSync32, readFileSync as readFileSync25, readdirSync as readdirSync12 } from "fs";
7948
+ import { dirname as dirname9, join as join31, relative as relative6, sep as sep3 } from "path";
7686
7949
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
7687
7950
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
7688
7951
  function findPluginManifests(root) {
@@ -7697,9 +7960,9 @@ function findPluginManifests(root) {
7697
7960
  for (const entry of entries) {
7698
7961
  if (entry.isDirectory()) {
7699
7962
  if (SKIP_DIRS.has(entry.name)) continue;
7700
- walk(join30(dir, entry.name));
7963
+ walk(join31(dir, entry.name));
7701
7964
  } else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
7702
- found.push(relative6(root, join30(dir, entry.name)).split(sep3).join("/"));
7965
+ found.push(relative6(root, join31(dir, entry.name)).split(sep3).join("/"));
7703
7966
  }
7704
7967
  }
7705
7968
  };
@@ -7709,7 +7972,7 @@ function findPluginManifests(root) {
7709
7972
  function readSubscriptions(absManifestPath) {
7710
7973
  let parsed;
7711
7974
  try {
7712
- parsed = JSON.parse(readFileSync23(absManifestPath, "utf8"));
7975
+ parsed = JSON.parse(readFileSync25(absManifestPath, "utf8"));
7713
7976
  } catch {
7714
7977
  return null;
7715
7978
  }
@@ -7724,14 +7987,14 @@ function readSubscriptions(absManifestPath) {
7724
7987
  }
7725
7988
  function checkPluginTerraform(root) {
7726
7989
  const violations = [];
7727
- const coreManifest = existsSync30(join30(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
7990
+ const coreManifest = existsSync32(join31(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
7728
7991
  for (const manifest of findPluginManifests(root)) {
7729
7992
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
7730
- const absManifest = join30(root, manifest);
7993
+ const absManifest = join31(root, manifest);
7731
7994
  const subscriptions = readSubscriptions(absManifest);
7732
7995
  if (subscriptions === null) continue;
7733
7996
  const pluginDir2 = dirname9(absManifest);
7734
- if (existsSync30(join30(pluginDir2, "terraform"))) continue;
7997
+ if (existsSync32(join31(pluginDir2, "terraform"))) continue;
7735
7998
  const relPluginDir = relative6(root, pluginDir2).split(sep3).join("/");
7736
7999
  violations.push({
7737
8000
  manifest,
@@ -7850,7 +8113,7 @@ async function runReleaseSubjectCheck(argv) {
7850
8113
  }
7851
8114
 
7852
8115
  // src/commands/check.ts
7853
- var checkCommand = new Command23("check").description(
8116
+ var checkCommand = new Command24("check").description(
7854
8117
  "Repo guards (ownership, release subject, plugin terraform) \u2014 run in CI and git hooks"
7855
8118
  );
7856
8119
  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 () => {
@@ -7870,17 +8133,17 @@ function rawArgsAfter(subcommand) {
7870
8133
  // src/commands/teardown.ts
7871
8134
  import { execSync as execSync7 } from "child_process";
7872
8135
  import { GetCallerIdentityCommand as GetCallerIdentityCommand3, STSClient as STSClient3 } from "@aws-sdk/client-sts";
7873
- import chalk21 from "chalk";
7874
- import { Command as Command24 } from "commander";
8136
+ import chalk22 from "chalk";
8137
+ import { Command as Command25 } from "commander";
7875
8138
  import inquirer8 from "inquirer";
7876
- var teardownCommand = new Command24("teardown").description(
8139
+ var teardownCommand = new Command25("teardown").description(
7877
8140
  "Destroy all infrastructure then remove the repo, IAM role, and state bucket \u2014 single command"
7878
8141
  ).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(
7879
8142
  "--confirm <name>",
7880
8143
  "Pre-confirm by supplying the project name (the scriptable form of the typed confirmation)"
7881
8144
  ).option("-y, --yes", "Skip the typed project-name confirmation entirely").action(
7882
8145
  async (options) => {
7883
- console.log(chalk21.bold("\n Biffo \u2014 Teardown\n"));
8146
+ console.log(chalk22.bold("\n Biffo \u2014 Teardown\n"));
7884
8147
  const githubToken = resolveGithubToken4();
7885
8148
  const sts = new STSClient3({});
7886
8149
  const { Account: accountId } = await sts.send(new GetCallerIdentityCommand3({}));
@@ -7902,7 +8165,7 @@ var teardownCommand = new Command24("teardown").description(
7902
8165
  adminEmail = session.config.admin?.email ?? "noop@example.com";
7903
8166
  adminUsername = session.config.admin?.username ?? "noop";
7904
8167
  domain = session.config.project.domain ?? "";
7905
- console.log(chalk21.yellow(" Loaded session for: ") + chalk21.bold(projectName));
8168
+ console.log(chalk22.yellow(" Loaded session for: ") + chalk22.bold(projectName));
7906
8169
  if (org && repo) console.log(` Repository: ${org}/${repo}`);
7907
8170
  console.log();
7908
8171
  } else if (savedConfig) {
@@ -7915,7 +8178,7 @@ var teardownCommand = new Command24("teardown").description(
7915
8178
  adminEmail = savedConfig.admin.email;
7916
8179
  adminUsername = savedConfig.admin.username;
7917
8180
  domain = resolveDnsConfig(savedConfig).domain;
7918
- console.log(chalk21.yellow(" Loaded config for: ") + chalk21.bold(projectName));
8181
+ console.log(chalk22.yellow(" Loaded config for: ") + chalk22.bold(projectName));
7919
8182
  console.log(` Repository: ${org}/${repo}`);
7920
8183
  console.log();
7921
8184
  } else {
@@ -7986,35 +8249,35 @@ var teardownCommand = new Command24("teardown").description(
7986
8249
  throw err;
7987
8250
  }
7988
8251
  }
7989
- console.log(chalk21.red.bold(" This will permanently delete:\n"));
8252
+ console.log(chalk22.red.bold(" This will permanently delete:\n"));
7990
8253
  if (deployedEnvs.length > 0 || hasGlobal) {
7991
- console.log(chalk21.red(" Infrastructure (via GitHub Actions terraform destroy):"));
8254
+ console.log(chalk22.red(" Infrastructure (via GitHub Actions terraform destroy):"));
7992
8255
  for (const env of deployedEnvs) {
7993
8256
  console.log(
7994
- ` ${chalk21.red("\u2717")} ${env} \u2014 VPC, RDS, Lambda, Cognito, CloudFront, EventBridge`
8257
+ ` ${chalk22.red("\u2717")} ${env} \u2014 VPC, RDS, Lambda, Cognito, CloudFront, EventBridge`
7995
8258
  );
7996
8259
  }
7997
8260
  if (hasGlobal) {
7998
- console.log(` ${chalk21.red("\u2717")} global \u2014 Route 53 hosted zone, ACM certificate`);
8261
+ console.log(` ${chalk22.red("\u2717")} global \u2014 Route 53 hosted zone, ACM certificate`);
7999
8262
  }
8000
8263
  console.log();
8001
8264
  }
8002
8265
  for (const line of formatSiblingPlan(siblings, options.skipDestroy === true)) {
8003
8266
  console.log(line);
8004
8267
  }
8005
- console.log(chalk21.red(" Biffo resources:"));
8006
- console.log(` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${org}/${repo}`)}`);
8268
+ console.log(chalk22.red(" Biffo resources:"));
8269
+ console.log(` ${chalk22.red("\u2717")} GitHub repository ${chalk22.bold(`${org}/${repo}`)}`);
8007
8270
  console.log(
8008
- ` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${projectName}`)}`
8271
+ ` ${chalk22.red("\u2717")} IAM role ${chalk22.bold(`biffo-github-actions-${projectName}`)}`
8009
8272
  );
8010
8273
  console.log(
8011
- ` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(stateBucket)} (all versions)`
8274
+ ` ${chalk22.red("\u2717")} S3 bucket ${chalk22.bold(stateBucket)} (all versions)`
8012
8275
  );
8013
- console.log(` ${chalk21.red("\u2717")} Local session file`);
8276
+ console.log(` ${chalk22.red("\u2717")} Local session file`);
8014
8277
  if (options.skipDestroy) {
8015
8278
  console.log();
8016
8279
  console.log(
8017
- chalk21.yellow(
8280
+ chalk22.yellow(
8018
8281
  " --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."
8019
8282
  )
8020
8283
  );
@@ -8202,30 +8465,30 @@ async function assertSiblingsAreDestroyable(github, siblings) {
8202
8465
  }
8203
8466
  function formatSiblingPlan(siblings, skipDestroy) {
8204
8467
  if (siblings.length === 0) return [];
8205
- const lines = [chalk21.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
8468
+ const lines = [chalk22.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
8206
8469
  for (const s of siblings) {
8207
8470
  const envs = s.environments.join(", ");
8208
8471
  if (s.repoState === "gone") {
8209
8472
  lines.push(
8210
- ` ${chalk21.yellow("!")} ${chalk21.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
8473
+ ` ${chalk22.yellow("!")} ${chalk22.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
8211
8474
  );
8212
8475
  continue;
8213
8476
  }
8214
8477
  lines.push(
8215
- ` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
8478
+ ` ${chalk22.red("\u2717")} GitHub repository ${chalk22.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
8216
8479
  );
8217
8480
  lines.push(
8218
- ` ${chalk21.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
8481
+ ` ${chalk22.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
8219
8482
  );
8220
8483
  lines.push(
8221
- ` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${s.projectName}`)}`
8484
+ ` ${chalk22.red("\u2717")} IAM role ${chalk22.bold(`biffo-github-actions-${s.projectName}`)}`
8222
8485
  );
8223
8486
  lines.push(
8224
- ` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
8487
+ ` ${chalk22.red("\u2717")} S3 bucket ${chalk22.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
8225
8488
  );
8226
8489
  if (s.pendingRegistrationPr !== void 0) {
8227
8490
  lines.push(
8228
- chalk21.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
8491
+ chalk22.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
8229
8492
  );
8230
8493
  }
8231
8494
  }
@@ -8263,7 +8526,7 @@ async function confirmTeardown(projectName, options) {
8263
8526
  {
8264
8527
  type: "input",
8265
8528
  name: "confirm",
8266
- message: `Type ${chalk21.bold(projectName)} to confirm:`
8529
+ message: `Type ${chalk22.bold(projectName)} to confirm:`
8267
8530
  }
8268
8531
  ]);
8269
8532
  return confirm === projectName;
@@ -8281,7 +8544,7 @@ function resolveGithubToken4() {
8281
8544
  }
8282
8545
 
8283
8546
  // src/index.ts
8284
- var program = new Command25();
8547
+ var program = new Command26();
8285
8548
  function cliVersion() {
8286
8549
  try {
8287
8550
  return getLatestCoreVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.97.0",
3
+ "version": "0.99.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",