@biffo/cli 0.34.1 → 0.35.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 (3) hide show
  1. package/core.version +1 -1
  2. package/dist/index.js +2300 -2112
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3134,10 +3134,10 @@ function resolveGithubToken2() {
3134
3134
  }
3135
3135
 
3136
3136
  // src/commands/init.ts
3137
- import { readFileSync as readFileSync10 } from "fs";
3138
- import { resolve as resolve9 } from "path";
3139
- import chalk11 from "chalk";
3140
- import { Command as Command11 } from "commander";
3137
+ import { readFileSync as readFileSync12 } from "fs";
3138
+ import { resolve as resolve10 } from "path";
3139
+ import chalk12 from "chalk";
3140
+ import { Command as Command12 } from "commander";
3141
3141
  import inquirer5 from "inquirer";
3142
3142
 
3143
3143
  // src/lib/build-freshness.ts
@@ -3459,1116 +3459,1437 @@ async function resolveRepoIds(github, config) {
3459
3459
  }
3460
3460
  }
3461
3461
 
3462
- // src/commands/init.ts
3463
- var initCommand = new Command11("init").description("Scaffold a new project from the Biffo template").option("-c, --config <path>", "Path to a pre-filled biffo.config.json").option("--dry-run", "Validate config without making any changes").option("--fresh", "Ignore any saved session and start from scratch").option("-y, --yes", "Auto-accept detected credentials without prompting (implied by --config)").action(
3464
- async (options) => {
3462
+ // src/lib/root-sibling.ts
3463
+ var ROOT_SIBLING_NAME = "app";
3464
+ var RESERVED_SIBLING_NAMES = ["admin", "login", ROOT_SIBLING_NAME];
3465
+ function isRootPathPrefix(pathPrefix) {
3466
+ return pathPrefix === "";
3467
+ }
3468
+ function registryNameFor(pathPrefix) {
3469
+ return isRootPathPrefix(pathPrefix) ? ROOT_SIBLING_NAME : pathPrefix;
3470
+ }
3471
+ function displayPath(pathPrefix) {
3472
+ return isRootPathPrefix(pathPrefix) ? "/" : `/${pathPrefix}`;
3473
+ }
3474
+ function basePathFor(pathPrefix) {
3475
+ return isRootPathPrefix(pathPrefix) ? "" : `/${pathPrefix}`;
3476
+ }
3477
+ function rootSiblingProjectName(coreProjectName) {
3478
+ return `${coreProjectName}-app`;
3479
+ }
3480
+ function bucketRegionalDomain(bucketName, region) {
3481
+ return region === "us-east-1" ? `${bucketName}.s3.amazonaws.com` : `${bucketName}.s3.${region}.amazonaws.com`;
3482
+ }
3483
+ function siteBucketName(projectName, environment, accountId) {
3484
+ return `${projectName}-${environment}-site-${accountId}`;
3485
+ }
3486
+ function upsertSiblingOrigin(existing, entry) {
3487
+ return [...existing.filter((s) => s.name !== entry.name), entry];
3488
+ }
3489
+ function serializeRegistry(origins) {
3490
+ return JSON.stringify({ sibling_origins: origins }, null, 2) + "\n";
3491
+ }
3492
+
3493
+ // src/config/sibling-schema.ts
3494
+ import { z as z4 } from "zod";
3495
+ var SiblingConfigSchema = z4.object({
3496
+ $schema: z4.string().optional(),
3497
+ project: z4.object({
3498
+ name: z4.string().min(1).regex(
3499
+ /^[a-z][a-z0-9-]*$/,
3500
+ "Must be lowercase kebab-case, starting with a letter (it becomes a URL path segment)"
3501
+ ),
3502
+ description: z4.string().default(""),
3503
+ // Notable routes this sibling exposes, shown as labelled links on the
3504
+ // core project's Microservices tab (ADR-0007). Each `path` is relative to
3505
+ // the sibling's own path_prefix (so "demo" renders as /<prefix>/demo), and
3506
+ // `label` is the human name. Empty by default — a sibling with no declared
3507
+ // routes just shows its single root link. Declare real routes here as you
3508
+ // build the sibling's pages; the values flow to the core's
3509
+ // siblings.auto.tfvars.json at registration and into siblings.json at deploy.
3510
+ routes: z4.array(
3511
+ z4.object({
3512
+ path: z4.string().min(1).regex(
3513
+ /^[a-z0-9][a-z0-9/-]*$/,
3514
+ 'Sub-path relative to the sibling prefix, no leading slash (e.g. "demo" or "apply")'
3515
+ ),
3516
+ label: z4.string().min(1)
3517
+ })
3518
+ ).default([])
3519
+ }),
3520
+ source_control: SourceControlConfigSchema,
3521
+ cloud: CloudConfigSchema,
3522
+ environments: z4.array(z4.enum(["dev", "staging", "prod"])).min(1).default(["dev"]),
3523
+ // The core project this sibling is paired with (ADR-0007) — never
3524
+ // provisions its own Cognito pool or CloudFront distribution, always
3525
+ // plugs into the core project's.
3526
+ core: z4.object({
3527
+ // Exactly one of these two must be set — see the superRefine below.
3528
+ project_name: z4.string().min(1).optional().describe("Name of a project previously scaffolded with `biffo init` on this machine"),
3529
+ config_path: z4.string().min(1).optional().describe(
3530
+ "Path to the core project's biffo.config.json, for when it wasn't scaffolded here"
3531
+ ),
3532
+ // Defaults to project.name at parse time by the caller (sibling-create.ts),
3533
+ // not here — z.object() has no access to sibling fields from within core's
3534
+ // own schema without restructuring the whole object, and the caller
3535
+ // already has both values in hand when it validates.
3536
+ //
3537
+ // The EMPTY string is meaningful and deliberately allowed: it is the
3538
+ // root application sibling (issue #306), which serves `/` and takes the
3539
+ // CDN's default_cache_behavior instead of a pair of ordered behaviours.
3540
+ // It still registers under a non-empty reserved name ("app") — see
3541
+ // lib/root-sibling.ts for why the two must not be conflated.
3542
+ path_prefix: z4.string().regex(
3543
+ /^$|^[a-z][a-z0-9-]*$/,
3544
+ "Must be lowercase kebab-case, or empty for the root sibling"
3545
+ ).optional()
3546
+ })
3547
+ }).superRefine((config, ctx) => {
3548
+ if (!config.core.project_name && !config.core.config_path) {
3549
+ ctx.addIssue({
3550
+ code: z4.ZodIssueCode.custom,
3551
+ path: ["core"],
3552
+ message: "Either core.project_name or core.config_path is required"
3553
+ });
3554
+ }
3555
+ });
3556
+
3557
+ // src/commands/sibling-create.ts
3558
+ import { cpSync as cpSync2, existsSync as existsSync14, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync4, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
3559
+ import { tmpdir as tmpdir4 } from "os";
3560
+ import { dirname as dirname6, join as join15, resolve as resolve9 } from "path";
3561
+ import chalk11 from "chalk";
3562
+ import { Command as Command11 } from "commander";
3563
+
3564
+ // src/lib/sibling-session.ts
3565
+ import {
3566
+ existsSync as existsSync13,
3567
+ mkdirSync as mkdirSync5,
3568
+ readdirSync as readdirSync7,
3569
+ readFileSync as readFileSync10,
3570
+ rmSync as rmSync5,
3571
+ statSync as statSync4,
3572
+ writeFileSync as writeFileSync5
3573
+ } from "fs";
3574
+ import { homedir as homedir3 } from "os";
3575
+ import { join as join14 } from "path";
3576
+ function sessionsDir2() {
3577
+ return process.env["BIFFO_SIBLING_SESSIONS_DIR"] ?? join14(homedir3(), ".biffo", "sibling-sessions");
3578
+ }
3579
+ function sessionPath2(projectName) {
3580
+ return join14(sessionsDir2(), `${projectName}.json`);
3581
+ }
3582
+ function findLatestSiblingSession() {
3583
+ const dir = sessionsDir2();
3584
+ if (!existsSync13(dir)) return null;
3585
+ const files = readdirSync7(dir).filter((f) => f.endsWith(".json"));
3586
+ if (files.length === 0) return null;
3587
+ const sorted = files.map((f) => {
3588
+ const fullPath = join14(dir, f);
3589
+ const mtime = existsSync13(fullPath) ? statSync4(fullPath).mtimeMs : -1;
3590
+ return { f, mtime };
3591
+ }).sort((a, b) => b.mtime - a.mtime);
3592
+ try {
3593
+ return JSON.parse(readFileSync10(join14(dir, sorted[0].f), "utf8"));
3594
+ } catch {
3595
+ return null;
3596
+ }
3597
+ }
3598
+ function saveSiblingSession(session) {
3599
+ const dir = sessionsDir2();
3600
+ if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
3601
+ const name = session.config.project?.name ?? "unknown";
3602
+ writeFileSync5(sessionPath2(name), JSON.stringify(session, null, 2));
3603
+ }
3604
+ function markSiblingStepComplete(session, step) {
3605
+ if (!session.completedSteps.includes(step)) {
3606
+ session.completedSteps.push(step);
3607
+ }
3608
+ saveSiblingSession(session);
3609
+ }
3610
+ function deleteSiblingSession(projectName) {
3611
+ const path = sessionPath2(projectName);
3612
+ if (existsSync13(path)) rmSync5(path);
3613
+ }
3614
+
3615
+ // src/commands/sibling-create.ts
3616
+ var siblingCreateCommand = new Command11("create").description(
3617
+ "Create a standalone sibling app repository from the Biffo sibling template (ADR-0007)"
3618
+ ).argument("<name>", "Sibling name; must match config.project.name").requiredOption("-c, --config <path>", "Path to a pre-filled biffo.sibling.json").option("--template <path>", "Path to sibling template (defaults to the bundled skeleton)").option(
3619
+ "--root",
3620
+ `Create this sibling as the ROOT application: it serves "/" (empty path prefix) and takes the core distribution's default cache behaviour. Registered under the reserved name "${ROOT_SIBLING_NAME}". \`biffo init\` does this for you; use this flag only to re-create a root sibling by hand.`
3621
+ ).option("--dry-run", "Validate config and print planned changes without creating anything").option("--fresh", "Ignore any saved session and start from scratch").action(
3622
+ async (name, options) => {
3465
3623
  try {
3466
3624
  assertBuildIsFresh();
3625
+ await runSiblingCreateCommand(name, {
3626
+ configPath: resolve9(options.config),
3627
+ templateRoot: options.template ? resolve9(options.template) : defaultSiblingTemplateRoot(),
3628
+ root: options.root === true,
3629
+ dryRun: options.dryRun === true,
3630
+ fresh: options.fresh === true
3631
+ });
3467
3632
  } catch (err) {
3468
3633
  log.error(err.message);
3469
3634
  process.exit(1);
3470
3635
  }
3471
- console.log(chalk11.bold("\n Biffo \u2014 Project Initialiser\n"));
3472
- let session = null;
3473
- let config;
3474
- let githubToken;
3475
- if (options.config) {
3476
- const rawConfig = JSON.parse(readFileSync10(resolve9(options.config), "utf8"));
3477
- config = parseConfig(rawConfig);
3478
- const { account_id: accountId, region } = config.cloud.config;
3479
- session = {
3480
- version: 1,
3481
- config,
3482
- awsAccountId: accountId,
3483
- awsRegion: region,
3484
- completedSteps: [],
3485
- outputs: {}
3486
- };
3487
- } else {
3488
- githubToken = await resolveGithubToken3(options.yes === true);
3489
- const { accountId, region, profile } = await resolveAwsCredentials(options.yes === true);
3490
- if (!options.fresh) {
3491
- const saved = findLatestSession();
3492
- if (saved) {
3493
- const { resume } = await promptOr(
3494
- {
3495
- question: "Resume previous init?",
3496
- remedy: "Pass --fresh to ignore the saved session, or --config <path> to init from a file."
3497
- },
3498
- [
3499
- {
3500
- type: "confirm",
3501
- name: "resume",
3502
- message: `Resume previous init for ${chalk11.bold(saved.config.project?.name ?? "?")} (completed: ${saved.completedSteps.join(", ") || "none"})?`,
3503
- default: true
3504
- }
3505
- ]
3506
- );
3507
- if (resume) {
3508
- session = saved;
3509
- session.awsAccountId = accountId;
3510
- session.awsRegion = region;
3511
- config = applyResolvedAwsCredentials(parseConfig(session.config), {
3512
- accountId,
3513
- region,
3514
- profile
3515
- });
3516
- session.config = config;
3517
- saveSession(session);
3518
- console.log();
3519
- }
3520
- }
3521
- }
3522
- if (!session) {
3523
- const rawConfig = await promptForConfig(accountId, region, profile);
3524
- config = parseConfig(rawConfig);
3525
- session = {
3526
- version: 1,
3527
- config,
3528
- awsAccountId: accountId,
3529
- awsRegion: region,
3530
- completedSteps: [],
3531
- outputs: {}
3532
- };
3533
- saveSession(session);
3534
- }
3535
- }
3536
- config = config;
3537
- log.success("Configuration valid");
3538
- if (options.dryRun) {
3539
- console.log("\n", JSON.stringify(config, null, 2));
3540
- return;
3636
+ }
3637
+ );
3638
+ async function runSiblingCreateCommand(name, options) {
3639
+ console.log(chalk11.bold("\n Biffo \u2014 Sibling App Creator\n"));
3640
+ const config = readSiblingConfig(options.configPath, options.root);
3641
+ if (config.project.name !== name) {
3642
+ throw new Error(
3643
+ `Sibling name '${name}' does not match config project.name '${config.project.name}'.`
3644
+ );
3645
+ }
3646
+ assertPathPrefixIsAllowed(resolvePathPrefix(config));
3647
+ const coreConfig = resolveCoreConfig(config, options.configPath);
3648
+ if (options.dryRun) {
3649
+ printDryRun2(config, coreConfig, options.templateRoot);
3650
+ return;
3651
+ }
3652
+ if (!existsSync14(options.templateRoot)) {
3653
+ throw new Error(`Sibling template not found at ${options.templateRoot}`);
3654
+ }
3655
+ let session = null;
3656
+ if (!options.fresh) {
3657
+ const saved = findLatestSiblingSession();
3658
+ if (saved && saved.config.project?.name === name) {
3659
+ session = saved;
3660
+ console.log(
3661
+ chalk11.dim(
3662
+ ` Resuming previous sibling create for ${name} (completed: ${saved.completedSteps.join(", ") || "none"})
3663
+ `
3664
+ )
3665
+ );
3541
3666
  }
3542
- githubToken ??= await resolveGithubToken3(options.yes === true || Boolean(options.config));
3543
- const github = new GitHubAdapter(githubToken);
3544
- const aws = new AwsAdapter(config);
3545
- await runInit(github, aws, config, session);
3546
- const { org, repo } = config.source_control.config;
3547
- log.success("\nProject initialised successfully!");
3548
- console.log(`
3667
+ }
3668
+ if (!session) {
3669
+ session = {
3670
+ version: 1,
3671
+ config,
3672
+ awsAccountId: config.cloud.config.account_id,
3673
+ awsRegion: config.cloud.config.region,
3674
+ completedSteps: [],
3675
+ outputs: {}
3676
+ };
3677
+ saveSiblingSession(session);
3678
+ }
3679
+ const token = await resolveGithubToken3(true);
3680
+ const github = new GitHubAdapter(token);
3681
+ const aws = new AwsAdapter(config);
3682
+ const coreAws = new AwsAdapter(coreConfig);
3683
+ const git = new GitAdapter();
3684
+ await runSiblingCreate(github, aws, coreAws, git, config, session, {
3685
+ coreConfig,
3686
+ skeletonRoot: options.templateRoot,
3687
+ githubToken: token
3688
+ });
3689
+ const { org, repo } = githubRepo(config);
3690
+ const pathPrefix = resolvePathPrefix(config);
3691
+ log.success("\nSibling repo created successfully!");
3692
+ console.log(`
3549
3693
  Repository: https://github.com/${org}/${repo}`);
3550
- console.log(" Next: clone your repo and run the first deploy\n");
3694
+ console.log(` Path: ${displayPath(pathPrefix)}`);
3695
+ if (session.outputs.registrationPrUrl) {
3696
+ console.log(
3697
+ ` Registration PR (against ${coreConfig.project.name}): ${session.outputs.registrationPrUrl}`
3698
+ );
3551
3699
  }
3552
- );
3553
- async function runInit(github, aws, config, session) {
3554
- const totalSteps = 5;
3700
+ console.log(
3701
+ `
3702
+ Next steps:
3703
+ 1. Merge the registration PR above \u2014 until it merges, baseurl.com${displayPath(pathPrefix)} won't route anywhere.
3704
+ 2. Add a SIBLING_GITHUB_TOKEN secret to this new repo (a PAT with repo scope) \u2014 needed by its
3705
+ deploy workflow to export Terraform outputs as environment variables, same as the core project.
3706
+ 3. Push to \`dev\` (or run the Deploy workflow manually) to provision this sibling's own AWS resources.
3707
+ 4. Once the registration PR has ALSO merged and the core project has redeployed, set
3708
+ PARENT_CLOUDFRONT_DISTRIBUTION_ARN on this repo and re-run its Deploy workflow \u2014 see this
3709
+ repo's README, "The two-phase CDN registration".
3710
+ `
3711
+ );
3712
+ }
3713
+ async function runSiblingCreate(github, aws, coreAws, git, config, session, options) {
3714
+ const totalSteps = 7;
3715
+ const { org, repo } = githubRepo(config);
3716
+ const pathPrefix = resolvePathPrefix(config);
3555
3717
  if (!session.completedSteps.includes("verify_credentials")) {
3556
3718
  log.step(1, totalSteps, "Verifying AWS credentials...");
3557
3719
  await aws.verifyCredentials();
3558
- markStepComplete(session, "verify_credentials");
3720
+ markSiblingStepComplete(session, "verify_credentials");
3559
3721
  } else {
3560
3722
  log.step(1, totalSteps, "AWS credentials already verified \u2014 skipping");
3561
3723
  }
3562
- if (!session.completedSteps.includes("create_repo")) {
3563
- log.step(2, totalSteps, "Creating GitHub repository...");
3564
- const cloneUrl = await github.createRepoFromTemplate(config);
3724
+ if (options.skipCoreIdentity && !session.completedSteps.includes("resolve_core_identity")) {
3725
+ log.step(
3726
+ 2,
3727
+ totalSteps,
3728
+ "Core project isn't deployed yet \u2014 deferring its identity (CORE_COGNITO_*, CORE_API_URL)"
3729
+ );
3730
+ session.outputs.coreIdentity = {};
3731
+ markSiblingStepComplete(session, "resolve_core_identity");
3732
+ } else if (!session.completedSteps.includes("resolve_core_identity")) {
3733
+ log.step(2, totalSteps, "Resolving core project's identity...");
3734
+ session.outputs.coreIdentity = await resolveCoreIdentity(
3735
+ coreAws,
3736
+ options.coreConfig,
3737
+ config.environments
3738
+ );
3739
+ markSiblingStepComplete(session, "resolve_core_identity");
3740
+ } else {
3741
+ log.step(2, totalSteps, "Core identity already resolved \u2014 skipping");
3742
+ }
3743
+ const coreIdentity = session.outputs.coreIdentity;
3744
+ if (!coreIdentity) {
3745
+ throw new Error(
3746
+ "internal error: resolve_core_identity did not populate session.outputs.coreIdentity"
3747
+ );
3748
+ }
3749
+ if (!session.completedSteps.includes("create_repo")) {
3750
+ log.step(3, totalSteps, "Creating GitHub repository and pushing sibling skeleton...");
3751
+ const cloneUrl = await github.createEmptyRepo(
3752
+ org,
3753
+ repo,
3754
+ config.project.description || void 0
3755
+ );
3565
3756
  session.outputs.cloneUrl = cloneUrl;
3566
- markStepComplete(session, "create_repo");
3757
+ await pushSkeleton(
3758
+ git,
3759
+ options.skeletonRoot,
3760
+ cloneUrl,
3761
+ config,
3762
+ options.coreConfig,
3763
+ options.githubToken
3764
+ );
3765
+ markSiblingStepComplete(session, "create_repo");
3567
3766
  } else {
3568
- log.step(2, totalSteps, "GitHub repository already created \u2014 skipping");
3767
+ log.step(3, totalSteps, "GitHub repository already created \u2014 skipping");
3569
3768
  }
3570
3769
  if (!session.completedSteps.includes("oidc_trust")) {
3571
- log.step(3, totalSteps, "Configuring OIDC trust...");
3572
- const roleArn = await aws.setupOidcTrust(config, await resolveRepoIds(github, config));
3573
- session.outputs.oidcRoleArn = roleArn;
3574
- config.cloud.config = {
3575
- ...config.cloud.config,
3576
- oidc_role_arn: roleArn
3577
- };
3578
- markStepComplete(session, "oidc_trust");
3770
+ log.step(4, totalSteps, "Configuring OIDC trust...");
3771
+ session.outputs.oidcRoleArn = await aws.setupOidcTrust(
3772
+ config,
3773
+ await resolveRepoIds(github, config)
3774
+ );
3775
+ markSiblingStepComplete(session, "oidc_trust");
3579
3776
  } else {
3580
- log.step(3, totalSteps, "OIDC trust already configured \u2014 skipping");
3581
- if (session.outputs.oidcRoleArn) {
3582
- config.cloud.config = {
3583
- ...config.cloud.config,
3584
- oidc_role_arn: session.outputs.oidcRoleArn
3585
- };
3586
- }
3777
+ log.step(4, totalSteps, "OIDC trust already configured \u2014 skipping");
3587
3778
  }
3588
3779
  if (!session.completedSteps.includes("terraform_backend")) {
3589
- log.step(4, totalSteps, "Bootstrapping Terraform state backend...");
3590
- const tfStateBucket = await aws.bootstrapTerraformBackend(config.project.name);
3591
- session.outputs.tfStateBucket = tfStateBucket;
3592
- config.cloud.config = {
3593
- ...config.cloud.config,
3594
- tf_state_bucket: tfStateBucket
3595
- };
3596
- markStepComplete(session, "terraform_backend");
3780
+ log.step(5, totalSteps, "Bootstrapping Terraform state backend...");
3781
+ session.outputs.tfStateBucket = await aws.bootstrapTerraformBackend(config.project.name);
3782
+ markSiblingStepComplete(session, "terraform_backend");
3597
3783
  } else {
3598
- log.step(4, totalSteps, "Terraform backend already bootstrapped \u2014 skipping");
3599
- if (session.outputs.tfStateBucket) {
3600
- config.cloud.config = {
3601
- ...config.cloud.config,
3602
- tf_state_bucket: session.outputs.tfStateBucket
3603
- };
3604
- }
3784
+ log.step(5, totalSteps, "Terraform backend already bootstrapped \u2014 skipping");
3605
3785
  }
3606
3786
  if (!session.completedSteps.includes("github_config")) {
3607
- log.step(5, totalSteps, "Configuring GitHub repository...");
3608
- const { org, repo } = config.source_control.config;
3609
- const dns = resolveDnsConfig(config);
3610
- const domain = dns.domain;
3611
- await github.createBranch(org, repo, "dev", "main");
3612
- await github.createBranch(org, repo, "staging", "main");
3613
- await writeInstanceFiles(github, org, repo);
3614
- await github.setDefaultBranch(org, repo, "dev");
3615
- await github.configureBranchProtection(config);
3616
- await github.createEnvironments(config);
3617
- await github.enableVulnerabilityAlerts(org, repo);
3618
- await github.setRepoVariable(org, repo, "DNS_MODE", dns.mode);
3619
- if (domain) {
3620
- await github.setRepoVariable(org, repo, "DOMAIN", domain);
3621
- }
3622
- if (domain) {
3623
- const envDomains = {
3624
- dev: `dev.${domain}`,
3625
- staging: `staging.${domain}`,
3626
- prod: domain
3627
- };
3628
- for (const env of config.environments) {
3629
- const customDomain = envDomains[env] ?? "";
3630
- if (customDomain) {
3631
- await github.setEnvVariable(org, repo, env, "CUSTOM_DOMAIN", customDomain);
3632
- }
3633
- }
3634
- }
3635
- if (session.outputs.oidcRoleArn) {
3636
- await github.setRepoSecret(org, repo, "BIFFO_OIDC_ROLE_ARN", session.outputs.oidcRoleArn);
3637
- }
3638
- markStepComplete(session, "github_config");
3787
+ log.step(6, totalSteps, "Configuring GitHub repository...");
3788
+ await configureSiblingGithub(github, config, options.coreConfig, session, coreIdentity);
3789
+ markSiblingStepComplete(session, "github_config");
3639
3790
  } else {
3640
- log.step(5, totalSteps, "GitHub already configured \u2014 skipping");
3791
+ log.step(6, totalSteps, "GitHub already configured \u2014 skipping");
3641
3792
  }
3642
- deleteSession(config.project.name);
3643
- saveProjectConfig(config);
3793
+ if (options.skipRegistration && !session.completedSteps.includes("register_with_core")) {
3794
+ log.step(7, totalSteps, "Already registered with the core project by `biffo init` \u2014 skipping");
3795
+ markSiblingStepComplete(session, "register_with_core");
3796
+ } else if (!session.completedSteps.includes("register_with_core")) {
3797
+ log.step(7, totalSteps, "Opening a registration PR against the core project...");
3798
+ session.outputs.registrationPrUrl = await registerWithCore(
3799
+ git,
3800
+ github,
3801
+ config,
3802
+ options.coreConfig,
3803
+ pathPrefix,
3804
+ options.githubToken
3805
+ );
3806
+ markSiblingStepComplete(session, "register_with_core");
3807
+ } else {
3808
+ log.step(7, totalSteps, "Already registered with the core project \u2014 skipping");
3809
+ }
3810
+ deleteSiblingSession(config.project.name);
3644
3811
  }
3645
- var INSTANCE_CONFIG_FILE = "biffo.config.json";
3646
- var INSTANCE_FILE_BRANCHES = ["main", "dev", "staging"];
3647
- async function writeInstanceFiles(github, org, repo) {
3648
- const files = [
3649
- { path: INSTANCE_CORE_FILE, content: serializeInstanceCoreVersion(getLatestCoreVersion()) },
3650
- { path: INSTANCE_CONFIG_FILE, content: null }
3651
- ];
3652
- for (const branch of INSTANCE_FILE_BRANCHES) {
3653
- await github.commitFiles(
3654
- org,
3655
- repo,
3656
- branch,
3657
- files,
3658
- `chore: record core version and drop the template ${INSTANCE_CONFIG_FILE}`
3812
+ function resolvePathPrefix(config) {
3813
+ return config.core.path_prefix ?? config.project.name;
3814
+ }
3815
+ function assertPathPrefixIsAllowed(pathPrefix) {
3816
+ if (isRootPathPrefix(pathPrefix)) return;
3817
+ if (RESERVED_SIBLING_NAMES.includes(pathPrefix)) {
3818
+ const extra = pathPrefix === ROOT_SIBLING_NAME ? ` "${ROOT_SIBLING_NAME}" is the root application sibling, which \`biffo init\` creates; pass --root to create one by hand.` : ` "${pathPrefix}" is one of the portal's own routes.`;
3819
+ throw new Error(
3820
+ `Sibling path prefix "${pathPrefix}" is reserved (${RESERVED_SIBLING_NAMES.join(", ")}).` + extra
3659
3821
  );
3660
3822
  }
3661
3823
  }
3662
- function parseConfig(raw) {
3663
- const result = BiffoConfigSchema.safeParse(raw);
3824
+ function readSiblingConfig(path, root = false) {
3825
+ const raw = JSON.parse(readFileSync11(path, "utf8"));
3826
+ const withDefaults = raw && typeof raw === "object" && "project" in raw && "core" in raw ? {
3827
+ ...raw,
3828
+ core: {
3829
+ ...raw.core,
3830
+ // --root wins over whatever the file says: it is the whole point of
3831
+ // the flag, and a config carrying a stale non-empty prefix would
3832
+ // otherwise silently produce a path-routed sibling instead.
3833
+ path_prefix: root ? "" : raw.core.path_prefix ?? raw.project.name
3834
+ }
3835
+ } : raw;
3836
+ const result = SiblingConfigSchema.safeParse(withDefaults);
3664
3837
  if (!result.success) {
3665
- log.error("Invalid configuration:");
3666
- result.error.issues.forEach((issue) => {
3667
- log.error(` ${issue.path.join(".")} \u2014 ${issue.message}`);
3668
- });
3669
- process.exit(1);
3838
+ throw new Error(
3839
+ "Invalid sibling configuration:\n" + result.error.issues.map((issue) => ` ${issue.path.join(".")} \u2014 ${issue.message}`).join("\n")
3840
+ );
3670
3841
  }
3671
3842
  return result.data;
3672
3843
  }
3673
- function applyResolvedAwsCredentials(config, credentials) {
3674
- return {
3675
- ...config,
3676
- cloud: {
3677
- provider: "aws",
3678
- config: {
3679
- ...config.cloud.config,
3680
- account_id: credentials.accountId,
3681
- region: credentials.region,
3682
- ...credentials.profile ? { profile: credentials.profile } : {}
3844
+ function resolveCoreConfig(config, configPath) {
3845
+ if (config.core.config_path) {
3846
+ const corePath = resolve9(dirname6(configPath), config.core.config_path);
3847
+ return parseCoreConfig(corePath);
3848
+ }
3849
+ if (config.core.project_name) {
3850
+ const saved = loadProjectConfig(config.core.project_name);
3851
+ if (saved) return saved;
3852
+ throw new Error(
3853
+ `Core project '${config.core.project_name}' was not found in ~/.biffo/projects. Set core.config_path to the core project biffo.config.json instead.`
3854
+ );
3855
+ }
3856
+ throw new Error("Either core.project_name or core.config_path is required.");
3857
+ }
3858
+ function parseCoreConfig(path) {
3859
+ const result = BiffoConfigSchema.safeParse(JSON.parse(readFileSync11(path, "utf8")));
3860
+ if (!result.success) {
3861
+ throw new Error(
3862
+ `Invalid core configuration at ${path}:
3863
+ ` + result.error.issues.map((issue) => ` ${issue.path.join(".")} \u2014 ${issue.message}`).join("\n")
3864
+ );
3865
+ }
3866
+ return result.data;
3867
+ }
3868
+ async function resolveCoreIdentity(coreAws, coreConfig, environments) {
3869
+ const coreAwsConfig = coreConfig.cloud.config;
3870
+ const stateBucket = coreAwsConfig.tf_state_bucket ?? `${coreConfig.project.name}-terraform-state-${coreAwsConfig.account_id}`;
3871
+ const coreIdentity = {};
3872
+ for (const env of environments) {
3873
+ const stateKey = `${env}/terraform.tfstate`;
3874
+ log.info(`Reading ${coreConfig.project.name}'s Terraform outputs for ${env}...`);
3875
+ const outputs = await coreAws.readTerraformOutputs(stateBucket, stateKey);
3876
+ for (const key of [
3877
+ "cognito_user_pool_id",
3878
+ "cognito_client_id",
3879
+ "api_gateway_url",
3880
+ "portal_url"
3881
+ ]) {
3882
+ if (!outputs[key]) {
3883
+ throw new Error(
3884
+ `${key} not found in ${coreConfig.project.name}'s Terraform outputs for ${env}. Has the core project been deployed to ${env}? Run \`biffo deploy ${env}\` from the core project first.`
3885
+ );
3683
3886
  }
3684
3887
  }
3685
- };
3888
+ coreIdentity[env] = {
3889
+ cognitoUserPoolId: outputs["cognito_user_pool_id"],
3890
+ cognitoClientId: outputs["cognito_client_id"],
3891
+ apiUrl: outputs["api_gateway_url"],
3892
+ portalUrl: outputs["portal_url"]
3893
+ };
3894
+ }
3895
+ return coreIdentity;
3686
3896
  }
3687
- async function promptForConfig(awsAccountId, awsRegion, awsProfile) {
3688
- assertInteractive(
3689
- "Project configuration",
3690
- "Pass --config <path> with a pre-filled biffo.config.json (it implies -y)."
3897
+ async function pushSkeleton(git, skeletonRoot, cloneUrl, config, coreConfig, githubToken) {
3898
+ const workDir = mkdtempSync4(join15(tmpdir4(), `biffo-sibling-${config.project.name}-`));
3899
+ try {
3900
+ writeSiblingTemplate(skeletonRoot, workDir, config, {
3901
+ coreProjectName: coreConfig.project.name,
3902
+ pathPrefix: resolvePathPrefix(config)
3903
+ });
3904
+ await git.init(workDir, "main");
3905
+ await git.addRemote(workDir, "origin", cloneUrl);
3906
+ await git.add(workDir, ["."]);
3907
+ await git.commit(workDir, `feat: scaffold ${config.project.name} sibling app (ADR-0007)`);
3908
+ await git.push(workDir, "main", { token: githubToken });
3909
+ } finally {
3910
+ git.cleanup(workDir);
3911
+ }
3912
+ }
3913
+ function writeSiblingTemplate(templateRoot, targetDir, config, context) {
3914
+ if (!existsSync14(templateRoot)) {
3915
+ throw new Error(`Sibling template not found at ${templateRoot}`);
3916
+ }
3917
+ cpSync2(templateRoot, targetDir, { recursive: true });
3918
+ writeFileSync6(
3919
+ join15(targetDir, "biffo.sibling.json"),
3920
+ JSON.stringify(
3921
+ {
3922
+ name: config.project.name,
3923
+ core_project: context.coreProjectName,
3924
+ path_prefix: context.pathPrefix,
3925
+ ...config.project.description ? { description: config.project.description } : {},
3926
+ // Always written (even when empty) so the field is discoverable — declare
3927
+ // this sibling's notable routes here and they surface on the core's
3928
+ // Microservices tab. See SiblingConfigSchema.project.routes.
3929
+ routes: config.project.routes
3930
+ },
3931
+ null,
3932
+ 2
3933
+ ) + "\n"
3691
3934
  );
3692
- const answers = await inquirer5.prompt([
3693
- {
3694
- type: "input",
3695
- name: "project_name",
3696
- message: "Project name (lowercase kebab-case):",
3697
- validate: (v) => /^[a-z0-9-]+$/.test(v) || "Must be lowercase kebab-case"
3698
- },
3699
- { type: "input", name: "project_description", message: "Project description:" },
3700
- {
3701
- type: "list",
3702
- name: "dns_mode",
3703
- message: "DNS / custom domain mode:",
3704
- choices: [
3705
- {
3706
- name: "Managed Route53 \u2014 create DNS zone, certificate, and records automatically",
3707
- value: "managed-route53"
3708
- },
3709
- {
3710
- name: "External DNS \u2014 request SSL certificate and print records for manual DNS changes",
3711
- value: "external"
3712
- },
3713
- {
3714
- name: "None \u2014 use the default CloudFront domain only",
3715
- value: "none"
3716
- }
3717
- ],
3718
- default: "managed-route53"
3719
- },
3720
- {
3721
- type: "input",
3722
- name: "domain",
3723
- message: "Primary domain (e.g. myapp.com):",
3724
- when: (a) => a.dns_mode !== "none",
3725
- validate: (v) => v.trim().length > 0 || "Domain is required for this DNS mode"
3726
- },
3727
- { type: "input", name: "github_org", message: "GitHub org or username:" },
3728
- { type: "input", name: "github_repo", message: "Repository name (will be created):" },
3729
- { type: "input", name: "admin_email", message: "Admin email address:" },
3730
- { type: "input", name: "admin_username", message: "Admin username:" },
3731
- {
3732
- type: "checkbox",
3733
- name: "environments",
3734
- message: "Environments to provision:",
3735
- choices: ["dev", "staging", "prod"],
3736
- default: ["dev"]
3935
+ const envPath = join15(targetDir, "apps", "frontend", ".env.example");
3936
+ try {
3937
+ const path = basePathFor(context.pathPrefix);
3938
+ const content = readFileSync11(envPath, "utf8").replace(/^NEXT_PUBLIC_SIBLING_NAME=.*$/m, `NEXT_PUBLIC_SIBLING_NAME=${config.project.name}`).replace(/^NEXT_PUBLIC_SIBLING_PATH_PREFIX=.*$/m, `NEXT_PUBLIC_SIBLING_PATH_PREFIX=${path}`).replace(/^NEXT_PUBLIC_BASE_PATH=.*$/m, `NEXT_PUBLIC_BASE_PATH=${path}`);
3939
+ writeFileSync6(envPath, content);
3940
+ } catch (err) {
3941
+ if (err.code !== "ENOENT") throw err;
3942
+ }
3943
+ }
3944
+ async function configureSiblingGithub(github, config, coreConfig, session, coreIdentity) {
3945
+ const { org, repo } = githubRepo(config);
3946
+ await github.createBranch(org, repo, "dev", "main");
3947
+ await github.createBranch(org, repo, "staging", "main");
3948
+ await github.setDefaultBranch(org, repo, "dev");
3949
+ await github.configureBranchProtection(config);
3950
+ await github.createEnvironments(config);
3951
+ await github.enableVulnerabilityAlerts(org, repo);
3952
+ await github.setRepoVariable(org, repo, "PROJECT_NAME", config.project.name);
3953
+ await github.setRepoVariable(org, repo, "PATH_PREFIX", resolvePathPrefix(config));
3954
+ await github.setRepoVariable(org, repo, "AWS_REGION", awsConfig(config).region);
3955
+ await github.setRepoVariable(org, repo, "SIBLING_DEPLOY_ENABLED", "true");
3956
+ try {
3957
+ const { org: coreOrg, repo: coreRepo } = coreConfig.source_control.config;
3958
+ const runnerLabel = await github.getRepoVariable(coreOrg, coreRepo, "RUNNER_LABEL");
3959
+ if (runnerLabel && runnerLabel.trim()) {
3960
+ await github.setRepoVariable(org, repo, "RUNNER_LABEL", runnerLabel);
3737
3961
  }
3738
- ]);
3739
- return {
3740
- project: {
3741
- name: answers.project_name,
3742
- description: answers.project_description
3743
- },
3744
- dns: {
3745
- mode: answers.dns_mode,
3746
- domain: answers.domain
3747
- },
3748
- source_control: {
3749
- provider: "github",
3750
- config: { org: answers.github_org, repo: answers.github_repo }
3751
- },
3752
- cloud: {
3753
- provider: "aws",
3754
- config: {
3755
- account_id: awsAccountId,
3756
- region: awsRegion,
3757
- ...awsProfile ? { profile: awsProfile } : {}
3962
+ } catch (err) {
3963
+ log.warn(
3964
+ `Could not propagate RUNNER_LABEL from the core project to ${org}/${repo}: ${err.message}. The sibling will default to ubuntu-latest runners.`
3965
+ );
3966
+ }
3967
+ if (session.outputs.tfStateBucket) {
3968
+ await github.setRepoVariable(org, repo, "TF_STATE_BUCKET", session.outputs.tfStateBucket);
3969
+ }
3970
+ for (const env of config.environments) {
3971
+ const identity = coreIdentity[env];
3972
+ if (!identity) continue;
3973
+ await github.setEnvVariable(
3974
+ org,
3975
+ repo,
3976
+ env,
3977
+ "CORE_COGNITO_USER_POOL_ID",
3978
+ identity.cognitoUserPoolId
3979
+ );
3980
+ await github.setEnvVariable(org, repo, env, "CORE_COGNITO_CLIENT_ID", identity.cognitoClientId);
3981
+ await github.setEnvVariable(org, repo, env, "CORE_API_URL", identity.apiUrl);
3982
+ await github.setEnvVariable(org, repo, env, "CORE_PORTAL_URL", identity.portalUrl);
3983
+ await github.setEnvVariable(
3984
+ org,
3985
+ repo,
3986
+ env,
3987
+ "CORS_ORIGINS_JSON",
3988
+ JSON.stringify([identity.portalUrl])
3989
+ );
3990
+ }
3991
+ if (session.outputs.oidcRoleArn) {
3992
+ await github.setRepoSecret(org, repo, "SIBLING_OIDC_ROLE_ARN", session.outputs.oidcRoleArn);
3993
+ }
3994
+ }
3995
+ function readExistingSiblingOrigins(filePath) {
3996
+ try {
3997
+ return JSON.parse(readFileSync11(filePath, "utf8"));
3998
+ } catch (err) {
3999
+ if (err.code === "ENOENT") return {};
4000
+ throw err;
4001
+ }
4002
+ }
4003
+ function assertCoreSupportsSiblingRouting(cloneDir, coreRepo, pathPrefix = "x") {
4004
+ const cdnVarsPath = join15(cloneDir, "modules", "cloud", "aws", "cdn", "variables.tf");
4005
+ let declaresSiblingOrigins = false;
4006
+ try {
4007
+ declaresSiblingOrigins = /variable\s+"sibling_origins"/.test(readFileSync11(cdnVarsPath, "utf8"));
4008
+ } catch {
4009
+ declaresSiblingOrigins = false;
4010
+ }
4011
+ if (!declaresSiblingOrigins) {
4012
+ throw new Error(
4013
+ `The core project "${coreRepo}" doesn't support sibling CDN routing yet (its modules/cloud/aws/cdn predates ADR-0007). Run \`biffo core upgrade\` against it first, then re-run \`biffo sibling create\`.`
4014
+ );
4015
+ }
4016
+ if (!isRootPathPrefix(pathPrefix)) return;
4017
+ const cdnMainPath = join15(cloneDir, "modules", "cloud", "aws", "cdn", "main.tf");
4018
+ let supportsRoot = false;
4019
+ try {
4020
+ supportsRoot = /root_sibling_registered/.test(readFileSync11(cdnMainPath, "utf8"));
4021
+ } catch {
4022
+ supportsRoot = false;
4023
+ }
4024
+ if (!supportsRoot) {
4025
+ throw new Error(
4026
+ `The core project "${coreRepo}" doesn't support a ROOT application sibling yet (its modules/cloud/aws/cdn default_cache_behavior still can't follow the "${ROOT_SIBLING_NAME}" origin \u2014 issue #306). Run \`biffo core upgrade\` against it first, then re-run with --root.`
4027
+ );
4028
+ }
4029
+ }
4030
+ async function registerWithCore(git, github, config, coreConfig, pathPrefix, githubToken) {
4031
+ const { org: coreOrg, repo: coreRepo } = coreConfig.source_control.config;
4032
+ const coreCloneUrl = `https://github.com/${coreOrg}/${coreRepo}.git`;
4033
+ const coreAwsRegion = awsConfig(coreConfig).region;
4034
+ const siblingAccountId = config.cloud.config.account_id;
4035
+ const cloneDir = await git.cloneForEditing(
4036
+ coreCloneUrl,
4037
+ `biffo-sibling-register-${config.project.name}`,
4038
+ githubToken
4039
+ );
4040
+ const name = registryNameFor(pathPrefix);
4041
+ try {
4042
+ assertCoreSupportsSiblingRouting(cloneDir, coreRepo, pathPrefix);
4043
+ const base = await git.currentBranch(cloneDir);
4044
+ const branch = `biffo/register-sibling-${config.project.name}`.replace(/[^a-zA-Z0-9._/-]/g, "-");
4045
+ await git.createBranch(cloneDir, branch);
4046
+ const touchedFiles = [];
4047
+ for (const env of config.environments) {
4048
+ const bucketName = siteBucketName(config.project.name, env, siblingAccountId);
4049
+ const domain = bucketRegionalDomain(bucketName, coreAwsRegion);
4050
+ const relativePath = join15("infra", "environments", env, "siblings.auto.tfvars.json");
4051
+ const filePath = join15(cloneDir, relativePath);
4052
+ const existing = readExistingSiblingOrigins(filePath);
4053
+ const siblings = upsertSiblingOrigin(existing.sibling_origins ?? [], {
4054
+ name,
4055
+ bucket_regional_domain: domain,
4056
+ ...config.project.description ? { description: config.project.description } : {},
4057
+ ...config.project.routes.length > 0 ? { routes: config.project.routes } : {}
4058
+ });
4059
+ mkdirSync6(dirname6(filePath), { recursive: true });
4060
+ writeFileSync6(filePath, serializeRegistry(siblings));
4061
+ touchedFiles.push(relativePath);
4062
+ }
4063
+ await git.add(cloneDir, touchedFiles);
4064
+ await git.commit(
4065
+ cloneDir,
4066
+ isRootPathPrefix(pathPrefix) ? `infra(cdn): register root application sibling "${name}" at / (ADR-0007)` : `infra(cdn): register sibling "${name}" for path-based routing (ADR-0007)`
4067
+ );
4068
+ await git.push(cloneDir, branch, { token: githubToken });
4069
+ const remoteUrl = await git.getRemoteUrl(cloneDir);
4070
+ const { owner, repo } = parseGitHubRepo(remoteUrl);
4071
+ const pr = await github.createPullRequest({
4072
+ owner,
4073
+ repo,
4074
+ head: branch,
4075
+ base,
4076
+ title: isRootPathPrefix(pathPrefix) ? `Register root application sibling "${name}" at / (ADR-0007)` : `Register sibling "${name}" for CDN routing (ADR-0007)`,
4077
+ body: buildRegistrationPrBody(config, pathPrefix, touchedFiles)
4078
+ });
4079
+ return pr.url;
4080
+ } finally {
4081
+ git.cleanup(cloneDir);
4082
+ }
4083
+ }
4084
+ function buildRegistrationPrBody(config, pathPrefix, touchedFiles) {
4085
+ const { org, repo } = githubRepo(config);
4086
+ return [
4087
+ "Automated sibling registration generated by `biffo sibling create` (ADR-0007).",
4088
+ "",
4089
+ isRootPathPrefix(pathPrefix) ? `Adds **${org}/${repo}** to this project's CloudFront distribution as the ROOT application origin (reserved name \`${ROOT_SIBLING_NAME}\`, empty path prefix) \u2014 once merged and redeployed, the distribution's \`default_cache_behavior\` targets that sibling's own S3 bucket, so \`baseurl.com/\` and everything not claimed by an explicit behaviour (\`/admin*\`, \`/login*\`, other siblings) routes there. That includes \`/_next/*\`, which the portal vacated by setting \`assetPrefix: '/admin'\`.` : `Adds **${org}/${repo}** to this project's CloudFront distribution as a new path-routed origin \u2014 once merged and redeployed, \`baseurl.com/${pathPrefix}/*\` routes to that sibling's own S3 bucket.`,
4090
+ "",
4091
+ `## Files changed (${touchedFiles.length})`,
4092
+ "",
4093
+ ...touchedFiles.map((f) => `- \`${f}\``),
4094
+ "",
4095
+ "## After merging",
4096
+ "",
4097
+ "This sibling's own S3 bucket policy still needs this distribution's real ARN (a two-phase handshake \u2014 see the sibling repo's README, \"The two-phase CDN registration\"). Once this PR merges and this project redeploys, set `PARENT_CLOUDFRONT_DISTRIBUTION_ARN` on the sibling repo and re-run its deploy workflow."
4098
+ ].join("\n");
4099
+ }
4100
+ function printDryRun2(config, coreConfig, templateRoot) {
4101
+ const { org, repo } = githubRepo(config);
4102
+ const pathPrefix = resolvePathPrefix(config);
4103
+ console.log(chalk11.bold("\n Dry run \u2014 no changes will be made\n"));
4104
+ console.log(` Sibling: ${config.project.name}`);
4105
+ console.log(` Repository: ${org}/${repo}`);
4106
+ console.log(` Core project: ${coreConfig.project.name}`);
4107
+ console.log(
4108
+ ` Path prefix: ${displayPath(pathPrefix)}` + (isRootPathPrefix(pathPrefix) ? ` (ROOT application \u2014 registered as "${ROOT_SIBLING_NAME}", takes the CDN default behaviour)` : "")
4109
+ );
4110
+ console.log(` Environments: ${config.environments.join(", ")}`);
4111
+ console.log(` Template: ${templateRoot}`);
4112
+ console.log("\n Would:");
4113
+ console.log(" - resolve the core project's Cognito/API identity for each environment");
4114
+ console.log(" - create an empty private sibling GitHub repository");
4115
+ console.log(" - copy and rewrite _skeletons/sibling-template into the repo");
4116
+ console.log(" - push main, create dev/staging, and set dev as default");
4117
+ console.log(" - create AWS OIDC trust and a Terraform state bucket");
4118
+ console.log(" - configure repository secrets, variables, environments, and branch protection");
4119
+ console.log(" - open a PR against the core project to register this sibling for CDN routing\n");
4120
+ }
4121
+ function githubRepo(config) {
4122
+ return config.source_control.config;
4123
+ }
4124
+ function awsConfig(config) {
4125
+ return config.cloud.config;
4126
+ }
4127
+ function defaultSiblingTemplateRoot() {
4128
+ let dir = dirname6(new URL(import.meta.url).pathname);
4129
+ for (; ; ) {
4130
+ const candidate = join15(dir, "_skeletons", "sibling-template");
4131
+ if (existsSync14(candidate)) return candidate;
4132
+ const parent = dirname6(dir);
4133
+ if (parent === dir) break;
4134
+ dir = parent;
4135
+ }
4136
+ return resolve9(process.cwd(), "_skeletons", "sibling-template");
4137
+ }
4138
+
4139
+ // src/commands/init.ts
4140
+ var initCommand = new Command12("init").description("Scaffold a new project from the Biffo template").option("-c, --config <path>", "Path to a pre-filled biffo.config.json").option("--dry-run", "Validate config without making any changes").option("--fresh", "Ignore any saved session and start from scratch").option("-y, --yes", "Auto-accept detected credentials without prompting (implied by --config)").action(
4141
+ async (options) => {
4142
+ try {
4143
+ assertBuildIsFresh();
4144
+ } catch (err) {
4145
+ log.error(err.message);
4146
+ process.exit(1);
4147
+ }
4148
+ console.log(chalk12.bold("\n Biffo \u2014 Project Initialiser\n"));
4149
+ let session = null;
4150
+ let config;
4151
+ let githubToken;
4152
+ if (options.config) {
4153
+ const rawConfig = JSON.parse(readFileSync12(resolve10(options.config), "utf8"));
4154
+ config = parseConfig(rawConfig);
4155
+ const { account_id: accountId, region } = config.cloud.config;
4156
+ session = {
4157
+ version: 1,
4158
+ config,
4159
+ awsAccountId: accountId,
4160
+ awsRegion: region,
4161
+ completedSteps: [],
4162
+ outputs: {}
4163
+ };
4164
+ } else {
4165
+ githubToken = await resolveGithubToken3(options.yes === true);
4166
+ const { accountId, region, profile } = await resolveAwsCredentials(options.yes === true);
4167
+ if (!options.fresh) {
4168
+ const saved = findLatestSession();
4169
+ if (saved) {
4170
+ const { resume } = await promptOr(
4171
+ {
4172
+ question: "Resume previous init?",
4173
+ remedy: "Pass --fresh to ignore the saved session, or --config <path> to init from a file."
4174
+ },
4175
+ [
4176
+ {
4177
+ type: "confirm",
4178
+ name: "resume",
4179
+ message: `Resume previous init for ${chalk12.bold(saved.config.project?.name ?? "?")} (completed: ${saved.completedSteps.join(", ") || "none"})?`,
4180
+ default: true
4181
+ }
4182
+ ]
4183
+ );
4184
+ if (resume) {
4185
+ session = saved;
4186
+ session.awsAccountId = accountId;
4187
+ session.awsRegion = region;
4188
+ config = applyResolvedAwsCredentials(parseConfig(session.config), {
4189
+ accountId,
4190
+ region,
4191
+ profile
4192
+ });
4193
+ session.config = config;
4194
+ saveSession(session);
4195
+ console.log();
4196
+ }
4197
+ }
3758
4198
  }
3759
- },
3760
- environments: answers.environments,
3761
- admin: { email: answers.admin_email, username: answers.admin_username }
3762
- };
3763
- }
3764
-
3765
- // src/commands/plugin.ts
3766
- import { Command as Command19 } from "commander";
3767
-
3768
- // src/commands/plugin-create.ts
3769
- import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
3770
- import { dirname as dirname7, join as join16, resolve as resolve10 } from "path";
3771
- import { fileURLToPath as fileURLToPath4 } from "url";
3772
- import chalk12 from "chalk";
3773
- import { Command as Command12 } from "commander";
3774
-
3775
- // src/lib/plugin-locations.ts
3776
- import { existsSync as existsSync13, readdirSync as readdirSync7 } from "fs";
3777
- import { join as join14 } from "path";
3778
- var FIRST_PARTY_PLUGINS_DIR = "_plugins";
3779
- var PLUGIN_MANIFEST_FILE = "biffo.plugin.json";
3780
- function pluginDir(name, channel) {
3781
- return channel === "first-party" ? `services/${FIRST_PARTY_PLUGINS_DIR}/${name}` : `services/${name}`;
3782
- }
3783
- function scanDir(absDir, relDir, channel) {
3784
- if (!existsSync13(absDir)) return [];
3785
- const found = [];
3786
- for (const entry of readdirSync7(absDir, { withFileTypes: true })) {
3787
- if (!entry.isDirectory()) continue;
3788
- if (channel === "third-party" && entry.name === FIRST_PARTY_PLUGINS_DIR) continue;
3789
- const manifestPath = join14(absDir, entry.name, PLUGIN_MANIFEST_FILE);
3790
- if (!existsSync13(manifestPath)) continue;
3791
- found.push({
3792
- dirName: entry.name,
3793
- relDir: `${relDir}/${entry.name}`,
3794
- manifestPath,
3795
- channel
4199
+ if (!session) {
4200
+ const rawConfig = await promptForConfig(accountId, region, profile);
4201
+ config = parseConfig(rawConfig);
4202
+ session = {
4203
+ version: 1,
4204
+ config,
4205
+ awsAccountId: accountId,
4206
+ awsRegion: region,
4207
+ completedSteps: [],
4208
+ outputs: {}
4209
+ };
4210
+ saveSession(session);
4211
+ }
4212
+ }
4213
+ config = config;
4214
+ log.success("Configuration valid");
4215
+ printPlan2(config);
4216
+ if (options.dryRun) {
4217
+ console.log("\n", JSON.stringify(config, null, 2));
4218
+ return;
4219
+ }
4220
+ githubToken ??= await resolveGithubToken3(options.yes === true || Boolean(options.config));
4221
+ const github = new GitHubAdapter(githubToken);
4222
+ const aws = new AwsAdapter(config);
4223
+ await runInit(github, aws, config, session, {
4224
+ git: new GitAdapter(),
4225
+ awsFor: (siblingConfig) => new AwsAdapter(siblingConfig),
4226
+ skeletonRoot: defaultSiblingTemplateRoot(),
4227
+ githubToken
3796
4228
  });
4229
+ const { org, repo } = config.source_control.config;
4230
+ const appRepo = rootSiblingProjectName(config.project.name);
4231
+ log.success("\nProject initialised successfully!");
4232
+ console.log(`
4233
+ Platform: https://github.com/${org}/${repo}`);
4234
+ console.log(` Application: https://github.com/${org}/${appRepo} (serves /)`);
4235
+ console.log(
4236
+ `
4237
+ Next:
4238
+ 1. Clone the platform repo and run its first deploy \u2014 /admin and /login come up with it.
4239
+ 2. Then deploy the application repo. Until it deploys, / has no content and 404s;
4240
+ that window is expected.
4241
+ `
4242
+ );
3797
4243
  }
3798
- return found;
3799
- }
3800
- function findInstalledPlugins(cwd) {
3801
- const servicesDir = join14(cwd, "services");
3802
- return [
3803
- ...scanDir(servicesDir, "services", "third-party"),
3804
- ...scanDir(
3805
- join14(servicesDir, FIRST_PARTY_PLUGINS_DIR),
3806
- `services/${FIRST_PARTY_PLUGINS_DIR}`,
3807
- "first-party"
3808
- )
3809
- ].sort((a, b) => a.relDir.localeCompare(b.relDir));
3810
- }
3811
-
3812
- // src/lib/plugin-manifest.ts
3813
- import { z as z4 } from "zod";
3814
- var RESERVED_COLUMN_NAMES = /* @__PURE__ */ new Set(["id", "tenant_id", "created_at", "updated_at"]);
3815
- var COLUMN_TYPE_PATTERN = /^(String|Integer|Text|Boolean|Float|DateTime)(\(.*\))?$/;
3816
- var ColumnDefinitionSchema = z4.object({
3817
- name: z4.string().refine(
3818
- (n) => !RESERVED_COLUMN_NAMES.has(n),
3819
- (n) => ({
3820
- message: `Column '${n}' is reserved and added automatically; it must not be declared in the manifest.`
3821
- })
3822
- ),
3823
- type: z4.string().regex(
3824
- COLUMN_TYPE_PATTERN,
3825
- "must be one of String, Integer, Text, Boolean, Float, DateTime (e.g. 'String(255)')"
3826
- ),
3827
- primary_key: z4.boolean().default(false),
3828
- nullable: z4.boolean().default(false),
3829
- index: z4.boolean().default(false),
3830
- default: z4.string().optional(),
3831
- description: z4.string().default("")
3832
- });
3833
- var IndexDefinitionSchema = z4.object({
3834
- name: z4.string(),
3835
- columns: z4.array(z4.string()).min(1),
3836
- unique: z4.boolean().default(false)
3837
- });
3838
- var PermissionRuleSchema = z4.object({
3839
- allowed: z4.boolean().default(false),
3840
- required_role: z4.array(z4.string()).default([])
3841
- }).strict();
3842
- var TablePermissionsSchema = z4.object({
3843
- list: PermissionRuleSchema.default({}),
3844
- read: PermissionRuleSchema.default({}),
3845
- create: PermissionRuleSchema.default({}),
3846
- update: PermissionRuleSchema.default({}),
3847
- delete: PermissionRuleSchema.default({})
3848
- }).strict();
3849
- var TableDefinitionSchema = z4.object({
3850
- name: z4.string().regex(/^[a-z][a-z0-9_]*$/, "table name must be snake_case, e.g. rbac_roles"),
3851
- columns: z4.array(ColumnDefinitionSchema).default([]),
3852
- indexes: z4.array(IndexDefinitionSchema).default([]),
3853
- permissions: TablePermissionsSchema.default({})
3854
- }).superRefine((table, ctx) => {
3855
- const colCounts = /* @__PURE__ */ new Map();
3856
- for (const c of table.columns) colCounts.set(c.name, (colCounts.get(c.name) ?? 0) + 1);
3857
- for (const [name, count] of colCounts) {
3858
- if (count > 1) {
3859
- ctx.addIssue({
3860
- code: z4.ZodIssueCode.custom,
3861
- message: `Duplicate column name '${name}' in table '${table.name}'`
3862
- });
4244
+ );
4245
+ async function runInit(github, aws, config, session, appSibling) {
4246
+ const totalSteps = appSibling ? 6 : 5;
4247
+ if (!session.completedSteps.includes("verify_credentials")) {
4248
+ log.step(1, totalSteps, "Verifying AWS credentials...");
4249
+ await aws.verifyCredentials();
4250
+ markStepComplete(session, "verify_credentials");
4251
+ } else {
4252
+ log.step(1, totalSteps, "AWS credentials already verified \u2014 skipping");
4253
+ }
4254
+ if (!session.completedSteps.includes("create_repo")) {
4255
+ log.step(2, totalSteps, "Creating GitHub repository...");
4256
+ const cloneUrl = await github.createRepoFromTemplate(config);
4257
+ session.outputs.cloneUrl = cloneUrl;
4258
+ markStepComplete(session, "create_repo");
4259
+ } else {
4260
+ log.step(2, totalSteps, "GitHub repository already created \u2014 skipping");
4261
+ }
4262
+ if (!session.completedSteps.includes("oidc_trust")) {
4263
+ log.step(3, totalSteps, "Configuring OIDC trust...");
4264
+ const roleArn = await aws.setupOidcTrust(config, await resolveRepoIds(github, config));
4265
+ session.outputs.oidcRoleArn = roleArn;
4266
+ config.cloud.config = {
4267
+ ...config.cloud.config,
4268
+ oidc_role_arn: roleArn
4269
+ };
4270
+ markStepComplete(session, "oidc_trust");
4271
+ } else {
4272
+ log.step(3, totalSteps, "OIDC trust already configured \u2014 skipping");
4273
+ if (session.outputs.oidcRoleArn) {
4274
+ config.cloud.config = {
4275
+ ...config.cloud.config,
4276
+ oidc_role_arn: session.outputs.oidcRoleArn
4277
+ };
3863
4278
  }
3864
4279
  }
3865
- const idxCounts = /* @__PURE__ */ new Map();
3866
- for (const i of table.indexes) idxCounts.set(i.name, (idxCounts.get(i.name) ?? 0) + 1);
3867
- for (const [name, count] of idxCounts) {
3868
- if (count > 1) {
3869
- ctx.addIssue({
3870
- code: z4.ZodIssueCode.custom,
3871
- message: `Duplicate index name '${name}' in table '${table.name}'`
3872
- });
4280
+ if (!session.completedSteps.includes("terraform_backend")) {
4281
+ log.step(4, totalSteps, "Bootstrapping Terraform state backend...");
4282
+ const tfStateBucket = await aws.bootstrapTerraformBackend(config.project.name);
4283
+ session.outputs.tfStateBucket = tfStateBucket;
4284
+ config.cloud.config = {
4285
+ ...config.cloud.config,
4286
+ tf_state_bucket: tfStateBucket
4287
+ };
4288
+ markStepComplete(session, "terraform_backend");
4289
+ } else {
4290
+ log.step(4, totalSteps, "Terraform backend already bootstrapped \u2014 skipping");
4291
+ if (session.outputs.tfStateBucket) {
4292
+ config.cloud.config = {
4293
+ ...config.cloud.config,
4294
+ tf_state_bucket: session.outputs.tfStateBucket
4295
+ };
3873
4296
  }
3874
4297
  }
3875
- const validColumns = /* @__PURE__ */ new Set([...table.columns.map((c) => c.name), ...RESERVED_COLUMN_NAMES]);
3876
- for (const idx of table.indexes) {
3877
- for (const col of idx.columns) {
3878
- if (!validColumns.has(col)) {
3879
- ctx.addIssue({
3880
- code: z4.ZodIssueCode.custom,
3881
- message: `Index '${idx.name}' on table '${table.name}' references unknown column '${col}'`
3882
- });
4298
+ if (!session.completedSteps.includes("github_config")) {
4299
+ log.step(5, totalSteps, "Configuring GitHub repository...");
4300
+ const { org, repo } = config.source_control.config;
4301
+ const dns = resolveDnsConfig(config);
4302
+ const domain = dns.domain;
4303
+ await github.createBranch(org, repo, "dev", "main");
4304
+ await github.createBranch(org, repo, "staging", "main");
4305
+ await writeInstanceFiles(github, org, repo, config);
4306
+ await github.setDefaultBranch(org, repo, "dev");
4307
+ await github.configureBranchProtection(config);
4308
+ await github.createEnvironments(config);
4309
+ await github.enableVulnerabilityAlerts(org, repo);
4310
+ await github.setRepoVariable(org, repo, "DNS_MODE", dns.mode);
4311
+ if (domain) {
4312
+ await github.setRepoVariable(org, repo, "DOMAIN", domain);
4313
+ }
4314
+ if (domain) {
4315
+ const envDomains = {
4316
+ dev: `dev.${domain}`,
4317
+ staging: `staging.${domain}`,
4318
+ prod: domain
4319
+ };
4320
+ for (const env of config.environments) {
4321
+ const customDomain = envDomains[env] ?? "";
4322
+ if (customDomain) {
4323
+ await github.setEnvVariable(org, repo, env, "CUSTOM_DOMAIN", customDomain);
4324
+ }
3883
4325
  }
3884
4326
  }
3885
- }
3886
- });
3887
- var OPERATION_METHODS = {
3888
- list: /* @__PURE__ */ new Set(["GET"]),
3889
- read: /* @__PURE__ */ new Set(["GET"]),
3890
- create: /* @__PURE__ */ new Set(["POST"]),
3891
- update: /* @__PURE__ */ new Set(["PUT", "PATCH"]),
3892
- delete: /* @__PURE__ */ new Set(["DELETE"])
3893
- };
3894
- var SINGLE_ROW_OPERATIONS = /* @__PURE__ */ new Set(["read", "update", "delete"]);
3895
- var RouteDefSchema = z4.object({
3896
- method: z4.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
3897
- path: z4.string().startsWith("/", "path must start with '/'"),
3898
- table: z4.string(),
3899
- operation: z4.enum(["list", "read", "create", "update", "delete"]),
3900
- description: z4.string().default("")
3901
- }).superRefine((route, ctx) => {
3902
- const allowed = OPERATION_METHODS[route.operation];
3903
- if (allowed && !allowed.has(route.method)) {
3904
- ctx.addIssue({
3905
- code: z4.ZodIssueCode.custom,
3906
- message: `operation '${route.operation}' requires method in [${[...allowed].sort().join(", ")}], got '${route.method}'`
3907
- });
3908
- }
3909
- const hasId = route.path.includes("{id}");
3910
- const needsId = SINGLE_ROW_OPERATIONS.has(route.operation);
3911
- if (needsId && !hasId) {
3912
- ctx.addIssue({
3913
- code: z4.ZodIssueCode.custom,
3914
- message: `operation '${route.operation}' addresses a single row and requires an '{id}' path parameter: ${route.path}`
3915
- });
3916
- }
3917
- if (!needsId && hasId) {
3918
- ctx.addIssue({
3919
- code: z4.ZodIssueCode.custom,
3920
- message: `operation '${route.operation}' is collection-level and must not have an '{id}' path parameter: ${route.path}`
3921
- });
3922
- }
3923
- });
3924
- var PluginManifestSchema = z4.object({
3925
- name: z4.string().regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase kebab-case slug"),
3926
- version: z4.string().regex(/^\d+\.\d+\.\d+$/, "must be a full semver, e.g. 1.2.3"),
3927
- description: z4.string().default(""),
3928
- author: z4.string().default("Biffo Team"),
3929
- tags: z4.array(z4.string()).default([]),
3930
- tables: z4.array(TableDefinitionSchema).default([]),
3931
- api_routes: z4.array(RouteDefSchema).default([]),
3932
- // Events the plugin reacts to. Parsed (rather than dropped as an unknown
3933
- // key) so `biffo plugin install` can warn when a plugin declares
3934
- // subscriptions but ships no terraform/ to route them — see #194 and
3935
- // lib/plugin-terraform-guard.ts. Kept loose deliberately: the authoritative
3936
- // schema is the registry's, and this consumer only needs to count them.
3937
- event_subscriptions: z4.array(z4.object({ source: z4.string(), detail_type: z4.string() }).passthrough()).default([]),
3938
- required_core_version: z4.string().default(">=0.0.0")
3939
- }).superRefine((manifest, ctx) => {
3940
- const tableNames = new Set(manifest.tables.map((t) => t.name));
3941
- for (const route of manifest.api_routes) {
3942
- if (!tableNames.has(route.table)) {
3943
- ctx.addIssue({
3944
- code: z4.ZodIssueCode.custom,
3945
- message: `Route ${route.method} ${route.path} references table '${route.table}', which is not declared in this manifest's 'tables' (${[...tableNames].sort().join(", ") || "none"})`
3946
- });
4327
+ if (session.outputs.oidcRoleArn) {
4328
+ await github.setRepoSecret(org, repo, "BIFFO_OIDC_ROLE_ARN", session.outputs.oidcRoleArn);
3947
4329
  }
4330
+ markStepComplete(session, "github_config");
4331
+ } else {
4332
+ log.step(5, totalSteps, "GitHub already configured \u2014 skipping");
3948
4333
  }
3949
- });
3950
- function validateManifest(raw) {
3951
- const result = PluginManifestSchema.safeParse(raw);
3952
- if (!result.success) {
3953
- const messages = result.error.issues.map((issue) => {
3954
- const path = issue.path.join(".");
3955
- return path ? `${path}: ${issue.message}` : issue.message;
3956
- }).join("; ");
3957
- throw new Error(messages);
4334
+ if (appSibling) {
4335
+ if (!session.completedSteps.includes("app_sibling")) {
4336
+ log.step(6, totalSteps, "Creating the application sibling repository...");
4337
+ await createAppSibling(github, config, session, appSibling);
4338
+ markStepComplete(session, "app_sibling");
4339
+ } else {
4340
+ log.step(6, totalSteps, "Application sibling already created \u2014 skipping");
4341
+ }
3958
4342
  }
3959
- return result.data;
4343
+ deleteSession(config.project.name);
4344
+ saveProjectConfig(config);
3960
4345
  }
3961
-
3962
- // src/lib/plugin-scaffold.ts
3963
- import {
3964
- copyFileSync,
3965
- existsSync as existsSync14,
3966
- mkdirSync as mkdirSync5,
3967
- readFileSync as readFileSync11,
3968
- readdirSync as readdirSync8,
3969
- writeFileSync as writeFileSync5
3970
- } from "fs";
3971
- import { dirname as dirname6, join as join15 } from "path";
3972
- var STANDALONE_ONLY_ENTRIES = {
3973
- ".github": "standalone-repo CI/release workflows \u2014 the host monorepo already runs lint/type/test/security over services/",
3974
- "registry-schema.json": "the plugin-registry publishing schema, used when submitting a *published* plugin to the registry repo, not by an in-tree plugin"
3975
- };
3976
- var NEVER_COPY = /* @__PURE__ */ new Set([
3977
- ".git",
3978
- ".venv",
3979
- "node_modules",
3980
- "__pycache__",
3981
- ".ruff_cache",
3982
- ".pytest_cache",
3983
- ".mypy_cache",
3984
- "dist",
3985
- "uv.lock",
3986
- ".DS_Store"
3987
- ]);
3988
- var PLUGIN_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
3989
- function deriveNames(slug) {
3990
- if (!PLUGIN_NAME_PATTERN.test(slug)) {
3991
- throw new Error(
3992
- `Invalid plugin name '${slug}'. Expected a lowercase kebab-case slug starting with a letter, e.g. 'acme-crm' (this is what biffo.plugin.json's 'name' field accepts, per registry-schema.json).`
3993
- );
3994
- }
3995
- const parts = slug.split("-");
3996
- return {
3997
- slug,
3998
- pkg: parts.join("_"),
3999
- pascal: parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""),
4000
- dist: `biffo-plugin-${slug}`
4346
+ async function createAppSibling(github, config, session, deps) {
4347
+ const siblingConfig = appSiblingConfig(config);
4348
+ const cloud = config.cloud;
4349
+ session.outputs.appSibling ??= {
4350
+ version: 1,
4351
+ config: siblingConfig,
4352
+ awsAccountId: cloud.config.account_id,
4353
+ awsRegion: cloud.config.region,
4354
+ completedSteps: [],
4355
+ outputs: {}
4001
4356
  };
4357
+ const siblingSession = session.outputs.appSibling;
4358
+ const siblingAws = deps.awsFor(siblingConfig);
4359
+ await runSiblingCreate(github, siblingAws, siblingAws, deps.git, siblingConfig, siblingSession, {
4360
+ coreConfig: config,
4361
+ skeletonRoot: deps.skeletonRoot,
4362
+ githubToken: deps.githubToken,
4363
+ skipCoreIdentity: true,
4364
+ skipRegistration: true
4365
+ });
4002
4366
  }
4003
- function substitutions(names) {
4004
- return [
4005
- [/biffo-plugin-example/g, names.dist],
4006
- [/ExamplePlugin/g, `${names.pascal}Plugin`],
4007
- // The example table. Table names are global in the instance's database, so
4008
- // leaving every scaffolded plugin with `example_widgets` would have the
4009
- // second one collide with the first at migration time.
4010
- [/example_widgets/g, `${names.pkg}_widgets`],
4011
- [/example_plugin/g, names.pkg],
4012
- [/example-plugin/g, names.slug]
4013
- ];
4367
+ function appSiblingConfig(config) {
4368
+ const { org } = config.source_control.config;
4369
+ const cloud = config.cloud;
4370
+ const name = rootSiblingProjectName(config.project.name);
4371
+ return SiblingConfigSchema.parse({
4372
+ project: {
4373
+ name,
4374
+ description: `${config.project.description || config.project.name} \u2014 the user-facing application, served at /`
4375
+ },
4376
+ // Repo name === project name, deliberately and not incidentally:
4377
+ // `resolveSiblingRepos()` (lib/sibling-teardown.ts) resolves a sibling's
4378
+ // repo as `<coreOrg>/<projectName>`, recovering the project name from the
4379
+ // S3 bucket in the registry. Let the two diverge and `biffo teardown`
4380
+ // cannot find the repo it is meant to delete.
4381
+ source_control: { provider: "github", config: { org, repo: name } },
4382
+ // Identity only — account, region, profile. Deliberately NOT spread from
4383
+ // the core's cloud config, which by this point in `runInit` also carries
4384
+ // the CORE's `oidc_role_arn` and `tf_state_bucket`. The sibling gets its
4385
+ // own of both (steps 4 and 5 of runSiblingCreate); inheriting the core's
4386
+ // would either be silently wrong or, as an empty string mid-run, fail
4387
+ // schema validation here and take the whole init down with it.
4388
+ cloud: {
4389
+ provider: "aws",
4390
+ config: {
4391
+ account_id: cloud.config.account_id,
4392
+ region: cloud.config.region,
4393
+ ...cloud.config.profile ? { profile: cloud.config.profile } : {}
4394
+ }
4395
+ },
4396
+ environments: config.environments,
4397
+ core: {
4398
+ project_name: config.project.name,
4399
+ // The empty prefix IS the root mode. It registers under the reserved,
4400
+ // non-empty name "app" — see lib/root-sibling.ts on why those two must
4401
+ // not be conflated.
4402
+ path_prefix: ""
4403
+ }
4404
+ });
4014
4405
  }
4015
- function applySubstitutions(text, names) {
4016
- let out = text;
4017
- for (const [pattern, replacement] of substitutions(names)) {
4018
- out = out.replace(pattern, replacement);
4019
- }
4020
- return out;
4406
+ function appSiblingRegistryFiles(config) {
4407
+ const sibling = appSiblingConfig(config);
4408
+ const { account_id: accountId, region } = config.cloud.config;
4409
+ return config.environments.map((env) => ({
4410
+ path: `infra/environments/${env}/siblings.auto.tfvars.json`,
4411
+ content: serializeRegistry([
4412
+ {
4413
+ name: ROOT_SIBLING_NAME,
4414
+ bucket_regional_domain: bucketRegionalDomain(
4415
+ siteBucketName(sibling.project.name, env, accountId),
4416
+ region
4417
+ ),
4418
+ description: sibling.project.description
4419
+ }
4420
+ ])
4421
+ }));
4021
4422
  }
4022
- var BINARY_EXTENSIONS = /\.(png|jpe?g|gif|ico|woff2?|ttf|zip|gz)$/i;
4023
- function scaffoldPlugin(skeletonRoot, destDir, names) {
4024
- if (!existsSync14(skeletonRoot)) {
4025
- throw new Error(`Plugin skeleton not found at ${skeletonRoot}`);
4423
+ var INSTANCE_CONFIG_FILE = "biffo.config.json";
4424
+ var INSTANCE_FILE_BRANCHES = ["main", "dev", "staging"];
4425
+ async function writeInstanceFiles(github, org, repo, config) {
4426
+ const files = [
4427
+ { path: INSTANCE_CORE_FILE, content: serializeInstanceCoreVersion(getLatestCoreVersion()) },
4428
+ { path: INSTANCE_CONFIG_FILE, content: null },
4429
+ ...config ? appSiblingRegistryFiles(config) : []
4430
+ ];
4431
+ 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}`;
4432
+ for (const branch of INSTANCE_FILE_BRANCHES) {
4433
+ await github.commitFiles(org, repo, branch, files, message);
4026
4434
  }
4027
- if (!existsSync14(join15(skeletonRoot, "terraform"))) {
4028
- throw new Error(
4029
- `Plugin skeleton at ${skeletonRoot} has no terraform/ directory. Refusing to scaffold a plugin that cannot receive events (issue #194) \u2014 the skeleton is broken.`
4030
- );
4435
+ }
4436
+ function printPlan2(config) {
4437
+ const { org, repo } = config.source_control.config;
4438
+ const appRepo = rootSiblingProjectName(config.project.name);
4439
+ console.log(chalk12.bold("\n This will create TWO GitHub repositories:\n"));
4440
+ console.log(` 1. ${chalk12.bold(`${org}/${repo}`)}`);
4441
+ console.log(" The platform \u2014 Core API, admin portal (/admin, /login), infrastructure.");
4442
+ console.log(` 2. ${chalk12.bold(`${org}/${appRepo}`)}`);
4443
+ console.log(" Your application \u2014 served at / (ADR-0007 sibling, issue #306).");
4444
+ console.log(
4445
+ `
4446
+ Plus, in your AWS account: an OIDC role and a Terraform state bucket for each,
4447
+ across ${config.environments.join(", ")}. \`biffo teardown\` removes both repositories
4448
+ and everything they provision.
4449
+ `
4450
+ );
4451
+ }
4452
+ function parseConfig(raw) {
4453
+ const result = BiffoConfigSchema.safeParse(raw);
4454
+ if (!result.success) {
4455
+ log.error("Invalid configuration:");
4456
+ result.error.issues.forEach((issue) => {
4457
+ log.error(` ${issue.path.join(".")} \u2014 ${issue.message}`);
4458
+ });
4459
+ process.exit(1);
4031
4460
  }
4032
- const skipped = [];
4033
- const files = [];
4034
- const walk = (relDir) => {
4035
- const absDir = join15(skeletonRoot, relDir);
4036
- for (const entry of readdirSync8(absDir, { withFileTypes: true }).sort(
4037
- (a, b) => a.name.localeCompare(b.name)
4038
- )) {
4039
- if (NEVER_COPY.has(entry.name)) continue;
4040
- if (relDir === "" && entry.name in STANDALONE_ONLY_ENTRIES) {
4041
- skipped.push({ entry: entry.name, reason: STANDALONE_ONLY_ENTRIES[entry.name] });
4042
- continue;
4043
- }
4044
- const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
4045
- if (entry.isDirectory()) {
4046
- walk(relPath);
4047
- continue;
4048
- }
4049
- const destRel = applySubstitutions(relPath, names);
4050
- const destPath = join15(destDir, destRel);
4051
- mkdirSync5(dirname6(destPath), { recursive: true });
4052
- if (BINARY_EXTENSIONS.test(entry.name)) {
4053
- copyFileSync(join15(skeletonRoot, relPath), destPath);
4054
- } else {
4055
- writeFileSync5(
4056
- destPath,
4057
- applySubstitutions(readFileSync11(join15(skeletonRoot, relPath), "utf8"), names)
4058
- );
4461
+ return result.data;
4462
+ }
4463
+ function applyResolvedAwsCredentials(config, credentials) {
4464
+ return {
4465
+ ...config,
4466
+ cloud: {
4467
+ provider: "aws",
4468
+ config: {
4469
+ ...config.cloud.config,
4470
+ account_id: credentials.accountId,
4471
+ region: credentials.region,
4472
+ ...credentials.profile ? { profile: credentials.profile } : {}
4059
4473
  }
4060
- files.push(destRel);
4061
4474
  }
4062
4475
  };
4063
- walk("");
4064
- if (!files.some((f) => f.startsWith("terraform/"))) {
4065
- throw new Error(
4066
- `Scaffold produced no terraform/ files from ${skeletonRoot} \u2014 refusing to leave a plugin whose event subscriptions could never fire (issue #194).`
4067
- );
4068
- }
4069
- return { files: files.sort(), skipped };
4070
- }
4071
- function findSkeletonRoot(startDir, skeleton) {
4072
- let dir = startDir;
4073
- for (; ; ) {
4074
- const candidate = join15(dir, "_skeletons", skeleton);
4075
- if (existsSync14(candidate)) return candidate;
4076
- const parent = dirname6(dir);
4077
- if (parent === dir) return null;
4078
- dir = parent;
4079
- }
4080
4476
  }
4081
-
4082
- // src/commands/plugin-create.ts
4083
- var pluginCreateCommand = new Command12("create").description("Scaffold a new plugin from the Biffo plugin skeleton: biffo plugin create <name>").argument("<name>", "Plugin name \u2014 lowercase kebab-case, e.g. acme-crm").option(
4084
- "--first-party",
4085
- "Scaffold into the template-owned services/_plugins/ carve-out. Only valid in the biffo-template repo itself \u2014 see notes."
4086
- ).option(
4087
- "--skeleton <path>",
4088
- "Path to the plugin skeleton (defaults to _skeletons/plugin-template)"
4089
- ).option("--dry-run", "Print planned changes without modifying the repo").option("--no-commit", "Scaffold the files but leave them uncommitted").option("--cwd <path>", "Project root to scaffold into (defaults to the current directory)").action(
4090
- async (name, options) => {
4091
- const cwd = options.cwd ? resolve10(options.cwd) : process.cwd();
4092
- try {
4093
- await runPluginCreate(
4094
- name,
4477
+ async function promptForConfig(awsAccountId, awsRegion, awsProfile) {
4478
+ assertInteractive(
4479
+ "Project configuration",
4480
+ "Pass --config <path> with a pre-filled biffo.config.json (it implies -y)."
4481
+ );
4482
+ const answers = await inquirer5.prompt([
4483
+ {
4484
+ type: "input",
4485
+ name: "project_name",
4486
+ message: "Project name (lowercase kebab-case):",
4487
+ validate: (v) => /^[a-z0-9-]+$/.test(v) || "Must be lowercase kebab-case"
4488
+ },
4489
+ { type: "input", name: "project_description", message: "Project description:" },
4490
+ {
4491
+ type: "list",
4492
+ name: "dns_mode",
4493
+ message: "DNS / custom domain mode:",
4494
+ choices: [
4095
4495
  {
4096
- firstParty: options.firstParty ?? false,
4097
- ...options.skeleton ? { skeletonRoot: resolve10(options.skeleton) } : {},
4098
- dryRun: options.dryRun ?? false,
4099
- commit: options.commit !== false,
4100
- cwd
4496
+ name: "Managed Route53 \u2014 create DNS zone, certificate, and records automatically",
4497
+ value: "managed-route53"
4101
4498
  },
4102
- { git: new GitAdapter() }
4103
- );
4104
- } catch (err) {
4105
- log.error(err.message);
4106
- process.exit(1);
4107
- }
4108
- }
4109
- );
4110
- async function runPluginCreate(name, options, deps) {
4111
- const names = deriveNames(name);
4112
- const isInstance = existsSync15(join16(options.cwd, INSTANCE_CORE_FILE));
4113
- if (options.firstParty && isInstance) {
4114
- throw new Error(
4115
- `--first-party scaffolds into services/_plugins/, which is template-owned: \`biffo core upgrade\` three-way-merges it against the template on every upgrade, and the template has no '${names.slug}'. This checkout is a Biffo instance (${INSTANCE_CORE_FILE} is present), so your plugin belongs in the user-owned ${pluginDir(names.slug, "third-party")}/ \u2014 re-run without --first-party.`
4116
- );
4117
- }
4118
- const channel = options.firstParty ? "first-party" : "third-party";
4119
- const relDir = pluginDir(names.slug, channel);
4120
- const destDir = join16(options.cwd, relDir);
4121
- const servicesDir = join16(options.cwd, "services");
4122
- if (!existsSync15(servicesDir)) {
4123
- throw new Error(
4124
- `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
4125
- );
4126
- }
4127
- if (existsSync15(destDir)) {
4128
- throw new Error(`${relDir}/ already exists. Choose a different name, or remove it first.`);
4129
- }
4130
- const here = dirname7(fileURLToPath4(import.meta.url));
4131
- const skeletonRoot = options.skeletonRoot ?? findSkeletonRoot(here, "plugin-template") ?? join16(options.cwd, "_skeletons", "plugin-template");
4132
- if (!existsSync15(skeletonRoot)) {
4133
- throw new Error(
4134
- `Could not find the plugin skeleton (_skeletons/plugin-template/). Pass --skeleton <path> to point at it explicitly.`
4135
- );
4136
- }
4137
- if (options.dryRun) {
4138
- printDryRun2(names, relDir, skeletonRoot, channel);
4139
- return;
4140
- }
4141
- const { files, skipped } = scaffoldPlugin(skeletonRoot, destDir, names);
4142
- log.success(`Scaffolded ${files.length} file(s) into ${relDir}/`);
4143
- for (const { entry, reason } of skipped) {
4144
- log.info(`Skipped ${entry} \u2014 ${reason}`);
4145
- }
4146
- const manifestPath = join16(destDir, "biffo.plugin.json");
4147
- const manifest = validateManifest(JSON.parse(readFileSync12(manifestPath, "utf8")));
4148
- if (manifest.name !== names.slug) {
4149
- throw new Error(
4150
- `Scaffolded manifest declares name '${manifest.name}', expected '${names.slug}'. The skeleton's manifest name may have diverged from 'example-plugin'.`
4151
- );
4152
- }
4153
- log.success(
4154
- `Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
4155
- );
4156
- if (options.commit) {
4157
- if (!await deps.git.isGitRepo(options.cwd)) {
4158
- throw new Error(
4159
- `${options.cwd} is not a git repository \u2014 biffo plugin create must be run from a Biffo project checkout.`
4160
- );
4499
+ {
4500
+ name: "External DNS \u2014 request SSL certificate and print records for manual DNS changes",
4501
+ value: "external"
4502
+ },
4503
+ {
4504
+ name: "None \u2014 use the default CloudFront domain only",
4505
+ value: "none"
4506
+ }
4507
+ ],
4508
+ default: "managed-route53"
4509
+ },
4510
+ {
4511
+ type: "input",
4512
+ name: "domain",
4513
+ message: "Primary domain (e.g. myapp.com):",
4514
+ when: (a) => a.dns_mode !== "none",
4515
+ validate: (v) => v.trim().length > 0 || "Domain is required for this DNS mode"
4516
+ },
4517
+ { type: "input", name: "github_org", message: "GitHub org or username:" },
4518
+ { type: "input", name: "github_repo", message: "Repository name (will be created):" },
4519
+ { type: "input", name: "admin_email", message: "Admin email address:" },
4520
+ { type: "input", name: "admin_username", message: "Admin username:" },
4521
+ {
4522
+ type: "checkbox",
4523
+ name: "environments",
4524
+ message: "Environments to provision:",
4525
+ choices: ["dev", "staging", "prod"],
4526
+ default: ["dev"]
4161
4527
  }
4162
- const commitMessage = `feat(plugins): scaffold ${names.slug} plugin`;
4163
- await deps.git.add(options.cwd, [relDir]);
4164
- await deps.git.commit(options.cwd, commitMessage);
4165
- log.success(`Committed: ${commitMessage}`);
4166
- }
4167
- printNextSteps(names, relDir, channel);
4168
- }
4169
- function printDryRun2(names, relDir, skeletonRoot, channel) {
4170
- console.log(chalk12.bold("\n Dry run \u2014 no changes will be made\n"));
4171
- console.log(` Plugin: ${names.slug}`);
4172
- console.log(` Channel: ${channel}`);
4173
- console.log(` Would scaffold: ${relDir}/`);
4174
- console.log(` From skeleton: ${skeletonRoot}`);
4175
- console.log(` Python package: ${names.pkg} (dist: ${names.dist})`);
4176
- console.log(` Would commit: feat(plugins): scaffold ${names.slug} plugin
4177
- `);
4178
- }
4179
- function printNextSteps(names, relDir, channel) {
4180
- console.log(chalk12.bold("\n Plugin scaffolded!\n"));
4181
- console.log(` ${relDir}/ contains a working example: one table, four CRUD routes,`);
4182
- console.log(` one event subscription, and a terraform/ module for its Lambda.
4183
- `);
4184
- console.log(" Next:");
4185
- console.log(chalk12.dim(` 1. Edit ${relDir}/biffo.plugin.json \u2014 your tables and routes`));
4186
- console.log(chalk12.dim(` 2. Edit ${relDir}/src/${names.pkg}/plugin.py \u2014 your event handlers`));
4187
- console.log(chalk12.dim(` 3. biffo plugin install --local ${relDir}`));
4188
- console.log(
4189
- chalk12.dim(" (copies terraform/ into modules/plugins/, generates the migration)\n")
4190
- );
4191
- if (channel === "first-party") {
4192
- log.warn(
4193
- `${relDir}/ is template-owned: it will be distributed to every instance by \`biffo core upgrade\`, and bumping core.version is required for it (ADR-0006).`
4194
- );
4195
- }
4528
+ ]);
4529
+ return {
4530
+ project: {
4531
+ name: answers.project_name,
4532
+ description: answers.project_description
4533
+ },
4534
+ dns: {
4535
+ mode: answers.dns_mode,
4536
+ domain: answers.domain
4537
+ },
4538
+ source_control: {
4539
+ provider: "github",
4540
+ config: { org: answers.github_org, repo: answers.github_repo }
4541
+ },
4542
+ cloud: {
4543
+ provider: "aws",
4544
+ config: {
4545
+ account_id: awsAccountId,
4546
+ region: awsRegion,
4547
+ ...awsProfile ? { profile: awsProfile } : {}
4548
+ }
4549
+ },
4550
+ environments: answers.environments,
4551
+ admin: { email: answers.admin_email, username: answers.admin_username }
4552
+ };
4196
4553
  }
4197
4554
 
4198
- // src/commands/plugin-info.ts
4555
+ // src/commands/plugin.ts
4556
+ import { Command as Command20 } from "commander";
4557
+
4558
+ // src/commands/plugin-create.ts
4559
+ import { existsSync as existsSync17, readFileSync as readFileSync14 } from "fs";
4560
+ import { dirname as dirname8, join as join18, resolve as resolve11 } from "path";
4561
+ import { fileURLToPath as fileURLToPath4 } from "url";
4199
4562
  import chalk13 from "chalk";
4200
4563
  import { Command as Command13 } from "commander";
4201
4564
 
4202
- // src/adapters/registry/index.ts
4565
+ // src/lib/plugin-locations.ts
4566
+ import { existsSync as existsSync15, readdirSync as readdirSync8 } from "fs";
4567
+ import { join as join16 } from "path";
4568
+ var FIRST_PARTY_PLUGINS_DIR = "_plugins";
4569
+ var PLUGIN_MANIFEST_FILE = "biffo.plugin.json";
4570
+ function pluginDir(name, channel) {
4571
+ return channel === "first-party" ? `services/${FIRST_PARTY_PLUGINS_DIR}/${name}` : `services/${name}`;
4572
+ }
4573
+ function scanDir(absDir, relDir, channel) {
4574
+ if (!existsSync15(absDir)) return [];
4575
+ const found = [];
4576
+ for (const entry of readdirSync8(absDir, { withFileTypes: true })) {
4577
+ if (!entry.isDirectory()) continue;
4578
+ if (channel === "third-party" && entry.name === FIRST_PARTY_PLUGINS_DIR) continue;
4579
+ const manifestPath = join16(absDir, entry.name, PLUGIN_MANIFEST_FILE);
4580
+ if (!existsSync15(manifestPath)) continue;
4581
+ found.push({
4582
+ dirName: entry.name,
4583
+ relDir: `${relDir}/${entry.name}`,
4584
+ manifestPath,
4585
+ channel
4586
+ });
4587
+ }
4588
+ return found;
4589
+ }
4590
+ function findInstalledPlugins(cwd) {
4591
+ const servicesDir = join16(cwd, "services");
4592
+ return [
4593
+ ...scanDir(servicesDir, "services", "third-party"),
4594
+ ...scanDir(
4595
+ join16(servicesDir, FIRST_PARTY_PLUGINS_DIR),
4596
+ `services/${FIRST_PARTY_PLUGINS_DIR}`,
4597
+ "first-party"
4598
+ )
4599
+ ].sort((a, b) => a.relDir.localeCompare(b.relDir));
4600
+ }
4601
+
4602
+ // src/lib/plugin-manifest.ts
4203
4603
  import { z as z5 } from "zod";
4204
- var RegistryPluginEntrySchema = z5.object({
4205
- name: z5.string().regex(/^[a-z][a-z0-9-]*$/),
4206
- version: z5.string().regex(/^\d+\.\d+\.\d+$/),
4207
- minor_version: z5.string().regex(/^\d+\.\d+$/),
4208
- repo: z5.string().url(),
4209
- description: z5.string().optional(),
4210
- author: z5.string().optional(),
4211
- tags: z5.array(z5.string()).optional(),
4212
- required_core_version: z5.string().optional(),
4213
- infra_modules: z5.array(z5.string()).optional(),
4214
- api_routes: z5.array(z5.string()).optional(),
4215
- ui_components: z5.array(z5.string()).optional(),
4216
- status: z5.enum(["active", "disabled"])
4604
+ var RESERVED_COLUMN_NAMES = /* @__PURE__ */ new Set(["id", "tenant_id", "created_at", "updated_at"]);
4605
+ var COLUMN_TYPE_PATTERN = /^(String|Integer|Text|Boolean|Float|DateTime)(\(.*\))?$/;
4606
+ var ColumnDefinitionSchema = z5.object({
4607
+ name: z5.string().refine(
4608
+ (n) => !RESERVED_COLUMN_NAMES.has(n),
4609
+ (n) => ({
4610
+ message: `Column '${n}' is reserved and added automatically; it must not be declared in the manifest.`
4611
+ })
4612
+ ),
4613
+ type: z5.string().regex(
4614
+ COLUMN_TYPE_PATTERN,
4615
+ "must be one of String, Integer, Text, Boolean, Float, DateTime (e.g. 'String(255)')"
4616
+ ),
4617
+ primary_key: z5.boolean().default(false),
4618
+ nullable: z5.boolean().default(false),
4619
+ index: z5.boolean().default(false),
4620
+ default: z5.string().optional(),
4621
+ description: z5.string().default("")
4217
4622
  });
4218
- var PluginRegistrySchema = z5.object({
4219
- schema_version: z5.string(),
4220
- last_updated: z5.string(),
4221
- plugins: z5.array(RegistryPluginEntrySchema)
4623
+ var IndexDefinitionSchema = z5.object({
4624
+ name: z5.string(),
4625
+ columns: z5.array(z5.string()).min(1),
4626
+ unique: z5.boolean().default(false)
4222
4627
  });
4223
- var DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/keiranholloway/biffo-plugins-registry/main/plugins.json";
4224
- var RegistryAdapter = class {
4225
- registryUrl;
4226
- constructor(registryUrl) {
4227
- this.registryUrl = registryUrl ?? process.env["BIFFO_REGISTRY_URL"] ?? DEFAULT_REGISTRY_URL;
4228
- }
4229
- /** Fetches and validates plugins.json from the registry. */
4230
- async fetchRegistry() {
4231
- let response;
4232
- try {
4233
- response = await fetch(this.registryUrl);
4234
- } catch (err) {
4235
- throw new Error(
4236
- `Could not reach the plugin registry at ${this.registryUrl}: ${err.message}`
4237
- );
4238
- }
4239
- if (!response.ok) {
4240
- throw new Error(
4241
- `Plugin registry returned ${response.status} ${response.statusText} (${this.registryUrl})`
4242
- );
4243
- }
4244
- let raw;
4245
- try {
4246
- raw = await response.json();
4247
- } catch (err) {
4248
- throw new Error(
4249
- `Plugin registry at ${this.registryUrl} did not return valid JSON: ${err.message}`
4250
- );
4251
- }
4252
- const result = PluginRegistrySchema.safeParse(raw);
4253
- if (!result.success) {
4254
- const messages = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
4255
- throw new Error(
4256
- `Plugin registry at ${this.registryUrl} has an invalid shape: ${messages.join("; ")}`
4257
- );
4258
- }
4259
- return result.data;
4260
- }
4261
- /**
4262
- * Resolves `name@minorVersion` (e.g. "rbac", "1.0") against the registry.
4263
- *
4264
- * The registry stores one entry per plugin — its current release — not a
4265
- * full version history, so "resolving the latest patch for a minor"
4266
- * degenerates to an exact match against that single entry's
4267
- * `minor_version`. True multi-version history isn't representable in the
4268
- * registry schema today; see PR description for this known limitation.
4269
- */
4270
- async resolvePlugin(name, minorVersion) {
4271
- const registry = await this.fetchRegistry();
4272
- const candidates = registry.plugins.filter((p) => p.name === name);
4273
- if (candidates.length === 0) {
4274
- throw new Error(`Plugin '${name}' was not found in the registry (${this.registryUrl}).`);
4275
- }
4276
- const match = candidates.find((p) => p.minor_version === minorVersion);
4277
- if (!match) {
4278
- const available = candidates.map((p) => `${p.name}@${p.minor_version} (${p.status})`);
4279
- throw new Error(
4280
- `No version matching '${name}@${minorVersion}' found in the registry. Available: ${available.join(", ")}`
4281
- );
4282
- }
4283
- if (match.status !== "active") {
4284
- throw new Error(
4285
- `Plugin '${name}@${minorVersion}' is disabled in the registry and cannot be installed.`
4286
- );
4628
+ var PermissionRuleSchema = z5.object({
4629
+ allowed: z5.boolean().default(false),
4630
+ required_role: z5.array(z5.string()).default([])
4631
+ }).strict();
4632
+ var TablePermissionsSchema = z5.object({
4633
+ list: PermissionRuleSchema.default({}),
4634
+ read: PermissionRuleSchema.default({}),
4635
+ create: PermissionRuleSchema.default({}),
4636
+ update: PermissionRuleSchema.default({}),
4637
+ delete: PermissionRuleSchema.default({})
4638
+ }).strict();
4639
+ var TableDefinitionSchema = z5.object({
4640
+ name: z5.string().regex(/^[a-z][a-z0-9_]*$/, "table name must be snake_case, e.g. rbac_roles"),
4641
+ columns: z5.array(ColumnDefinitionSchema).default([]),
4642
+ indexes: z5.array(IndexDefinitionSchema).default([]),
4643
+ permissions: TablePermissionsSchema.default({})
4644
+ }).superRefine((table, ctx) => {
4645
+ const colCounts = /* @__PURE__ */ new Map();
4646
+ for (const c of table.columns) colCounts.set(c.name, (colCounts.get(c.name) ?? 0) + 1);
4647
+ for (const [name, count] of colCounts) {
4648
+ if (count > 1) {
4649
+ ctx.addIssue({
4650
+ code: z5.ZodIssueCode.custom,
4651
+ message: `Duplicate column name '${name}' in table '${table.name}'`
4652
+ });
4287
4653
  }
4288
- return match;
4289
- }
4290
- };
4291
-
4292
- // src/commands/plugin-info.ts
4293
- var pluginInfoCommand = new Command13("info").description("Show registry details for a plugin: biffo plugin info <name>").argument("<name>", "Plugin name").action(async (name) => {
4294
- try {
4295
- await runPluginInfo(name, { registry: new RegistryAdapter() });
4296
- } catch (err) {
4297
- log.error(err.message);
4298
- process.exit(1);
4299
4654
  }
4300
- });
4301
- async function runPluginInfo(name, deps) {
4302
- const registry = await deps.registry.fetchRegistry();
4303
- const matches = registry.plugins.filter((p) => p.name === name);
4304
- if (matches.length === 0) {
4305
- throw new Error(`Plugin '${name}' was not found in the registry.`);
4306
- }
4307
- for (const entry of matches) {
4308
- printEntry(entry);
4309
- }
4310
- }
4311
- function printEntry(entry) {
4312
- console.log(chalk13.bold(`
4313
- ${entry.name}@${entry.version}
4314
- `));
4315
- console.log(` Status: ${entry.status}`);
4316
- console.log(` Minor version channel: ${entry.minor_version}`);
4317
- console.log(` Repo: ${entry.repo}`);
4318
- if (entry.description) console.log(` Description: ${entry.description}`);
4319
- if (entry.author) console.log(` Author: ${entry.author}`);
4320
- if (entry.tags?.length) console.log(` Tags: ${entry.tags.join(", ")}`);
4321
- if (entry.required_core_version) {
4322
- console.log(` Required core version: ${entry.required_core_version}`);
4323
- }
4324
- if (entry.infra_modules?.length) {
4325
- console.log(` Infra modules: ${entry.infra_modules.join(", ")}`);
4326
- }
4327
- if (entry.api_routes?.length) {
4328
- console.log(` API routes: ${entry.api_routes.join(", ")}`);
4329
- }
4330
- if (entry.ui_components?.length) {
4331
- console.log(` UI components: ${entry.ui_components.join(", ")}`);
4332
- }
4333
- console.log("");
4334
- }
4335
-
4336
- // src/commands/plugin-install.ts
4337
- import { cpSync as cpSync2, existsSync as existsSync17, mkdirSync as mkdirSync7, readFileSync as readFileSync14, statSync as statSync4 } from "fs";
4338
- import { basename, join as join19, relative as relative3, resolve as resolve11 } from "path";
4339
- import chalk14 from "chalk";
4340
- import { Command as Command14 } from "commander";
4341
-
4342
- // src/adapters/plugin-migrations/index.ts
4343
- import { execa as execa3 } from "execa";
4344
- import { join as join17 } from "path";
4345
- var PluginMigrationsAdapter = class {
4346
- /**
4347
- * Generates migration file(s) for `pluginNames` (every discovered
4348
- * installed plugin if omitted), returning the absolute path of each newly
4349
- * generated file — empty if every named plugin already had a migration,
4350
- * or declared no tables.
4351
- */
4352
- async generate(cwd, pluginNames) {
4353
- const scriptPath = join17(cwd, "services", "api", "scripts", "generate_plugin_migrations.py");
4354
- const args = [
4355
- "run",
4356
- "python",
4357
- scriptPath,
4358
- "--services-root",
4359
- join17(cwd, "services"),
4360
- "--versions-dir",
4361
- join17(cwd, "services", "api", "migrations", "versions")
4362
- ];
4363
- for (const name of pluginNames ?? []) {
4364
- args.push("--plugin", name);
4655
+ const idxCounts = /* @__PURE__ */ new Map();
4656
+ for (const i of table.indexes) idxCounts.set(i.name, (idxCounts.get(i.name) ?? 0) + 1);
4657
+ for (const [name, count] of idxCounts) {
4658
+ if (count > 1) {
4659
+ ctx.addIssue({
4660
+ code: z5.ZodIssueCode.custom,
4661
+ message: `Duplicate index name '${name}' in table '${table.name}'`
4662
+ });
4365
4663
  }
4366
- let result;
4367
- try {
4368
- result = await execa3("uv", args, { cwd: join17(cwd, "services", "api") });
4369
- } catch (err) {
4370
- const cause = err;
4371
- if (cause.code === "ENOENT") {
4372
- throw new Error(
4373
- "biffo plugin install/upgrade/sync-migrations needs `uv` (Python) on PATH to generate a real migration file \u2014 see https://docs.astral.sh/uv/ to install it. Once installed, re-run this command (or `biffo plugin sync-migrations <name>` if services/<name>/ is already copied in)."
4374
- );
4664
+ }
4665
+ const validColumns = /* @__PURE__ */ new Set([...table.columns.map((c) => c.name), ...RESERVED_COLUMN_NAMES]);
4666
+ for (const idx of table.indexes) {
4667
+ for (const col of idx.columns) {
4668
+ if (!validColumns.has(col)) {
4669
+ ctx.addIssue({
4670
+ code: z5.ZodIssueCode.custom,
4671
+ message: `Index '${idx.name}' on table '${table.name}' references unknown column '${col}'`
4672
+ });
4375
4673
  }
4376
- throw new Error(
4377
- `Failed to generate plugin migration: ${cause.stderr?.trim() || err.message}`
4378
- );
4379
4674
  }
4380
- return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
4381
4675
  }
4676
+ });
4677
+ var OPERATION_METHODS = {
4678
+ list: /* @__PURE__ */ new Set(["GET"]),
4679
+ read: /* @__PURE__ */ new Set(["GET"]),
4680
+ create: /* @__PURE__ */ new Set(["POST"]),
4681
+ update: /* @__PURE__ */ new Set(["PUT", "PATCH"]),
4682
+ delete: /* @__PURE__ */ new Set(["DELETE"])
4382
4683
  };
4383
-
4384
- // src/lib/plugin-terraform-wiring.ts
4385
- import { existsSync as existsSync16, mkdirSync as mkdirSync6, readFileSync as readFileSync13, readdirSync as readdirSync9, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "fs";
4386
- import { join as join18 } from "path";
4387
- var TEMPLATE_MODULE_DIR = "_template";
4388
- var DEFAULT_PLUGIN_HANDLER = "src.lambda.main.handler";
4389
- var GENERATED_TF_FILE = "plugins.generated.tf";
4390
- var GENERATED_TFVARS_FILE = "plugins.auto.tfvars.json";
4391
- function standardArguments(pluginName, handler) {
4392
- return [
4393
- ["project_name", "var.project_name"],
4394
- ["environment", "local.environment"],
4395
- ["plugin_name", JSON.stringify(pluginName)],
4396
- ["handler", JSON.stringify(handler)],
4397
- ["event_bus_name", "module.events.event_bus_name"],
4398
- ["core_api_url", "module.api_gateway.api_endpoint"],
4399
- ["core_api_execution_arn", "module.api_gateway.execution_arn"],
4400
- ["tags", "local.tags"]
4401
- ];
4402
- }
4403
- function listPluginModules(cwd) {
4404
- const dir = join18(cwd, "modules", "plugins");
4405
- let entries;
4406
- try {
4407
- entries = readdirSync9(dir, { withFileTypes: true });
4408
- } catch {
4409
- return [];
4410
- }
4411
- return entries.filter((e) => e.isDirectory() && e.name !== TEMPLATE_MODULE_DIR && !e.name.startsWith(".")).map((e) => e.name).sort();
4412
- }
4413
- function listEnvironments(cwd) {
4414
- const dir = join18(cwd, "infra", "environments");
4415
- let entries;
4416
- try {
4417
- entries = readdirSync9(dir, { withFileTypes: true });
4418
- } catch {
4419
- return [];
4684
+ var SINGLE_ROW_OPERATIONS = /* @__PURE__ */ new Set(["read", "update", "delete"]);
4685
+ var RouteDefSchema = z5.object({
4686
+ method: z5.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
4687
+ path: z5.string().startsWith("/", "path must start with '/'"),
4688
+ table: z5.string(),
4689
+ operation: z5.enum(["list", "read", "create", "update", "delete"]),
4690
+ description: z5.string().default("")
4691
+ }).superRefine((route, ctx) => {
4692
+ const allowed = OPERATION_METHODS[route.operation];
4693
+ if (allowed && !allowed.has(route.method)) {
4694
+ ctx.addIssue({
4695
+ code: z5.ZodIssueCode.custom,
4696
+ message: `operation '${route.operation}' requires method in [${[...allowed].sort().join(", ")}], got '${route.method}'`
4697
+ });
4420
4698
  }
4421
- return entries.filter((e) => {
4422
- if (!e.isDirectory() || !existsSync16(join18(dir, e.name, "main.tf"))) return false;
4423
- return declaredVariables(join18(dir, e.name)).has("enabled_plugins");
4424
- }).map((e) => e.name).sort();
4425
- }
4426
- function listUnwirableEnvironments(cwd) {
4427
- const dir = join18(cwd, "infra", "environments");
4428
- let entries;
4429
- try {
4430
- entries = readdirSync9(dir, { withFileTypes: true });
4431
- } catch {
4432
- return [];
4699
+ const hasId = route.path.includes("{id}");
4700
+ const needsId = SINGLE_ROW_OPERATIONS.has(route.operation);
4701
+ if (needsId && !hasId) {
4702
+ ctx.addIssue({
4703
+ code: z5.ZodIssueCode.custom,
4704
+ message: `operation '${route.operation}' addresses a single row and requires an '{id}' path parameter: ${route.path}`
4705
+ });
4433
4706
  }
4434
- return entries.filter(
4435
- (e) => e.isDirectory() && existsSync16(join18(dir, e.name, "main.tf")) && !declaredVariables(join18(dir, e.name)).has("enabled_plugins")
4436
- ).map((e) => e.name).sort();
4437
- }
4438
- function declaredVariables(moduleDir) {
4439
- const names = /* @__PURE__ */ new Set();
4440
- let entries;
4441
- try {
4442
- entries = readdirSync9(moduleDir, { withFileTypes: true });
4443
- } catch {
4444
- return names;
4707
+ if (!needsId && hasId) {
4708
+ ctx.addIssue({
4709
+ code: z5.ZodIssueCode.custom,
4710
+ message: `operation '${route.operation}' is collection-level and must not have an '{id}' path parameter: ${route.path}`
4711
+ });
4445
4712
  }
4446
- for (const entry of entries) {
4447
- if (!entry.isFile() || !entry.name.endsWith(".tf")) continue;
4448
- let contents;
4449
- try {
4450
- contents = readFileSync13(join18(moduleDir, entry.name), "utf8");
4451
- } catch {
4452
- continue;
4453
- }
4454
- for (const match of contents.matchAll(/^\s*variable\s+"([^"]+)"/gm)) {
4455
- names.add(match[1]);
4713
+ });
4714
+ var PluginManifestSchema = z5.object({
4715
+ name: z5.string().regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase kebab-case slug"),
4716
+ version: z5.string().regex(/^\d+\.\d+\.\d+$/, "must be a full semver, e.g. 1.2.3"),
4717
+ description: z5.string().default(""),
4718
+ author: z5.string().default("Biffo Team"),
4719
+ tags: z5.array(z5.string()).default([]),
4720
+ tables: z5.array(TableDefinitionSchema).default([]),
4721
+ api_routes: z5.array(RouteDefSchema).default([]),
4722
+ // Events the plugin reacts to. Parsed (rather than dropped as an unknown
4723
+ // key) so `biffo plugin install` can warn when a plugin declares
4724
+ // subscriptions but ships no terraform/ to route them — see #194 and
4725
+ // lib/plugin-terraform-guard.ts. Kept loose deliberately: the authoritative
4726
+ // schema is the registry's, and this consumer only needs to count them.
4727
+ event_subscriptions: z5.array(z5.object({ source: z5.string(), detail_type: z5.string() }).passthrough()).default([]),
4728
+ required_core_version: z5.string().default(">=0.0.0")
4729
+ }).superRefine((manifest, ctx) => {
4730
+ const tableNames = new Set(manifest.tables.map((t) => t.name));
4731
+ for (const route of manifest.api_routes) {
4732
+ if (!tableNames.has(route.table)) {
4733
+ ctx.addIssue({
4734
+ code: z5.ZodIssueCode.custom,
4735
+ message: `Route ${route.method} ${route.path} references table '${route.table}', which is not declared in this manifest's 'tables' (${[...tableNames].sort().join(", ") || "none"})`
4736
+ });
4456
4737
  }
4457
4738
  }
4458
- return names;
4739
+ });
4740
+ function validateManifest(raw) {
4741
+ const result = PluginManifestSchema.safeParse(raw);
4742
+ if (!result.success) {
4743
+ const messages = result.error.issues.map((issue) => {
4744
+ const path = issue.path.join(".");
4745
+ return path ? `${path}: ${issue.message}` : issue.message;
4746
+ }).join("; ");
4747
+ throw new Error(messages);
4748
+ }
4749
+ return result.data;
4459
4750
  }
4460
- function renderArguments(args, indent) {
4461
- const width = Math.max(...args.map(([key]) => key.length));
4462
- return args.map(([key, value]) => `${indent}${key.padEnd(width)} = ${value}`).join("\n");
4751
+
4752
+ // src/lib/plugin-scaffold.ts
4753
+ import {
4754
+ copyFileSync,
4755
+ existsSync as existsSync16,
4756
+ mkdirSync as mkdirSync7,
4757
+ readFileSync as readFileSync13,
4758
+ readdirSync as readdirSync9,
4759
+ writeFileSync as writeFileSync7
4760
+ } from "fs";
4761
+ import { dirname as dirname7, join as join17 } from "path";
4762
+ var STANDALONE_ONLY_ENTRIES = {
4763
+ ".github": "standalone-repo CI/release workflows \u2014 the host monorepo already runs lint/type/test/security over services/",
4764
+ "registry-schema.json": "the plugin-registry publishing schema, used when submitting a *published* plugin to the registry repo, not by an in-tree plugin"
4765
+ };
4766
+ var NEVER_COPY = /* @__PURE__ */ new Set([
4767
+ ".git",
4768
+ ".venv",
4769
+ "node_modules",
4770
+ "__pycache__",
4771
+ ".ruff_cache",
4772
+ ".pytest_cache",
4773
+ ".mypy_cache",
4774
+ "dist",
4775
+ "uv.lock",
4776
+ ".DS_Store"
4777
+ ]);
4778
+ var PLUGIN_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
4779
+ function deriveNames(slug) {
4780
+ if (!PLUGIN_NAME_PATTERN.test(slug)) {
4781
+ throw new Error(
4782
+ `Invalid plugin name '${slug}'. Expected a lowercase kebab-case slug starting with a letter, e.g. 'acme-crm' (this is what biffo.plugin.json's 'name' field accepts, per registry-schema.json).`
4783
+ );
4784
+ }
4785
+ const parts = slug.split("-");
4786
+ return {
4787
+ slug,
4788
+ pkg: parts.join("_"),
4789
+ pascal: parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""),
4790
+ dist: `biffo-plugin-${slug}`
4791
+ };
4463
4792
  }
4464
- function renderModuleBlock(pluginName, declared, handler) {
4465
- const args = standardArguments(pluginName, handler).filter(([key]) => declared.has(key));
4466
- const quoted = JSON.stringify(pluginName);
4793
+ function substitutions(names) {
4467
4794
  return [
4468
- `module "plugin_${pluginName}" {`,
4469
- ` source = "../../../modules/plugins/${pluginName}"`,
4470
- ` for_each = contains(var.enabled_plugins, ${quoted}) ? { ${quoted} = true } : {}`,
4471
- "",
4472
- renderArguments(args, " "),
4473
- "}",
4474
- "",
4475
- `output "plugin_${pluginName}_function_arn" {`,
4476
- ` description = "Lambda ARN of the ${pluginName} plugin, or null when it is not in enabled_plugins."`,
4477
- ` value = try(module.plugin_${pluginName}[${quoted}].function_arn, null)`,
4478
- "}"
4479
- ].join("\n");
4480
- }
4481
- var GENERATED_HEADER = `# ---------------------------------------------------------------------------
4482
- # GENERATED FILE \u2014 DO NOT EDIT BY HAND.
4483
- #
4484
- # Written by \`biffo plugin install\` / \`biffo plugin uninstall\` (issue #201),
4485
- # regenerated in full from the contents of modules/plugins/. Any manual edit is
4486
- # lost on the next plugin install or uninstall.
4487
- #
4488
- # Terraform loads every *.tf file in this directory, so these blocks are as
4489
- # live as anything in main.tf \u2014 they simply live in a CLI-owned file so the
4490
- # CLI never has to rewrite your hand-authored main.tf.
4491
- #
4492
- # Terraform requires a module's \`source\` to be a static string literal, so it
4493
- # cannot loop over var.enabled_plugins; hence one explicit block per plugin,
4494
- # each gated on membership in enabled_plugins (supplied by the generated
4495
- # ${GENERATED_TFVARS_FILE} alongside this file).
4496
- #
4497
- # Not generated here: the Core API's BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST
4498
- # (ADR-0009). It lives in main.tf and is derived from var.enabled_plugins as a
4499
- # static role-name glob \u2014 deriving it from a plugin module's role_arn output
4500
- # would create the cycle core_api -> api_gateway -> plugin -> core_api.
4501
- # ---------------------------------------------------------------------------
4502
- `;
4503
- function renderGeneratedTerraform(plugins) {
4504
- const blocks = plugins.map(
4505
- (p) => renderModuleBlock(p.name, p.declaredVariables, p.handler ?? DEFAULT_PLUGIN_HANDLER)
4506
- );
4507
- return `${GENERATED_HEADER}
4508
- ${blocks.join("\n\n")}
4509
- `;
4795
+ [/biffo-plugin-example/g, names.dist],
4796
+ [/ExamplePlugin/g, `${names.pascal}Plugin`],
4797
+ // The example table. Table names are global in the instance's database, so
4798
+ // leaving every scaffolded plugin with `example_widgets` would have the
4799
+ // second one collide with the first at migration time.
4800
+ [/example_widgets/g, `${names.pkg}_widgets`],
4801
+ [/example_plugin/g, names.pkg],
4802
+ [/example-plugin/g, names.slug]
4803
+ ];
4510
4804
  }
4511
- function renderGeneratedTfvars(pluginNames) {
4512
- return `${JSON.stringify({ enabled_plugins: pluginNames }, null, 2)}
4513
- `;
4805
+ function applySubstitutions(text, names) {
4806
+ let out = text;
4807
+ for (const [pattern, replacement] of substitutions(names)) {
4808
+ out = out.replace(pattern, replacement);
4809
+ }
4810
+ return out;
4514
4811
  }
4515
- function syncPluginTerraform(cwd) {
4516
- const plugins = listPluginModules(cwd);
4517
- const environments = listEnvironments(cwd);
4518
- const skippedEnvironments = listUnwirableEnvironments(cwd);
4519
- const changedPaths = [];
4520
- const rendered = plugins.map((name) => ({
4521
- name,
4522
- declaredVariables: declaredVariables(join18(cwd, "modules", "plugins", name))
4523
- }));
4524
- for (const env of environments) {
4525
- const envDir = join18(cwd, "infra", "environments", env);
4526
- const tfPath = join18(envDir, GENERATED_TF_FILE);
4527
- const tfvarsPath = join18(envDir, GENERATED_TFVARS_FILE);
4528
- const relBase = `infra/environments/${env}`;
4529
- if (plugins.length === 0) {
4530
- for (const [abs, rel] of [
4531
- [tfPath, `${relBase}/${GENERATED_TF_FILE}`],
4532
- [tfvarsPath, `${relBase}/${GENERATED_TFVARS_FILE}`]
4533
- ]) {
4534
- if (existsSync16(abs)) {
4535
- rmSync5(abs);
4536
- changedPaths.push(rel);
4537
- }
4812
+ var BINARY_EXTENSIONS = /\.(png|jpe?g|gif|ico|woff2?|ttf|zip|gz)$/i;
4813
+ function scaffoldPlugin(skeletonRoot, destDir, names) {
4814
+ if (!existsSync16(skeletonRoot)) {
4815
+ throw new Error(`Plugin skeleton not found at ${skeletonRoot}`);
4816
+ }
4817
+ if (!existsSync16(join17(skeletonRoot, "terraform"))) {
4818
+ throw new Error(
4819
+ `Plugin skeleton at ${skeletonRoot} has no terraform/ directory. Refusing to scaffold a plugin that cannot receive events (issue #194) \u2014 the skeleton is broken.`
4820
+ );
4821
+ }
4822
+ const skipped = [];
4823
+ const files = [];
4824
+ const walk = (relDir) => {
4825
+ const absDir = join17(skeletonRoot, relDir);
4826
+ for (const entry of readdirSync9(absDir, { withFileTypes: true }).sort(
4827
+ (a, b) => a.name.localeCompare(b.name)
4828
+ )) {
4829
+ if (NEVER_COPY.has(entry.name)) continue;
4830
+ if (relDir === "" && entry.name in STANDALONE_ONLY_ENTRIES) {
4831
+ skipped.push({ entry: entry.name, reason: STANDALONE_ONLY_ENTRIES[entry.name] });
4832
+ continue;
4538
4833
  }
4539
- continue;
4834
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
4835
+ if (entry.isDirectory()) {
4836
+ walk(relPath);
4837
+ continue;
4838
+ }
4839
+ const destRel = applySubstitutions(relPath, names);
4840
+ const destPath = join17(destDir, destRel);
4841
+ mkdirSync7(dirname7(destPath), { recursive: true });
4842
+ if (BINARY_EXTENSIONS.test(entry.name)) {
4843
+ copyFileSync(join17(skeletonRoot, relPath), destPath);
4844
+ } else {
4845
+ writeFileSync7(
4846
+ destPath,
4847
+ applySubstitutions(readFileSync13(join17(skeletonRoot, relPath), "utf8"), names)
4848
+ );
4849
+ }
4850
+ files.push(destRel);
4540
4851
  }
4541
- mkdirSync6(envDir, { recursive: true });
4542
- writeFileSync6(tfPath, renderGeneratedTerraform(rendered));
4543
- writeFileSync6(tfvarsPath, renderGeneratedTfvars(plugins));
4544
- changedPaths.push(`${relBase}/${GENERATED_TF_FILE}`, `${relBase}/${GENERATED_TFVARS_FILE}`);
4852
+ };
4853
+ walk("");
4854
+ if (!files.some((f) => f.startsWith("terraform/"))) {
4855
+ throw new Error(
4856
+ `Scaffold produced no terraform/ files from ${skeletonRoot} \u2014 refusing to leave a plugin whose event subscriptions could never fire (issue #194).`
4857
+ );
4858
+ }
4859
+ return { files: files.sort(), skipped };
4860
+ }
4861
+ function findSkeletonRoot(startDir, skeleton) {
4862
+ let dir = startDir;
4863
+ for (; ; ) {
4864
+ const candidate = join17(dir, "_skeletons", skeleton);
4865
+ if (existsSync16(candidate)) return candidate;
4866
+ const parent = dirname7(dir);
4867
+ if (parent === dir) return null;
4868
+ dir = parent;
4545
4869
  }
4546
- return { plugins, environments, skippedEnvironments, changedPaths };
4547
4870
  }
4548
4871
 
4549
- // src/commands/plugin-install.ts
4550
- var TARGET_PATTERN = /^([a-z][a-z0-9-]*)@(\d+\.\d+)$/;
4551
- var pluginInstallCommand = new Command14("install").description(
4552
- "Install a plugin from the Biffo plugin registry (biffo plugin install <name>@<minor>) or from a local directory (biffo plugin install --local <path>)"
4553
- ).argument("[target]", "Plugin name and minor version, e.g. rbac@1.0 (omit when using --local)").option(
4554
- "--local <path>",
4555
- "Install from a local, unpublished plugin directory instead of the registry"
4556
- ).option("--dry-run", "Resolve the plugin and print planned changes without modifying the repo").option("--cwd <path>", "Project root to install into (defaults to the current directory)").action(
4557
- async (target, options) => {
4872
+ // src/commands/plugin-create.ts
4873
+ var pluginCreateCommand = new Command13("create").description("Scaffold a new plugin from the Biffo plugin skeleton: biffo plugin create <name>").argument("<name>", "Plugin name \u2014 lowercase kebab-case, e.g. acme-crm").option(
4874
+ "--first-party",
4875
+ "Scaffold into the template-owned services/_plugins/ carve-out. Only valid in the biffo-template repo itself \u2014 see notes."
4876
+ ).option(
4877
+ "--skeleton <path>",
4878
+ "Path to the plugin skeleton (defaults to _skeletons/plugin-template)"
4879
+ ).option("--dry-run", "Print planned changes without modifying the repo").option("--no-commit", "Scaffold the files but leave them uncommitted").option("--cwd <path>", "Project root to scaffold into (defaults to the current directory)").action(
4880
+ async (name, options) => {
4558
4881
  const cwd = options.cwd ? resolve11(options.cwd) : process.cwd();
4559
4882
  try {
4560
- await runPluginInstall(
4561
- target,
4883
+ await runPluginCreate(
4884
+ name,
4562
4885
  {
4563
- ...options.local ? { local: resolve11(options.local) } : {},
4886
+ firstParty: options.firstParty ?? false,
4887
+ ...options.skeleton ? { skeletonRoot: resolve11(options.skeleton) } : {},
4564
4888
  dryRun: options.dryRun ?? false,
4889
+ commit: options.commit !== false,
4565
4890
  cwd
4566
4891
  },
4567
- {
4568
- registry: new RegistryAdapter(),
4569
- git: new GitAdapter(),
4570
- migrations: new PluginMigrationsAdapter()
4571
- }
4892
+ { git: new GitAdapter() }
4572
4893
  );
4573
4894
  } catch (err) {
4574
4895
  log.error(err.message);
@@ -4576,384 +4897,468 @@ var pluginInstallCommand = new Command14("install").description(
4576
4897
  }
4577
4898
  }
4578
4899
  );
4579
- var LOCAL_COPY_EXCLUDES = /* @__PURE__ */ new Set([
4580
- ".git",
4581
- ".venv",
4582
- "node_modules",
4583
- "__pycache__",
4584
- ".ruff_cache",
4585
- ".pytest_cache",
4586
- ".mypy_cache",
4587
- "dist",
4588
- ".terraform"
4589
- ]);
4590
- function resolveLocalPlugin(localPath) {
4591
- if (!existsSync17(localPath)) {
4592
- throw new Error(`--local path does not exist: ${localPath}`);
4593
- }
4594
- if (!statSync4(localPath).isDirectory()) {
4595
- throw new Error(`--local path is not a directory: ${localPath}`);
4596
- }
4597
- const manifestPath = join19(localPath, "biffo.plugin.json");
4598
- if (!existsSync17(manifestPath)) {
4599
- throw new Error(
4600
- `${localPath} does not contain a biffo.plugin.json manifest at its root \u2014 is it a plugin directory? (Scaffold one with \`biffo plugin create <name>\`.)`
4601
- );
4602
- }
4603
- const manifest = validateManifest(parseManifestFile(manifestPath));
4604
- return {
4605
- name: manifest.name,
4606
- version: manifest.version,
4607
- manifest,
4608
- sourceDir: localPath,
4609
- origin: localPath,
4610
- cleanup: () => {
4611
- }
4612
- };
4613
- }
4614
- function parsePluginTarget(target) {
4615
- const match = TARGET_PATTERN.exec(target);
4616
- if (!match) {
4617
- throw new Error(`Invalid target '${target}'. Expected format: <name>@<minor>, e.g. rbac@1.0`);
4618
- }
4619
- return { name: match[1], minor: match[2] };
4620
- }
4621
- async function cloneAndValidatePlugin(entry, git) {
4622
- const tmpDir = await git.cloneToTemp(entry.repo, `biffo-plugin-${entry.name}`);
4623
- try {
4624
- const manifestPath = join19(tmpDir, "biffo.plugin.json");
4625
- if (!existsSync17(manifestPath)) {
4626
- throw new Error(
4627
- `Plugin repo ${entry.repo} does not contain a biffo.plugin.json manifest at its root.`
4628
- );
4629
- }
4630
- const manifest = validateManifest(parseManifestFile(manifestPath));
4631
- if (manifest.name !== entry.name) {
4632
- throw new Error(
4633
- `Manifest name '${manifest.name}' in ${entry.repo} does not match the registry entry '${entry.name}'.`
4634
- );
4635
- }
4636
- return { tmpDir, manifest };
4637
- } catch (err) {
4638
- git.cleanup(tmpDir);
4639
- throw err;
4640
- }
4641
- }
4642
- async function runPluginInstall(target, options, deps) {
4643
- if (options.local && target) {
4644
- throw new Error(
4645
- `Pass either a registry target (<name>@<minor>) or --local <path>, not both. --local installs an unpublished plugin from disk and has no registry entry to resolve.`
4646
- );
4647
- }
4648
- if (!options.local && !target) {
4900
+ async function runPluginCreate(name, options, deps) {
4901
+ const names = deriveNames(name);
4902
+ const isInstance = existsSync17(join18(options.cwd, INSTANCE_CORE_FILE));
4903
+ if (options.firstParty && isInstance) {
4649
4904
  throw new Error(
4650
- `Nothing to install. Pass a registry target (e.g. \`biffo plugin install acme-crm@1.0\`) or a local plugin directory (\`biffo plugin install --local services/acme-crm\`).`
4905
+ `--first-party scaffolds into services/_plugins/, which is template-owned: \`biffo core upgrade\` three-way-merges it against the template on every upgrade, and the template has no '${names.slug}'. This checkout is a Biffo instance (${INSTANCE_CORE_FILE} is present), so your plugin belongs in the user-owned ${pluginDir(names.slug, "third-party")}/ \u2014 re-run without --first-party.`
4651
4906
  );
4652
4907
  }
4653
- const servicesDir = join19(options.cwd, "services");
4908
+ const channel = options.firstParty ? "first-party" : "third-party";
4909
+ const relDir = pluginDir(names.slug, channel);
4910
+ const destDir = join18(options.cwd, relDir);
4911
+ const servicesDir = join18(options.cwd, "services");
4654
4912
  if (!existsSync17(servicesDir)) {
4655
4913
  throw new Error(
4656
4914
  `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
4657
4915
  );
4658
4916
  }
4659
- let source;
4660
- let entry = null;
4661
- if (options.local) {
4662
- source = resolveLocalPlugin(options.local);
4663
- log.success(`Resolved ${source.name}@${source.version} from ${source.origin}`);
4664
- } else {
4665
- const { name, minor } = parsePluginTarget(target);
4666
- log.info(`Resolving ${name}@${minor} from the plugin registry...`);
4667
- entry = await deps.registry.resolvePlugin(name, minor);
4668
- log.success(`Resolved ${entry.name}@${entry.version} \u2014 ${entry.repo}`);
4917
+ if (existsSync17(destDir)) {
4918
+ throw new Error(`${relDir}/ already exists. Choose a different name, or remove it first.`);
4669
4919
  }
4670
- const pluginName = entry ? entry.name : source.name;
4671
- const relTargetDir = pluginDir(pluginName, "third-party");
4672
- const targetDir = join19(options.cwd, relTargetDir);
4673
- const modulesDir = join19(options.cwd, "modules", "plugins", pluginName);
4674
- const inTreeSource = options.local !== void 0 && resolve11(options.local) === resolve11(targetDir);
4675
- if (existsSync17(targetDir) && !inTreeSource) {
4920
+ const here = dirname8(fileURLToPath4(import.meta.url));
4921
+ const skeletonRoot = options.skeletonRoot ?? findSkeletonRoot(here, "plugin-template") ?? join18(options.cwd, "_skeletons", "plugin-template");
4922
+ if (!existsSync17(skeletonRoot)) {
4676
4923
  throw new Error(
4677
- `Plugin '${pluginName}' is already installed at ${relTargetDir}/. Remove it first, or wait for a future 'biffo plugin upgrade' command.`
4924
+ `Could not find the plugin skeleton (_skeletons/plugin-template/). Pass --skeleton <path> to point at it explicitly.`
4678
4925
  );
4679
4926
  }
4680
4927
  if (options.dryRun) {
4681
- printDryRun3(entry, source, relTargetDir, inTreeSource);
4928
+ printDryRun3(names, relDir, skeletonRoot, channel);
4682
4929
  return;
4683
4930
  }
4684
- const isRepo = await deps.git.isGitRepo(options.cwd);
4685
- if (!isRepo) {
4931
+ const { files, skipped } = scaffoldPlugin(skeletonRoot, destDir, names);
4932
+ log.success(`Scaffolded ${files.length} file(s) into ${relDir}/`);
4933
+ for (const { entry, reason } of skipped) {
4934
+ log.info(`Skipped ${entry} \u2014 ${reason}`);
4935
+ }
4936
+ const manifestPath = join18(destDir, "biffo.plugin.json");
4937
+ const manifest = validateManifest(JSON.parse(readFileSync14(manifestPath, "utf8")));
4938
+ if (manifest.name !== names.slug) {
4686
4939
  throw new Error(
4687
- `${options.cwd} is not a git repository \u2014 biffo plugin install must be run from a Biffo project checkout.`
4940
+ `Scaffolded manifest declares name '${manifest.name}', expected '${names.slug}'. The skeleton's manifest name may have diverged from 'example-plugin'.`
4688
4941
  );
4689
4942
  }
4690
- if (entry) {
4691
- log.info(`Cloning ${entry.repo}...`);
4692
- const cloned = await cloneAndValidatePlugin(entry, deps.git);
4693
- source = {
4694
- name: entry.name,
4695
- version: entry.version,
4696
- manifest: cloned.manifest,
4697
- sourceDir: cloned.tmpDir,
4698
- origin: entry.repo,
4699
- cleanup: () => deps.git.cleanup(cloned.tmpDir)
4700
- };
4701
- }
4702
- const { manifest } = source;
4703
- try {
4704
- log.success(
4705
- `Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
4706
- );
4707
- if (inTreeSource) {
4708
- log.info(`${relTargetDir}/ is already in this checkout \u2014 installing in place.`);
4709
- } else {
4710
- mkdirSync7(targetDir, { recursive: true });
4711
- cpSync2(source.sourceDir, targetDir, {
4712
- recursive: true,
4713
- filter: (src) => !LOCAL_COPY_EXCLUDES.has(basename(src))
4714
- });
4715
- log.success(`Installed plugin source at ${relTargetDir}/`);
4716
- }
4717
- const stagePaths = [relTargetDir];
4718
- const tfSourceDir = join19(targetDir, "terraform");
4719
- if (existsSync17(tfSourceDir)) {
4720
- mkdirSync7(modulesDir, { recursive: true });
4721
- cpSync2(tfSourceDir, modulesDir, { recursive: true });
4722
- stagePaths.push(`modules/plugins/${pluginName}`);
4723
- log.success(`Copied Terraform module to modules/plugins/${pluginName}/`);
4724
- const wiring = syncPluginTerraform(options.cwd);
4725
- stagePaths.push(...wiring.changedPaths);
4726
- if (wiring.environments.length > 0) {
4727
- log.success(
4728
- `Wired module "plugin_${pluginName}" and enabled_plugins into ${wiring.environments.length} environment(s): ${wiring.environments.join(", ")}`
4729
- );
4730
- log.info(
4731
- "The Core API allowlist (ADR-0009) follows automatically \u2014 local.plugin_service_principal_arns in main.tf derives it from enabled_plugins."
4732
- );
4733
- } else {
4734
- log.warn(
4735
- "No wirable infra/environments/*/ root config found, so the Terraform module was copied but not wired into any environment."
4736
- );
4737
- }
4738
- if (wiring.skippedEnvironments.length > 0) {
4739
- log.warn(
4740
- `Skipped ${wiring.skippedEnvironments.join(", ")} \u2014 no \`enabled_plugins\` variable declared there. infra/ is user-owned, so \`biffo core upgrade\` cannot add it: copy the variable (and local.plugin_service_principal_arns) from the template\u2019s infra/environments/dev/ and re-run this install to wire those environments.`
4741
- );
4742
- }
4743
- } else if (manifest.event_subscriptions.length > 0) {
4744
- log.warn(
4745
- `Plugin "${pluginName}" declares ${manifest.event_subscriptions.length} event subscription(s) but ships no terraform/ directory, so no Lambda or EventBridge rule was created \u2014 those events will never reach it. Add a terraform/ module (start from modules/plugins/_template/) and reinstall.`
4943
+ log.success(
4944
+ `Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
4945
+ );
4946
+ if (options.commit) {
4947
+ if (!await deps.git.isGitRepo(options.cwd)) {
4948
+ throw new Error(
4949
+ `${options.cwd} is not a git repository \u2014 biffo plugin create must be run from a Biffo project checkout.`
4746
4950
  );
4747
4951
  }
4748
- if (manifest.tables.length > 0) {
4749
- log.info(`Generating migration for ${relTargetDir}/'s ${manifest.tables.length} table(s)...`);
4750
- const generatedPaths = await deps.migrations.generate(options.cwd, [pluginName]);
4751
- for (const absPath of generatedPaths) {
4752
- stagePaths.push(relative3(options.cwd, absPath));
4753
- }
4754
- if (generatedPaths.length > 0) {
4755
- log.success(`Generated migration: ${relative3(options.cwd, generatedPaths[0])}`);
4756
- }
4757
- } else {
4758
- log.info(`${pluginName} declares no tables \u2014 nothing to migrate.`);
4759
- }
4760
- const commitMessage = `feat(plugins): install ${pluginName}@${source.version}`;
4761
- await deps.git.add(options.cwd, stagePaths);
4952
+ const commitMessage = `feat(plugins): scaffold ${names.slug} plugin`;
4953
+ await deps.git.add(options.cwd, [relDir]);
4762
4954
  await deps.git.commit(options.cwd, commitMessage);
4763
4955
  log.success(`Committed: ${commitMessage}`);
4764
- console.log(chalk14.bold("\n Plugin installed!\n"));
4765
- console.log(` ${pluginName}@${source.version} is committed at ${relTargetDir}/`);
4766
- console.log(" Push and redeploy to apply its migration and register its routes:");
4767
- console.log(chalk14.dim(` git push`));
4768
- console.log(chalk14.dim(` biffo deploy <environment> --app-only
4769
- `));
4770
- } finally {
4771
- source.cleanup();
4772
4956
  }
4957
+ printNextSteps(names, relDir, channel);
4773
4958
  }
4774
- function parseManifestFile(path) {
4775
- try {
4776
- return JSON.parse(readFileSync14(path, "utf8"));
4777
- } catch (err) {
4778
- throw new Error(`Could not parse ${path} as JSON: ${err.message}`);
4959
+ function printDryRun3(names, relDir, skeletonRoot, channel) {
4960
+ console.log(chalk13.bold("\n Dry run \u2014 no changes will be made\n"));
4961
+ console.log(` Plugin: ${names.slug}`);
4962
+ console.log(` Channel: ${channel}`);
4963
+ console.log(` Would scaffold: ${relDir}/`);
4964
+ console.log(` From skeleton: ${skeletonRoot}`);
4965
+ console.log(` Python package: ${names.pkg} (dist: ${names.dist})`);
4966
+ console.log(` Would commit: feat(plugins): scaffold ${names.slug} plugin
4967
+ `);
4968
+ }
4969
+ function printNextSteps(names, relDir, channel) {
4970
+ console.log(chalk13.bold("\n Plugin scaffolded!\n"));
4971
+ console.log(` ${relDir}/ contains a working example: one table, four CRUD routes,`);
4972
+ console.log(` one event subscription, and a terraform/ module for its Lambda.
4973
+ `);
4974
+ console.log(" Next:");
4975
+ console.log(chalk13.dim(` 1. Edit ${relDir}/biffo.plugin.json \u2014 your tables and routes`));
4976
+ console.log(chalk13.dim(` 2. Edit ${relDir}/src/${names.pkg}/plugin.py \u2014 your event handlers`));
4977
+ console.log(chalk13.dim(` 3. biffo plugin install --local ${relDir}`));
4978
+ console.log(
4979
+ chalk13.dim(" (copies terraform/ into modules/plugins/, generates the migration)\n")
4980
+ );
4981
+ if (channel === "first-party") {
4982
+ log.warn(
4983
+ `${relDir}/ is template-owned: it will be distributed to every instance by \`biffo core upgrade\`, and bumping core.version is required for it (ADR-0006).`
4984
+ );
4779
4985
  }
4780
4986
  }
4781
- function printDryRun3(entry, source, relTargetDir, inTreeSource) {
4782
- const name = entry ? entry.name : source.name;
4783
- const version = entry ? entry.version : source.version;
4784
- console.log(chalk14.bold("\n Dry run \u2014 no changes will be made\n"));
4785
- console.log(` Plugin: ${name}@${version}`);
4786
- if (entry) {
4787
- console.log(` Source repo: ${entry.repo}`);
4788
- console.log(` Would clone into: ${relTargetDir}/`);
4789
- } else {
4790
- console.log(` Local source: ${source.origin}`);
4791
- console.log(
4792
- inTreeSource ? ` Already in tree at ${relTargetDir}/ \u2014 would install in place (no copy)` : ` Would copy into: ${relTargetDir}/`
4793
- );
4987
+
4988
+ // src/commands/plugin-info.ts
4989
+ import chalk14 from "chalk";
4990
+ import { Command as Command14 } from "commander";
4991
+
4992
+ // src/adapters/registry/index.ts
4993
+ import { z as z6 } from "zod";
4994
+ var RegistryPluginEntrySchema = z6.object({
4995
+ name: z6.string().regex(/^[a-z][a-z0-9-]*$/),
4996
+ version: z6.string().regex(/^\d+\.\d+\.\d+$/),
4997
+ minor_version: z6.string().regex(/^\d+\.\d+$/),
4998
+ repo: z6.string().url(),
4999
+ description: z6.string().optional(),
5000
+ author: z6.string().optional(),
5001
+ tags: z6.array(z6.string()).optional(),
5002
+ required_core_version: z6.string().optional(),
5003
+ infra_modules: z6.array(z6.string()).optional(),
5004
+ api_routes: z6.array(z6.string()).optional(),
5005
+ ui_components: z6.array(z6.string()).optional(),
5006
+ status: z6.enum(["active", "disabled"])
5007
+ });
5008
+ var PluginRegistrySchema = z6.object({
5009
+ schema_version: z6.string(),
5010
+ last_updated: z6.string(),
5011
+ plugins: z6.array(RegistryPluginEntrySchema)
5012
+ });
5013
+ var DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/keiranholloway/biffo-plugins-registry/main/plugins.json";
5014
+ var RegistryAdapter = class {
5015
+ registryUrl;
5016
+ constructor(registryUrl) {
5017
+ this.registryUrl = registryUrl ?? process.env["BIFFO_REGISTRY_URL"] ?? DEFAULT_REGISTRY_URL;
4794
5018
  }
4795
- if (!entry || entry.infra_modules && entry.infra_modules.length > 0) {
4796
- console.log(
4797
- ` Would copy Terraform module into: modules/plugins/${name}/ (if the plugin has one)`
4798
- );
4799
- console.log(
4800
- ` Would wire module "plugin_${name}" + enabled_plugins into infra/environments/*/plugins.generated.tf and plugins.auto.tfvars.json`
4801
- );
5019
+ /** Fetches and validates plugins.json from the registry. */
5020
+ async fetchRegistry() {
5021
+ let response;
5022
+ try {
5023
+ response = await fetch(this.registryUrl);
5024
+ } catch (err) {
5025
+ throw new Error(
5026
+ `Could not reach the plugin registry at ${this.registryUrl}: ${err.message}`
5027
+ );
5028
+ }
5029
+ if (!response.ok) {
5030
+ throw new Error(
5031
+ `Plugin registry returned ${response.status} ${response.statusText} (${this.registryUrl})`
5032
+ );
5033
+ }
5034
+ let raw;
5035
+ try {
5036
+ raw = await response.json();
5037
+ } catch (err) {
5038
+ throw new Error(
5039
+ `Plugin registry at ${this.registryUrl} did not return valid JSON: ${err.message}`
5040
+ );
5041
+ }
5042
+ const result = PluginRegistrySchema.safeParse(raw);
5043
+ if (!result.success) {
5044
+ const messages = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
5045
+ throw new Error(
5046
+ `Plugin registry at ${this.registryUrl} has an invalid shape: ${messages.join("; ")}`
5047
+ );
5048
+ }
5049
+ return result.data;
4802
5050
  }
4803
- if (source && source.manifest.tables.length > 0) {
4804
- console.log(
4805
- ` Would generate a migration for ${source.manifest.tables.length} table(s) into services/api/migrations/versions/`
4806
- );
5051
+ /**
5052
+ * Resolves `name@minorVersion` (e.g. "rbac", "1.0") against the registry.
5053
+ *
5054
+ * The registry stores one entry per plugin — its current release — not a
5055
+ * full version history, so "resolving the latest patch for a minor"
5056
+ * degenerates to an exact match against that single entry's
5057
+ * `minor_version`. True multi-version history isn't representable in the
5058
+ * registry schema today; see PR description for this known limitation.
5059
+ */
5060
+ async resolvePlugin(name, minorVersion) {
5061
+ const registry = await this.fetchRegistry();
5062
+ const candidates = registry.plugins.filter((p) => p.name === name);
5063
+ if (candidates.length === 0) {
5064
+ throw new Error(`Plugin '${name}' was not found in the registry (${this.registryUrl}).`);
5065
+ }
5066
+ const match = candidates.find((p) => p.minor_version === minorVersion);
5067
+ if (!match) {
5068
+ const available = candidates.map((p) => `${p.name}@${p.minor_version} (${p.status})`);
5069
+ throw new Error(
5070
+ `No version matching '${name}@${minorVersion}' found in the registry. Available: ${available.join(", ")}`
5071
+ );
5072
+ }
5073
+ if (match.status !== "active") {
5074
+ throw new Error(
5075
+ `Plugin '${name}@${minorVersion}' is disabled in the registry and cannot be installed.`
5076
+ );
5077
+ }
5078
+ return match;
4807
5079
  }
4808
- console.log(` Would commit: feat(plugins): install ${name}@${version}
4809
- `);
4810
- }
5080
+ };
4811
5081
 
4812
- // src/commands/plugin-list.ts
4813
- import { existsSync as existsSync18, readFileSync as readFileSync15 } from "fs";
4814
- import { join as join20, resolve as resolve12 } from "path";
4815
- import chalk15 from "chalk";
4816
- import { Command as Command15 } from "commander";
4817
- var pluginListCommand = new Command15("list").description("List plugins installed in this project checkout").option("--cwd <path>", "Project root to scan (defaults to the current directory)").action(async (options) => {
4818
- const cwd = options.cwd ? resolve12(options.cwd) : process.cwd();
5082
+ // src/commands/plugin-info.ts
5083
+ var pluginInfoCommand = new Command14("info").description("Show registry details for a plugin: biffo plugin info <name>").argument("<name>", "Plugin name").action(async (name) => {
4819
5084
  try {
4820
- await runPluginList({ cwd });
5085
+ await runPluginInfo(name, { registry: new RegistryAdapter() });
4821
5086
  } catch (err) {
4822
5087
  log.error(err.message);
4823
5088
  process.exit(1);
4824
5089
  }
4825
5090
  });
4826
- async function runPluginList(options) {
4827
- const servicesDir = join20(options.cwd, "services");
4828
- if (!existsSync18(servicesDir)) {
4829
- throw new Error(
4830
- `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
4831
- );
4832
- }
4833
- const plugins = [];
4834
- for (const location of findInstalledPlugins(options.cwd)) {
4835
- try {
4836
- const manifest = validateManifest(JSON.parse(readFileSync15(location.manifestPath, "utf8")));
4837
- plugins.push({
4838
- name: manifest.name,
4839
- version: manifest.version,
4840
- location: location.relDir
4841
- });
4842
- } catch (err) {
4843
- log.warn(
4844
- `Skipping ${location.relDir}/biffo.plugin.json \u2014 invalid manifest: ${err.message}`
4845
- );
4846
- }
5091
+ async function runPluginInfo(name, deps) {
5092
+ const registry = await deps.registry.fetchRegistry();
5093
+ const matches = registry.plugins.filter((p) => p.name === name);
5094
+ if (matches.length === 0) {
5095
+ throw new Error(`Plugin '${name}' was not found in the registry.`);
4847
5096
  }
4848
- if (plugins.length === 0) {
4849
- console.log(chalk15.dim("\n No plugins installed in this checkout.\n"));
4850
- return;
5097
+ for (const entry of matches) {
5098
+ printEntry(entry);
4851
5099
  }
4852
- const nameWidth = Math.max(...plugins.map((p) => p.name.length), "NAME".length);
4853
- const versionWidth = Math.max(...plugins.map((p) => p.version.length), "VERSION".length);
4854
- console.log(chalk15.bold(`
4855
- Installed plugins (${plugins.length})
5100
+ }
5101
+ function printEntry(entry) {
5102
+ console.log(chalk14.bold(`
5103
+ ${entry.name}@${entry.version}
4856
5104
  `));
4857
- console.log(` ${"NAME".padEnd(nameWidth)} ${"VERSION".padEnd(versionWidth)} LOCATION`);
4858
- for (const plugin of plugins) {
4859
- console.log(
4860
- ` ${plugin.name.padEnd(nameWidth)} ${plugin.version.padEnd(versionWidth)} ${plugin.location}`
4861
- );
5105
+ console.log(` Status: ${entry.status}`);
5106
+ console.log(` Minor version channel: ${entry.minor_version}`);
5107
+ console.log(` Repo: ${entry.repo}`);
5108
+ if (entry.description) console.log(` Description: ${entry.description}`);
5109
+ if (entry.author) console.log(` Author: ${entry.author}`);
5110
+ if (entry.tags?.length) console.log(` Tags: ${entry.tags.join(", ")}`);
5111
+ if (entry.required_core_version) {
5112
+ console.log(` Required core version: ${entry.required_core_version}`);
4862
5113
  }
4863
- console.log(
4864
- chalk15.dim(
4865
- `
4866
- Note: no "status" / "last-updated" columns \u2014 the CLI tracks no installed-plugin registry locally. Once pushed and deployed, live status is available from the running Core API's GET /admin/plugins/available.
4867
- `
4868
- )
4869
- );
5114
+ if (entry.infra_modules?.length) {
5115
+ console.log(` Infra modules: ${entry.infra_modules.join(", ")}`);
5116
+ }
5117
+ if (entry.api_routes?.length) {
5118
+ console.log(` API routes: ${entry.api_routes.join(", ")}`);
5119
+ }
5120
+ if (entry.ui_components?.length) {
5121
+ console.log(` UI components: ${entry.ui_components.join(", ")}`);
5122
+ }
5123
+ console.log("");
4870
5124
  }
4871
5125
 
4872
- // src/commands/plugin-sync-migrations.ts
4873
- import { existsSync as existsSync19 } from "fs";
4874
- import { join as join21, relative as relative4, resolve as resolve13 } from "path";
4875
- import chalk16 from "chalk";
4876
- import { Command as Command16 } from "commander";
4877
- var pluginSyncMigrationsCommand = new Command16("sync-migrations").description(
4878
- "Generate real, committed migration file(s) for installed-but-not-yet-migrated plugin(s): biffo plugin sync-migrations [name]"
4879
- ).argument("[name]", "Restrict to this installed plugin (default: every plugin under services/)").option("--dry-run", "Generate nothing; just report what would be generated").option("--no-commit", "Generate and stage the file(s) but do not commit").option("--cwd <path>", "Project root (defaults to the current directory)").action(
4880
- async (name, options) => {
4881
- const cwd = options.cwd ? resolve13(options.cwd) : process.cwd();
5126
+ // src/commands/plugin-install.ts
5127
+ import { cpSync as cpSync3, existsSync as existsSync19, mkdirSync as mkdirSync9, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
5128
+ import { basename, join as join21, relative as relative3, resolve as resolve12 } from "path";
5129
+ import chalk15 from "chalk";
5130
+ import { Command as Command15 } from "commander";
5131
+
5132
+ // src/adapters/plugin-migrations/index.ts
5133
+ import { execa as execa3 } from "execa";
5134
+ import { join as join19 } from "path";
5135
+ var PluginMigrationsAdapter = class {
5136
+ /**
5137
+ * Generates migration file(s) for `pluginNames` (every discovered
5138
+ * installed plugin if omitted), returning the absolute path of each newly
5139
+ * generated file — empty if every named plugin already had a migration,
5140
+ * or declared no tables.
5141
+ */
5142
+ async generate(cwd, pluginNames) {
5143
+ const scriptPath = join19(cwd, "services", "api", "scripts", "generate_plugin_migrations.py");
5144
+ const args = [
5145
+ "run",
5146
+ "python",
5147
+ scriptPath,
5148
+ "--services-root",
5149
+ join19(cwd, "services"),
5150
+ "--versions-dir",
5151
+ join19(cwd, "services", "api", "migrations", "versions")
5152
+ ];
5153
+ for (const name of pluginNames ?? []) {
5154
+ args.push("--plugin", name);
5155
+ }
5156
+ let result;
4882
5157
  try {
4883
- await runPluginSyncMigrations(
4884
- name,
4885
- { dryRun: options.dryRun ?? false, commit: options.commit, cwd },
4886
- { migrations: new PluginMigrationsAdapter(), git: new GitAdapter() }
4887
- );
5158
+ result = await execa3("uv", args, { cwd: join19(cwd, "services", "api") });
4888
5159
  } catch (err) {
4889
- log.error(err.message);
4890
- process.exit(1);
5160
+ const cause = err;
5161
+ if (cause.code === "ENOENT") {
5162
+ throw new Error(
5163
+ "biffo plugin install/upgrade/sync-migrations needs `uv` (Python) on PATH to generate a real migration file \u2014 see https://docs.astral.sh/uv/ to install it. Once installed, re-run this command (or `biffo plugin sync-migrations <name>` if services/<name>/ is already copied in)."
5164
+ );
5165
+ }
5166
+ throw new Error(
5167
+ `Failed to generate plugin migration: ${cause.stderr?.trim() || err.message}`
5168
+ );
4891
5169
  }
5170
+ return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
4892
5171
  }
4893
- );
4894
- async function runPluginSyncMigrations(name, options, deps) {
4895
- const servicesDir = join21(options.cwd, "services");
4896
- if (!existsSync19(servicesDir)) {
4897
- throw new Error(`${servicesDir} does not exist \u2014 is ${options.cwd} a Biffo project checkout?`);
5172
+ };
5173
+
5174
+ // src/lib/plugin-terraform-wiring.ts
5175
+ import { existsSync as existsSync18, mkdirSync as mkdirSync8, readFileSync as readFileSync15, readdirSync as readdirSync10, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
5176
+ import { join as join20 } from "path";
5177
+ var TEMPLATE_MODULE_DIR = "_template";
5178
+ var DEFAULT_PLUGIN_HANDLER = "src.lambda.main.handler";
5179
+ var GENERATED_TF_FILE = "plugins.generated.tf";
5180
+ var GENERATED_TFVARS_FILE = "plugins.auto.tfvars.json";
5181
+ function standardArguments(pluginName, handler) {
5182
+ return [
5183
+ ["project_name", "var.project_name"],
5184
+ ["environment", "local.environment"],
5185
+ ["plugin_name", JSON.stringify(pluginName)],
5186
+ ["handler", JSON.stringify(handler)],
5187
+ ["event_bus_name", "module.events.event_bus_name"],
5188
+ ["core_api_url", "module.api_gateway.api_endpoint"],
5189
+ ["core_api_execution_arn", "module.api_gateway.execution_arn"],
5190
+ ["tags", "local.tags"]
5191
+ ];
5192
+ }
5193
+ function listPluginModules(cwd) {
5194
+ const dir = join20(cwd, "modules", "plugins");
5195
+ let entries;
5196
+ try {
5197
+ entries = readdirSync10(dir, { withFileTypes: true });
5198
+ } catch {
5199
+ return [];
4898
5200
  }
4899
- if (name && !existsSync19(join21(servicesDir, name, "biffo.plugin.json"))) {
4900
- throw new Error(`Plugin '${name}' is not installed at services/${name}/.`);
5201
+ return entries.filter((e) => e.isDirectory() && e.name !== TEMPLATE_MODULE_DIR && !e.name.startsWith(".")).map((e) => e.name).sort();
5202
+ }
5203
+ function listEnvironments(cwd) {
5204
+ const dir = join20(cwd, "infra", "environments");
5205
+ let entries;
5206
+ try {
5207
+ entries = readdirSync10(dir, { withFileTypes: true });
5208
+ } catch {
5209
+ return [];
4901
5210
  }
4902
- if (options.dryRun) {
4903
- console.log(chalk16.bold("\n Dry run \u2014 no changes will be made\n"));
4904
- console.log(` Target: ${name ?? "all installed plugins under services/"}
4905
- `);
4906
- return;
5211
+ return entries.filter((e) => {
5212
+ if (!e.isDirectory() || !existsSync18(join20(dir, e.name, "main.tf"))) return false;
5213
+ return declaredVariables(join20(dir, e.name)).has("enabled_plugins");
5214
+ }).map((e) => e.name).sort();
5215
+ }
5216
+ function listUnwirableEnvironments(cwd) {
5217
+ const dir = join20(cwd, "infra", "environments");
5218
+ let entries;
5219
+ try {
5220
+ entries = readdirSync10(dir, { withFileTypes: true });
5221
+ } catch {
5222
+ return [];
4907
5223
  }
4908
- const generated = await deps.migrations.generate(options.cwd, name ? [name] : void 0);
4909
- if (generated.length === 0) {
4910
- log.warn(
4911
- name ? `${name} already has a committed migration (or declares no tables) \u2014 nothing to do.` : "Every installed plugin already has a committed migration (or declares no tables) \u2014 nothing to do."
4912
- );
4913
- return;
5224
+ return entries.filter(
5225
+ (e) => e.isDirectory() && existsSync18(join20(dir, e.name, "main.tf")) && !declaredVariables(join20(dir, e.name)).has("enabled_plugins")
5226
+ ).map((e) => e.name).sort();
5227
+ }
5228
+ function declaredVariables(moduleDir) {
5229
+ const names = /* @__PURE__ */ new Set();
5230
+ let entries;
5231
+ try {
5232
+ entries = readdirSync10(moduleDir, { withFileTypes: true });
5233
+ } catch {
5234
+ return names;
4914
5235
  }
4915
- const relativePaths = generated.map((p) => relative4(options.cwd, p));
4916
- for (const p of relativePaths) {
4917
- log.success(`Generated ${p}`);
5236
+ for (const entry of entries) {
5237
+ if (!entry.isFile() || !entry.name.endsWith(".tf")) continue;
5238
+ let contents;
5239
+ try {
5240
+ contents = readFileSync15(join20(moduleDir, entry.name), "utf8");
5241
+ } catch {
5242
+ continue;
5243
+ }
5244
+ for (const match of contents.matchAll(/^\s*variable\s+"([^"]+)"/gm)) {
5245
+ names.add(match[1]);
5246
+ }
4918
5247
  }
4919
- if (options.commit) {
4920
- const isRepo = await deps.git.isGitRepo(options.cwd);
4921
- if (!isRepo) {
4922
- throw new Error(`${options.cwd} is not a git repository \u2014 cannot commit.`);
5248
+ return names;
5249
+ }
5250
+ function renderArguments(args, indent) {
5251
+ const width = Math.max(...args.map(([key]) => key.length));
5252
+ return args.map(([key, value]) => `${indent}${key.padEnd(width)} = ${value}`).join("\n");
5253
+ }
5254
+ function renderModuleBlock(pluginName, declared, handler) {
5255
+ const args = standardArguments(pluginName, handler).filter(([key]) => declared.has(key));
5256
+ const quoted = JSON.stringify(pluginName);
5257
+ return [
5258
+ `module "plugin_${pluginName}" {`,
5259
+ ` source = "../../../modules/plugins/${pluginName}"`,
5260
+ ` for_each = contains(var.enabled_plugins, ${quoted}) ? { ${quoted} = true } : {}`,
5261
+ "",
5262
+ renderArguments(args, " "),
5263
+ "}",
5264
+ "",
5265
+ `output "plugin_${pluginName}_function_arn" {`,
5266
+ ` description = "Lambda ARN of the ${pluginName} plugin, or null when it is not in enabled_plugins."`,
5267
+ ` value = try(module.plugin_${pluginName}[${quoted}].function_arn, null)`,
5268
+ "}"
5269
+ ].join("\n");
5270
+ }
5271
+ var GENERATED_HEADER = `# ---------------------------------------------------------------------------
5272
+ # GENERATED FILE \u2014 DO NOT EDIT BY HAND.
5273
+ #
5274
+ # Written by \`biffo plugin install\` / \`biffo plugin uninstall\` (issue #201),
5275
+ # regenerated in full from the contents of modules/plugins/. Any manual edit is
5276
+ # lost on the next plugin install or uninstall.
5277
+ #
5278
+ # Terraform loads every *.tf file in this directory, so these blocks are as
5279
+ # live as anything in main.tf \u2014 they simply live in a CLI-owned file so the
5280
+ # CLI never has to rewrite your hand-authored main.tf.
5281
+ #
5282
+ # Terraform requires a module's \`source\` to be a static string literal, so it
5283
+ # cannot loop over var.enabled_plugins; hence one explicit block per plugin,
5284
+ # each gated on membership in enabled_plugins (supplied by the generated
5285
+ # ${GENERATED_TFVARS_FILE} alongside this file).
5286
+ #
5287
+ # Not generated here: the Core API's BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST
5288
+ # (ADR-0009). It lives in main.tf and is derived from var.enabled_plugins as a
5289
+ # static role-name glob \u2014 deriving it from a plugin module's role_arn output
5290
+ # would create the cycle core_api -> api_gateway -> plugin -> core_api.
5291
+ # ---------------------------------------------------------------------------
5292
+ `;
5293
+ function renderGeneratedTerraform(plugins) {
5294
+ const blocks = plugins.map(
5295
+ (p) => renderModuleBlock(p.name, p.declaredVariables, p.handler ?? DEFAULT_PLUGIN_HANDLER)
5296
+ );
5297
+ return `${GENERATED_HEADER}
5298
+ ${blocks.join("\n\n")}
5299
+ `;
5300
+ }
5301
+ function renderGeneratedTfvars(pluginNames) {
5302
+ return `${JSON.stringify({ enabled_plugins: pluginNames }, null, 2)}
5303
+ `;
5304
+ }
5305
+ function syncPluginTerraform(cwd) {
5306
+ const plugins = listPluginModules(cwd);
5307
+ const environments = listEnvironments(cwd);
5308
+ const skippedEnvironments = listUnwirableEnvironments(cwd);
5309
+ const changedPaths = [];
5310
+ const rendered = plugins.map((name) => ({
5311
+ name,
5312
+ declaredVariables: declaredVariables(join20(cwd, "modules", "plugins", name))
5313
+ }));
5314
+ for (const env of environments) {
5315
+ const envDir = join20(cwd, "infra", "environments", env);
5316
+ const tfPath = join20(envDir, GENERATED_TF_FILE);
5317
+ const tfvarsPath = join20(envDir, GENERATED_TFVARS_FILE);
5318
+ const relBase = `infra/environments/${env}`;
5319
+ if (plugins.length === 0) {
5320
+ for (const [abs, rel] of [
5321
+ [tfPath, `${relBase}/${GENERATED_TF_FILE}`],
5322
+ [tfvarsPath, `${relBase}/${GENERATED_TFVARS_FILE}`]
5323
+ ]) {
5324
+ if (existsSync18(abs)) {
5325
+ rmSync6(abs);
5326
+ changedPaths.push(rel);
5327
+ }
5328
+ }
5329
+ continue;
4923
5330
  }
4924
- await deps.git.add(options.cwd, relativePaths);
4925
- const label = name ?? `${String(generated.length)} plugin(s)`;
4926
- const commitMessage = `chore(plugins): sync migration(s) for ${label}`;
4927
- await deps.git.commit(options.cwd, commitMessage);
4928
- log.success(`Committed: ${commitMessage}`);
4929
- } else {
4930
- log.warn("--no-commit: generated file(s) are on disk but not staged/committed.");
5331
+ mkdirSync8(envDir, { recursive: true });
5332
+ writeFileSync8(tfPath, renderGeneratedTerraform(rendered));
5333
+ writeFileSync8(tfvarsPath, renderGeneratedTfvars(plugins));
5334
+ changedPaths.push(`${relBase}/${GENERATED_TF_FILE}`, `${relBase}/${GENERATED_TFVARS_FILE}`);
4931
5335
  }
5336
+ return { plugins, environments, skippedEnvironments, changedPaths };
4932
5337
  }
4933
5338
 
4934
- // src/commands/plugin-uninstall.ts
4935
- import { existsSync as existsSync20, readFileSync as readFileSync16, rmSync as rmSync6 } from "fs";
4936
- import { join as join22, resolve as resolve14 } from "path";
4937
- import chalk17 from "chalk";
4938
- import { Command as Command17 } from "commander";
4939
- import inquirer6 from "inquirer";
4940
- var NAME_PATTERN2 = /^[a-z][a-z0-9-]*$/;
4941
- var pluginUninstallCommand = new Command17("uninstall").description("Remove an installed plugin: biffo plugin uninstall <name>").argument("<name>", "Plugin name").option("--dry-run", "Print planned changes without modifying the repo").option("--force", "Skip the confirmation prompt").option(
4942
- "--keep-data",
4943
- "No-op today (see notes) \u2014 the CLI never drops plugin data regardless of this flag"
4944
- ).option("--cwd <path>", "Project root to uninstall from (defaults to the current directory)").action(
4945
- async (name, options) => {
4946
- const cwd = options.cwd ? resolve14(options.cwd) : process.cwd();
5339
+ // src/commands/plugin-install.ts
5340
+ var TARGET_PATTERN = /^([a-z][a-z0-9-]*)@(\d+\.\d+)$/;
5341
+ var pluginInstallCommand = new Command15("install").description(
5342
+ "Install a plugin from the Biffo plugin registry (biffo plugin install <name>@<minor>) or from a local directory (biffo plugin install --local <path>)"
5343
+ ).argument("[target]", "Plugin name and minor version, e.g. rbac@1.0 (omit when using --local)").option(
5344
+ "--local <path>",
5345
+ "Install from a local, unpublished plugin directory instead of the registry"
5346
+ ).option("--dry-run", "Resolve the plugin and print planned changes without modifying the repo").option("--cwd <path>", "Project root to install into (defaults to the current directory)").action(
5347
+ async (target, options) => {
5348
+ const cwd = options.cwd ? resolve12(options.cwd) : process.cwd();
4947
5349
  try {
4948
- await runPluginUninstall(
4949
- name,
5350
+ await runPluginInstall(
5351
+ target,
4950
5352
  {
5353
+ ...options.local ? { local: resolve12(options.local) } : {},
4951
5354
  dryRun: options.dryRun ?? false,
4952
- force: options.force ?? false,
4953
- keepData: options.keepData ?? false,
4954
5355
  cwd
4955
5356
  },
4956
- { git: new GitAdapter() }
5357
+ {
5358
+ registry: new RegistryAdapter(),
5359
+ git: new GitAdapter(),
5360
+ migrations: new PluginMigrationsAdapter()
5361
+ }
4957
5362
  );
4958
5363
  } catch (err) {
4959
5364
  log.error(err.message);
@@ -4961,889 +5366,670 @@ var pluginUninstallCommand = new Command17("uninstall").description("Remove an i
4961
5366
  }
4962
5367
  }
4963
5368
  );
4964
- async function runPluginUninstall(name, options, deps) {
4965
- if (!NAME_PATTERN2.test(name)) {
4966
- throw new Error(`Invalid plugin name '${name}'. Expected a lowercase kebab-case slug.`);
4967
- }
4968
- const servicesDir = join22(options.cwd, "services");
4969
- if (!existsSync20(servicesDir)) {
4970
- throw new Error(
4971
- `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
4972
- );
4973
- }
4974
- const targetDir = join22(servicesDir, name);
4975
- if (!existsSync20(targetDir)) {
4976
- const firstParty = join22(servicesDir, FIRST_PARTY_PLUGINS_DIR, name);
4977
- if (existsSync20(firstParty)) {
4978
- throw new Error(
4979
- `Plugin '${name}' is a first-party plugin at ${pluginDir(name, "first-party")}/, which is template-owned \u2014 \`biffo core upgrade\` would restore it on the next upgrade. Disable it instead by removing '${name}' from \`enabled_plugins\` in infra/environments/<env>/main.tf and re-applying.`
4980
- );
4981
- }
4982
- throw new Error(`Plugin '${name}' is not installed at services/${name}/.`);
4983
- }
4984
- const version = readInstalledVersion(targetDir);
4985
- const modulesDir = join22(options.cwd, "modules", "plugins", name);
4986
- const stagePaths = [`services/${name}`];
4987
- if (existsSync20(modulesDir)) {
4988
- stagePaths.push(`modules/plugins/${name}`);
4989
- }
4990
- if (options.dryRun) {
4991
- printDryRun4(name, version, stagePaths, options.keepData);
4992
- return;
5369
+ var LOCAL_COPY_EXCLUDES = /* @__PURE__ */ new Set([
5370
+ ".git",
5371
+ ".venv",
5372
+ "node_modules",
5373
+ "__pycache__",
5374
+ ".ruff_cache",
5375
+ ".pytest_cache",
5376
+ ".mypy_cache",
5377
+ "dist",
5378
+ ".terraform"
5379
+ ]);
5380
+ function resolveLocalPlugin(localPath) {
5381
+ if (!existsSync19(localPath)) {
5382
+ throw new Error(`--local path does not exist: ${localPath}`);
4993
5383
  }
4994
- if (!options.force) {
4995
- const proceed = await confirmUninstall(name, version);
4996
- if (!proceed) {
4997
- log.warn("Uninstall cancelled");
4998
- return;
4999
- }
5384
+ if (!statSync5(localPath).isDirectory()) {
5385
+ throw new Error(`--local path is not a directory: ${localPath}`);
5000
5386
  }
5001
- const isRepo = await deps.git.isGitRepo(options.cwd);
5002
- if (!isRepo) {
5387
+ const manifestPath = join21(localPath, "biffo.plugin.json");
5388
+ if (!existsSync19(manifestPath)) {
5003
5389
  throw new Error(
5004
- `${options.cwd} is not a git repository \u2014 biffo plugin uninstall must be run from a Biffo project checkout.`
5005
- );
5006
- }
5007
- rmSync6(targetDir, { recursive: true, force: true });
5008
- log.success(`Removed services/${name}/`);
5009
- if (existsSync20(modulesDir)) {
5010
- rmSync6(modulesDir, { recursive: true, force: true });
5011
- log.success(`Removed modules/plugins/${name}/`);
5012
- const wiring = syncPluginTerraform(options.cwd);
5013
- stagePaths.push(...wiring.changedPaths);
5014
- if (wiring.changedPaths.length > 0) {
5015
- log.success(
5016
- `Unwired module "plugin_${name}" and its enabled_plugins entry from ${wiring.environments.length} environment(s): ${wiring.environments.join(", ")}`
5017
- );
5018
- }
5019
- }
5020
- const label = version ? `${name}@${version}` : name;
5021
- const commitMessage = `chore(plugins): uninstall ${label}`;
5022
- await deps.git.add(options.cwd, stagePaths);
5023
- await deps.git.commit(options.cwd, commitMessage);
5024
- log.success(`Committed: ${commitMessage}`);
5025
- console.log(chalk17.bold("\n Plugin uninstalled!\n"));
5026
- console.log(` services/${name}/ has been removed and committed.`);
5027
- console.log(
5028
- " Push and redeploy so the Core API stops discovering its routes at the next db-init:"
5029
- );
5030
- console.log(chalk17.dim(` git push`));
5031
- console.log(chalk17.dim(` biffo deploy <environment> --app-only
5032
- `));
5033
- if (options.keepData) {
5034
- console.log(
5035
- chalk17.dim(
5036
- " --keep-data: no action was needed \u2014 the CLI never drops plugin tables. See notes.\n"
5037
- )
5038
- );
5039
- } else {
5040
- log.warn(
5041
- "Any tables this plugin created remain in the database, and its migration file at services/api/migrations/versions/ is NOT removed (it is a permanent historical record \u2014 see notes). Dropping tables, if desired, requires a manual Alembic migration written against the Core API."
5390
+ `${localPath} does not contain a biffo.plugin.json manifest at its root \u2014 is it a plugin directory? (Scaffold one with \`biffo plugin create <name>\`.)`
5042
5391
  );
5043
5392
  }
5044
- }
5045
- function readInstalledVersion(targetDir) {
5046
- const manifestPath = join22(targetDir, "biffo.plugin.json");
5047
- if (!existsSync20(manifestPath)) return void 0;
5048
- try {
5049
- return validateManifest(JSON.parse(readFileSync16(manifestPath, "utf8"))).version;
5050
- } catch {
5051
- return void 0;
5052
- }
5053
- }
5054
- async function confirmUninstall(name, version) {
5055
- const label = version ? `${name}@${version}` : name;
5056
- const { confirmed } = await inquirer6.prompt([
5057
- {
5058
- type: "confirm",
5059
- name: "confirmed",
5060
- message: `Remove ${chalk17.bold(label)} from services/${name}/? This cannot be undone from the CLI.`,
5061
- default: false
5393
+ const manifest = validateManifest(parseManifestFile(manifestPath));
5394
+ return {
5395
+ name: manifest.name,
5396
+ version: manifest.version,
5397
+ manifest,
5398
+ sourceDir: localPath,
5399
+ origin: localPath,
5400
+ cleanup: () => {
5062
5401
  }
5063
- ]);
5064
- return confirmed;
5402
+ };
5065
5403
  }
5066
- function printDryRun4(name, version, stagePaths, keepData) {
5067
- const label = version ? `${name}@${version}` : name;
5068
- console.log(chalk17.bold("\n Dry run \u2014 no changes will be made\n"));
5069
- console.log(` Plugin: ${label}`);
5070
- console.log(` Would remove: ${stagePaths.join(", ")}`);
5071
- console.log(
5072
- ` Would unwire: module "plugin_${name}" + its enabled_plugins entry from infra/environments/*/plugins.generated.tf`
5073
- );
5074
- console.log(` Would commit: chore(plugins): uninstall ${label}`);
5075
- console.log(
5076
- ` --keep-data: ${keepData ? "no-op \u2014 CLI never drops tables either way" : "not set \u2014 no DB action taken either way; see notes"}
5077
- `
5078
- );
5404
+ function parsePluginTarget(target) {
5405
+ const match = TARGET_PATTERN.exec(target);
5406
+ if (!match) {
5407
+ throw new Error(`Invalid target '${target}'. Expected format: <name>@<minor>, e.g. rbac@1.0`);
5408
+ }
5409
+ return { name: match[1], minor: match[2] };
5079
5410
  }
5080
-
5081
- // src/commands/plugin-upgrade.ts
5082
- import { cpSync as cpSync3, existsSync as existsSync21, mkdirSync as mkdirSync8, readFileSync as readFileSync17, rmSync as rmSync7 } from "fs";
5083
- import { join as join23, relative as relative5, resolve as resolve15 } from "path";
5084
- import chalk18 from "chalk";
5085
- import { Command as Command18 } from "commander";
5086
- import inquirer7 from "inquirer";
5087
- var pluginUpgradeCommand = new Command18("upgrade").description(
5088
- "Upgrade an installed plugin to a new minor version: biffo plugin upgrade <name>@<new-minor>"
5089
- ).argument("<target>", "Plugin name and new minor version, e.g. rbac@1.1").option("--dry-run", "Resolve the new version and print planned changes without applying them").option("--force", "Skip the confirmation prompt").option("--cwd <path>", "Project root to upgrade in (defaults to the current directory)").action(async (target, options) => {
5090
- const cwd = options.cwd ? resolve15(options.cwd) : process.cwd();
5411
+ async function cloneAndValidatePlugin(entry, git) {
5412
+ const tmpDir = await git.cloneToTemp(entry.repo, `biffo-plugin-${entry.name}`);
5091
5413
  try {
5092
- await runPluginUpgrade(
5093
- target,
5094
- { dryRun: options.dryRun ?? false, force: options.force ?? false, cwd },
5095
- {
5096
- registry: new RegistryAdapter(),
5097
- git: new GitAdapter(),
5098
- migrations: new PluginMigrationsAdapter()
5099
- }
5100
- );
5414
+ const manifestPath = join21(tmpDir, "biffo.plugin.json");
5415
+ if (!existsSync19(manifestPath)) {
5416
+ throw new Error(
5417
+ `Plugin repo ${entry.repo} does not contain a biffo.plugin.json manifest at its root.`
5418
+ );
5419
+ }
5420
+ const manifest = validateManifest(parseManifestFile(manifestPath));
5421
+ if (manifest.name !== entry.name) {
5422
+ throw new Error(
5423
+ `Manifest name '${manifest.name}' in ${entry.repo} does not match the registry entry '${entry.name}'.`
5424
+ );
5425
+ }
5426
+ return { tmpDir, manifest };
5101
5427
  } catch (err) {
5102
- log.error(err.message);
5103
- process.exit(1);
5428
+ git.cleanup(tmpDir);
5429
+ throw err;
5104
5430
  }
5105
- });
5106
- async function runPluginUpgrade(target, options, deps) {
5107
- const { name, minor } = parsePluginTarget(target);
5108
- const servicesDir = join23(options.cwd, "services");
5109
- if (!existsSync21(servicesDir)) {
5431
+ }
5432
+ async function runPluginInstall(target, options, deps) {
5433
+ if (options.local && target) {
5110
5434
  throw new Error(
5111
- `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
5435
+ `Pass either a registry target (<name>@<minor>) or --local <path>, not both. --local installs an unpublished plugin from disk and has no registry entry to resolve.`
5112
5436
  );
5113
5437
  }
5114
- const targetDir = join23(servicesDir, name);
5115
- if (!existsSync21(targetDir)) {
5438
+ if (!options.local && !target) {
5116
5439
  throw new Error(
5117
- `Plugin '${name}' is not installed at services/${name}/. Use 'biffo plugin install ${name}@${minor}' instead.`
5440
+ `Nothing to install. Pass a registry target (e.g. \`biffo plugin install acme-crm@1.0\`) or a local plugin directory (\`biffo plugin install --local services/acme-crm\`).`
5118
5441
  );
5119
5442
  }
5120
- const currentVersion = readInstalledVersion2(targetDir);
5121
- log.info(`Resolving ${name}@${minor} from the plugin registry...`);
5122
- const entry = await deps.registry.resolvePlugin(name, minor);
5123
- log.success(`Resolved ${entry.name}@${entry.version} \u2014 ${entry.repo}`);
5124
- if (entry.required_core_version) {
5125
- log.warn(
5126
- `Plugin declares required_core_version '${entry.required_core_version}'. The CLI cannot verify this against your deployment \u2014 the Core API exposes no version endpoint and services/api/pyproject.toml's version is a static placeholder, not a real release marker. Confirm compatibility yourself before deploying.`
5443
+ const servicesDir = join21(options.cwd, "services");
5444
+ if (!existsSync19(servicesDir)) {
5445
+ throw new Error(
5446
+ `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
5127
5447
  );
5128
5448
  }
5129
- const modulesDir = join23(options.cwd, "modules", "plugins", entry.name);
5130
- if (options.dryRun) {
5131
- printDryRun5(entry, currentVersion);
5132
- return;
5449
+ let source;
5450
+ let entry = null;
5451
+ if (options.local) {
5452
+ source = resolveLocalPlugin(options.local);
5453
+ log.success(`Resolved ${source.name}@${source.version} from ${source.origin}`);
5454
+ } else {
5455
+ const { name, minor } = parsePluginTarget(target);
5456
+ log.info(`Resolving ${name}@${minor} from the plugin registry...`);
5457
+ entry = await deps.registry.resolvePlugin(name, minor);
5458
+ log.success(`Resolved ${entry.name}@${entry.version} \u2014 ${entry.repo}`);
5133
5459
  }
5134
- if (currentVersion === entry.version) {
5135
- log.warn(`services/${name}/ is already at ${entry.version} \u2014 nothing to upgrade.`);
5136
- return;
5460
+ const pluginName = entry ? entry.name : source.name;
5461
+ const relTargetDir = pluginDir(pluginName, "third-party");
5462
+ const targetDir = join21(options.cwd, relTargetDir);
5463
+ const modulesDir = join21(options.cwd, "modules", "plugins", pluginName);
5464
+ const inTreeSource = options.local !== void 0 && resolve12(options.local) === resolve12(targetDir);
5465
+ if (existsSync19(targetDir) && !inTreeSource) {
5466
+ throw new Error(
5467
+ `Plugin '${pluginName}' is already installed at ${relTargetDir}/. Remove it first, or wait for a future 'biffo plugin upgrade' command.`
5468
+ );
5137
5469
  }
5138
- if (!options.force) {
5139
- const proceed = await confirmUpgrade(name, currentVersion, entry.version);
5140
- if (!proceed) {
5141
- log.warn("Upgrade cancelled");
5142
- return;
5143
- }
5470
+ if (options.dryRun) {
5471
+ printDryRun4(entry, source, relTargetDir, inTreeSource);
5472
+ return;
5144
5473
  }
5145
5474
  const isRepo = await deps.git.isGitRepo(options.cwd);
5146
5475
  if (!isRepo) {
5147
5476
  throw new Error(
5148
- `${options.cwd} is not a git repository \u2014 biffo plugin upgrade must be run from a Biffo project checkout.`
5477
+ `${options.cwd} is not a git repository \u2014 biffo plugin install must be run from a Biffo project checkout.`
5149
5478
  );
5150
5479
  }
5151
- log.info(`Cloning ${entry.repo}...`);
5152
- const { tmpDir, manifest } = await cloneAndValidatePlugin(entry, deps.git);
5480
+ if (entry) {
5481
+ log.info(`Cloning ${entry.repo}...`);
5482
+ const cloned = await cloneAndValidatePlugin(entry, deps.git);
5483
+ source = {
5484
+ name: entry.name,
5485
+ version: entry.version,
5486
+ manifest: cloned.manifest,
5487
+ sourceDir: cloned.tmpDir,
5488
+ origin: entry.repo,
5489
+ cleanup: () => deps.git.cleanup(cloned.tmpDir)
5490
+ };
5491
+ }
5492
+ const { manifest } = source;
5153
5493
  try {
5154
5494
  log.success(
5155
5495
  `Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
5156
5496
  );
5157
- rmSync7(targetDir, { recursive: true, force: true });
5158
- mkdirSync8(targetDir, { recursive: true });
5159
- cpSync3(tmpDir, targetDir, { recursive: true });
5160
- log.success(`Upgraded plugin source at services/${entry.name}/`);
5161
- const stagePaths = [`services/${entry.name}`];
5162
- if (existsSync21(modulesDir)) {
5163
- rmSync7(modulesDir, { recursive: true, force: true });
5497
+ if (inTreeSource) {
5498
+ log.info(`${relTargetDir}/ is already in this checkout \u2014 installing in place.`);
5499
+ } else {
5500
+ mkdirSync9(targetDir, { recursive: true });
5501
+ cpSync3(source.sourceDir, targetDir, {
5502
+ recursive: true,
5503
+ filter: (src) => !LOCAL_COPY_EXCLUDES.has(basename(src))
5504
+ });
5505
+ log.success(`Installed plugin source at ${relTargetDir}/`);
5164
5506
  }
5165
- const tfSourceDir = join23(targetDir, "terraform");
5166
- if (existsSync21(tfSourceDir)) {
5167
- mkdirSync8(modulesDir, { recursive: true });
5507
+ const stagePaths = [relTargetDir];
5508
+ const tfSourceDir = join21(targetDir, "terraform");
5509
+ if (existsSync19(tfSourceDir)) {
5510
+ mkdirSync9(modulesDir, { recursive: true });
5168
5511
  cpSync3(tfSourceDir, modulesDir, { recursive: true });
5169
- stagePaths.push(`modules/plugins/${entry.name}`);
5170
- log.success(`Copied Terraform module to modules/plugins/${entry.name}/`);
5171
- }
5172
- if (manifest.tables.length > 0) {
5173
- log.info(
5174
- `Generating migration for services/${entry.name}/'s ${manifest.tables.length} table(s)...`
5175
- );
5176
- const generatedPaths = await deps.migrations.generate(options.cwd, [entry.name]);
5177
- for (const absPath of generatedPaths) {
5178
- stagePaths.push(relative5(options.cwd, absPath));
5179
- }
5180
- if (generatedPaths.length > 0) {
5181
- log.success(`Generated migration: ${relative5(options.cwd, generatedPaths[0])}`);
5182
- }
5183
- }
5184
- const label = currentVersion ? `${entry.name} ${currentVersion} -> ${entry.version}` : `${entry.name} to ${entry.version}`;
5185
- const commitMessage = `feat(plugins): upgrade ${label}`;
5186
- await deps.git.add(options.cwd, stagePaths);
5187
- await deps.git.commit(options.cwd, commitMessage);
5188
- log.success(`Committed: ${commitMessage}`);
5189
- console.log(chalk18.bold("\n Plugin upgraded!\n"));
5190
- console.log(` ${entry.name}@${entry.version} is committed at services/${entry.name}/`);
5191
- console.log(" Push and redeploy to apply its updated tables and routes:");
5192
- console.log(chalk18.dim(` git push`));
5193
- console.log(chalk18.dim(` biffo deploy <environment> --app-only
5194
- `));
5195
- } finally {
5196
- deps.git.cleanup(tmpDir);
5197
- }
5198
- }
5199
- function readInstalledVersion2(targetDir) {
5200
- const manifestPath = join23(targetDir, "biffo.plugin.json");
5201
- if (!existsSync21(manifestPath)) return void 0;
5202
- try {
5203
- return validateManifest(JSON.parse(readFileSync17(manifestPath, "utf8"))).version;
5204
- } catch {
5205
- return void 0;
5206
- }
5207
- }
5208
- async function confirmUpgrade(name, currentVersion, newVersion) {
5209
- const message = currentVersion ? `Upgrade ${chalk18.bold(name)} from ${currentVersion} to ${chalk18.bold(newVersion)}?` : `Upgrade ${chalk18.bold(name)} to ${chalk18.bold(newVersion)}?`;
5210
- const { confirmed } = await inquirer7.prompt([
5211
- { type: "confirm", name: "confirmed", message, default: false }
5212
- ]);
5213
- return confirmed;
5214
- }
5215
- function printDryRun5(entry, currentVersion) {
5216
- console.log(chalk18.bold("\n Dry run \u2014 no changes will be made\n"));
5217
- console.log(` Plugin: ${entry.name}`);
5218
- console.log(` Current: ${currentVersion ?? "(unknown \u2014 manifest unreadable)"}`);
5219
- console.log(` Would upgrade to: ${entry.version}`);
5220
- console.log(` Source repo: ${entry.repo}`);
5221
- console.log(` Would replace: services/${entry.name}/`);
5222
- if (entry.infra_modules && entry.infra_modules.length > 0) {
5223
- console.log(
5224
- ` Would replace Terraform module at: modules/plugins/${entry.name}/ (if the repo has one)`
5225
- );
5226
- }
5227
- console.log(` Would commit: feat(plugins): upgrade ${entry.name} to ${entry.version}
5228
- `);
5229
- }
5230
-
5231
- // src/commands/plugin.ts
5232
- var pluginCommand = new Command19("plugin").description("Manage Biffo plugins");
5233
- pluginCommand.addCommand(pluginCreateCommand);
5234
- pluginCommand.addCommand(pluginListCommand);
5235
- pluginCommand.addCommand(pluginInstallCommand);
5236
- pluginCommand.addCommand(pluginUninstallCommand);
5237
- pluginCommand.addCommand(pluginUpgradeCommand);
5238
- pluginCommand.addCommand(pluginSyncMigrationsCommand);
5239
- pluginCommand.addCommand(pluginInfoCommand);
5240
-
5241
- // src/commands/sibling.ts
5242
- import { Command as Command21 } from "commander";
5243
-
5244
- // src/commands/sibling-create.ts
5245
- import { cpSync as cpSync4, existsSync as existsSync23, mkdirSync as mkdirSync10, mkdtempSync as mkdtempSync4, readFileSync as readFileSync19, writeFileSync as writeFileSync8 } from "fs";
5246
- import { tmpdir as tmpdir4 } from "os";
5247
- import { dirname as dirname8, join as join25, resolve as resolve16 } from "path";
5248
- import chalk19 from "chalk";
5249
- import { Command as Command20 } from "commander";
5250
-
5251
- // src/config/sibling-schema.ts
5252
- import { z as z6 } from "zod";
5253
- var SiblingConfigSchema = z6.object({
5254
- $schema: z6.string().optional(),
5255
- project: z6.object({
5256
- name: z6.string().min(1).regex(
5257
- /^[a-z][a-z0-9-]*$/,
5258
- "Must be lowercase kebab-case, starting with a letter (it becomes a URL path segment)"
5259
- ),
5260
- description: z6.string().default(""),
5261
- // Notable routes this sibling exposes, shown as labelled links on the
5262
- // core project's Microservices tab (ADR-0007). Each `path` is relative to
5263
- // the sibling's own path_prefix (so "demo" renders as /<prefix>/demo), and
5264
- // `label` is the human name. Empty by default — a sibling with no declared
5265
- // routes just shows its single root link. Declare real routes here as you
5266
- // build the sibling's pages; the values flow to the core's
5267
- // siblings.auto.tfvars.json at registration and into siblings.json at deploy.
5268
- routes: z6.array(
5269
- z6.object({
5270
- path: z6.string().min(1).regex(
5271
- /^[a-z0-9][a-z0-9/-]*$/,
5272
- 'Sub-path relative to the sibling prefix, no leading slash (e.g. "demo" or "apply")'
5273
- ),
5274
- label: z6.string().min(1)
5275
- })
5276
- ).default([])
5277
- }),
5278
- source_control: SourceControlConfigSchema,
5279
- cloud: CloudConfigSchema,
5280
- environments: z6.array(z6.enum(["dev", "staging", "prod"])).min(1).default(["dev"]),
5281
- // The core project this sibling is paired with (ADR-0007) — never
5282
- // provisions its own Cognito pool or CloudFront distribution, always
5283
- // plugs into the core project's.
5284
- core: z6.object({
5285
- // Exactly one of these two must be set — see the superRefine below.
5286
- project_name: z6.string().min(1).optional().describe("Name of a project previously scaffolded with `biffo init` on this machine"),
5287
- config_path: z6.string().min(1).optional().describe(
5288
- "Path to the core project's biffo.config.json, for when it wasn't scaffolded here"
5289
- ),
5290
- // Defaults to project.name at parse time by the caller (sibling-create.ts),
5291
- // not here — z.object() has no access to sibling fields from within core's
5292
- // own schema without restructuring the whole object, and the caller
5293
- // already has both values in hand when it validates.
5294
- path_prefix: z6.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "Must be lowercase kebab-case").optional()
5295
- })
5296
- }).superRefine((config, ctx) => {
5297
- if (!config.core.project_name && !config.core.config_path) {
5298
- ctx.addIssue({
5299
- code: z6.ZodIssueCode.custom,
5300
- path: ["core"],
5301
- message: "Either core.project_name or core.config_path is required"
5302
- });
5512
+ stagePaths.push(`modules/plugins/${pluginName}`);
5513
+ log.success(`Copied Terraform module to modules/plugins/${pluginName}/`);
5514
+ const wiring = syncPluginTerraform(options.cwd);
5515
+ stagePaths.push(...wiring.changedPaths);
5516
+ if (wiring.environments.length > 0) {
5517
+ log.success(
5518
+ `Wired module "plugin_${pluginName}" and enabled_plugins into ${wiring.environments.length} environment(s): ${wiring.environments.join(", ")}`
5519
+ );
5520
+ log.info(
5521
+ "The Core API allowlist (ADR-0009) follows automatically \u2014 local.plugin_service_principal_arns in main.tf derives it from enabled_plugins."
5522
+ );
5523
+ } else {
5524
+ log.warn(
5525
+ "No wirable infra/environments/*/ root config found, so the Terraform module was copied but not wired into any environment."
5526
+ );
5527
+ }
5528
+ if (wiring.skippedEnvironments.length > 0) {
5529
+ log.warn(
5530
+ `Skipped ${wiring.skippedEnvironments.join(", ")} \u2014 no \`enabled_plugins\` variable declared there. infra/ is user-owned, so \`biffo core upgrade\` cannot add it: copy the variable (and local.plugin_service_principal_arns) from the template\u2019s infra/environments/dev/ and re-run this install to wire those environments.`
5531
+ );
5532
+ }
5533
+ } else if (manifest.event_subscriptions.length > 0) {
5534
+ log.warn(
5535
+ `Plugin "${pluginName}" declares ${manifest.event_subscriptions.length} event subscription(s) but ships no terraform/ directory, so no Lambda or EventBridge rule was created \u2014 those events will never reach it. Add a terraform/ module (start from modules/plugins/_template/) and reinstall.`
5536
+ );
5537
+ }
5538
+ if (manifest.tables.length > 0) {
5539
+ log.info(`Generating migration for ${relTargetDir}/'s ${manifest.tables.length} table(s)...`);
5540
+ const generatedPaths = await deps.migrations.generate(options.cwd, [pluginName]);
5541
+ for (const absPath of generatedPaths) {
5542
+ stagePaths.push(relative3(options.cwd, absPath));
5543
+ }
5544
+ if (generatedPaths.length > 0) {
5545
+ log.success(`Generated migration: ${relative3(options.cwd, generatedPaths[0])}`);
5546
+ }
5547
+ } else {
5548
+ log.info(`${pluginName} declares no tables \u2014 nothing to migrate.`);
5549
+ }
5550
+ const commitMessage = `feat(plugins): install ${pluginName}@${source.version}`;
5551
+ await deps.git.add(options.cwd, stagePaths);
5552
+ await deps.git.commit(options.cwd, commitMessage);
5553
+ log.success(`Committed: ${commitMessage}`);
5554
+ console.log(chalk15.bold("\n Plugin installed!\n"));
5555
+ console.log(` ${pluginName}@${source.version} is committed at ${relTargetDir}/`);
5556
+ console.log(" Push and redeploy to apply its migration and register its routes:");
5557
+ console.log(chalk15.dim(` git push`));
5558
+ console.log(chalk15.dim(` biffo deploy <environment> --app-only
5559
+ `));
5560
+ } finally {
5561
+ source.cleanup();
5303
5562
  }
5304
- });
5305
-
5306
- // src/lib/sibling-session.ts
5307
- import {
5308
- existsSync as existsSync22,
5309
- mkdirSync as mkdirSync9,
5310
- readdirSync as readdirSync10,
5311
- readFileSync as readFileSync18,
5312
- rmSync as rmSync8,
5313
- statSync as statSync5,
5314
- writeFileSync as writeFileSync7
5315
- } from "fs";
5316
- import { homedir as homedir3 } from "os";
5317
- import { join as join24 } from "path";
5318
- function sessionsDir2() {
5319
- return process.env["BIFFO_SIBLING_SESSIONS_DIR"] ?? join24(homedir3(), ".biffo", "sibling-sessions");
5320
- }
5321
- function sessionPath2(projectName) {
5322
- return join24(sessionsDir2(), `${projectName}.json`);
5323
5563
  }
5324
- function findLatestSiblingSession() {
5325
- const dir = sessionsDir2();
5326
- if (!existsSync22(dir)) return null;
5327
- const files = readdirSync10(dir).filter((f) => f.endsWith(".json"));
5328
- if (files.length === 0) return null;
5329
- const sorted = files.map((f) => {
5330
- const fullPath = join24(dir, f);
5331
- const mtime = existsSync22(fullPath) ? statSync5(fullPath).mtimeMs : -1;
5332
- return { f, mtime };
5333
- }).sort((a, b) => b.mtime - a.mtime);
5564
+ function parseManifestFile(path) {
5334
5565
  try {
5335
- return JSON.parse(readFileSync18(join24(dir, sorted[0].f), "utf8"));
5336
- } catch {
5337
- return null;
5566
+ return JSON.parse(readFileSync16(path, "utf8"));
5567
+ } catch (err) {
5568
+ throw new Error(`Could not parse ${path} as JSON: ${err.message}`);
5338
5569
  }
5339
5570
  }
5340
- function saveSiblingSession(session) {
5341
- const dir = sessionsDir2();
5342
- if (!existsSync22(dir)) mkdirSync9(dir, { recursive: true });
5343
- const name = session.config.project?.name ?? "unknown";
5344
- writeFileSync7(sessionPath2(name), JSON.stringify(session, null, 2));
5345
- }
5346
- function markSiblingStepComplete(session, step) {
5347
- if (!session.completedSteps.includes(step)) {
5348
- session.completedSteps.push(step);
5571
+ function printDryRun4(entry, source, relTargetDir, inTreeSource) {
5572
+ const name = entry ? entry.name : source.name;
5573
+ const version = entry ? entry.version : source.version;
5574
+ console.log(chalk15.bold("\n Dry run \u2014 no changes will be made\n"));
5575
+ console.log(` Plugin: ${name}@${version}`);
5576
+ if (entry) {
5577
+ console.log(` Source repo: ${entry.repo}`);
5578
+ console.log(` Would clone into: ${relTargetDir}/`);
5579
+ } else {
5580
+ console.log(` Local source: ${source.origin}`);
5581
+ console.log(
5582
+ inTreeSource ? ` Already in tree at ${relTargetDir}/ \u2014 would install in place (no copy)` : ` Would copy into: ${relTargetDir}/`
5583
+ );
5349
5584
  }
5350
- saveSiblingSession(session);
5351
- }
5352
- function deleteSiblingSession(projectName) {
5353
- const path = sessionPath2(projectName);
5354
- if (existsSync22(path)) rmSync8(path);
5585
+ if (!entry || entry.infra_modules && entry.infra_modules.length > 0) {
5586
+ console.log(
5587
+ ` Would copy Terraform module into: modules/plugins/${name}/ (if the plugin has one)`
5588
+ );
5589
+ console.log(
5590
+ ` Would wire module "plugin_${name}" + enabled_plugins into infra/environments/*/plugins.generated.tf and plugins.auto.tfvars.json`
5591
+ );
5592
+ }
5593
+ if (source && source.manifest.tables.length > 0) {
5594
+ console.log(
5595
+ ` Would generate a migration for ${source.manifest.tables.length} table(s) into services/api/migrations/versions/`
5596
+ );
5597
+ }
5598
+ console.log(` Would commit: feat(plugins): install ${name}@${version}
5599
+ `);
5355
5600
  }
5356
5601
 
5357
- // src/commands/sibling-create.ts
5358
- var siblingCreateCommand = new Command20("create").description(
5359
- "Create a standalone sibling app repository from the Biffo sibling template (ADR-0007)"
5360
- ).argument("<name>", "Sibling name; must match config.project.name").requiredOption("-c, --config <path>", "Path to a pre-filled biffo.sibling.json").option("--template <path>", "Path to sibling template (defaults to the bundled skeleton)").option("--dry-run", "Validate config and print planned changes without creating anything").option("--fresh", "Ignore any saved session and start from scratch").action(
5361
- async (name, options) => {
5362
- try {
5363
- assertBuildIsFresh();
5364
- await runSiblingCreateCommand(name, {
5365
- configPath: resolve16(options.config),
5366
- templateRoot: options.template ? resolve16(options.template) : defaultSiblingTemplateRoot(),
5367
- dryRun: options.dryRun === true,
5368
- fresh: options.fresh === true
5369
- });
5370
- } catch (err) {
5371
- log.error(err.message);
5372
- process.exit(1);
5373
- }
5602
+ // src/commands/plugin-list.ts
5603
+ import { existsSync as existsSync20, readFileSync as readFileSync17 } from "fs";
5604
+ import { join as join22, resolve as resolve13 } from "path";
5605
+ import chalk16 from "chalk";
5606
+ import { Command as Command16 } from "commander";
5607
+ var pluginListCommand = new Command16("list").description("List plugins installed in this project checkout").option("--cwd <path>", "Project root to scan (defaults to the current directory)").action(async (options) => {
5608
+ const cwd = options.cwd ? resolve13(options.cwd) : process.cwd();
5609
+ try {
5610
+ await runPluginList({ cwd });
5611
+ } catch (err) {
5612
+ log.error(err.message);
5613
+ process.exit(1);
5374
5614
  }
5375
- );
5376
- async function runSiblingCreateCommand(name, options) {
5377
- console.log(chalk19.bold("\n Biffo \u2014 Sibling App Creator\n"));
5378
- const config = readSiblingConfig(options.configPath);
5379
- if (config.project.name !== name) {
5615
+ });
5616
+ async function runPluginList(options) {
5617
+ const servicesDir = join22(options.cwd, "services");
5618
+ if (!existsSync20(servicesDir)) {
5380
5619
  throw new Error(
5381
- `Sibling name '${name}' does not match config project.name '${config.project.name}'.`
5620
+ `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
5382
5621
  );
5383
5622
  }
5384
- const coreConfig = resolveCoreConfig(config, options.configPath);
5385
- if (options.dryRun) {
5386
- printDryRun6(config, coreConfig, options.templateRoot);
5387
- return;
5388
- }
5389
- if (!existsSync23(options.templateRoot)) {
5390
- throw new Error(`Sibling template not found at ${options.templateRoot}`);
5391
- }
5392
- let session = null;
5393
- if (!options.fresh) {
5394
- const saved = findLatestSiblingSession();
5395
- if (saved && saved.config.project?.name === name) {
5396
- session = saved;
5397
- console.log(
5398
- chalk19.dim(
5399
- ` Resuming previous sibling create for ${name} (completed: ${saved.completedSteps.join(", ") || "none"})
5400
- `
5401
- )
5623
+ const plugins = [];
5624
+ for (const location of findInstalledPlugins(options.cwd)) {
5625
+ try {
5626
+ const manifest = validateManifest(JSON.parse(readFileSync17(location.manifestPath, "utf8")));
5627
+ plugins.push({
5628
+ name: manifest.name,
5629
+ version: manifest.version,
5630
+ location: location.relDir
5631
+ });
5632
+ } catch (err) {
5633
+ log.warn(
5634
+ `Skipping ${location.relDir}/biffo.plugin.json \u2014 invalid manifest: ${err.message}`
5402
5635
  );
5403
5636
  }
5404
5637
  }
5405
- if (!session) {
5406
- session = {
5407
- version: 1,
5408
- config,
5409
- awsAccountId: config.cloud.config.account_id,
5410
- awsRegion: config.cloud.config.region,
5411
- completedSteps: [],
5412
- outputs: {}
5413
- };
5414
- saveSiblingSession(session);
5638
+ if (plugins.length === 0) {
5639
+ console.log(chalk16.dim("\n No plugins installed in this checkout.\n"));
5640
+ return;
5415
5641
  }
5416
- const token = await resolveGithubToken3(true);
5417
- const github = new GitHubAdapter(token);
5418
- const aws = new AwsAdapter(config);
5419
- const coreAws = new AwsAdapter(coreConfig);
5420
- const git = new GitAdapter();
5421
- await runSiblingCreate(github, aws, coreAws, git, config, session, {
5422
- coreConfig,
5423
- skeletonRoot: options.templateRoot,
5424
- githubToken: token
5425
- });
5426
- const { org, repo } = githubRepo(config);
5427
- const pathPrefix = config.core.path_prefix ?? config.project.name;
5428
- log.success("\nSibling repo created successfully!");
5429
- console.log(`
5430
- Repository: https://github.com/${org}/${repo}`);
5431
- console.log(` Path: /${pathPrefix}`);
5432
- if (session.outputs.registrationPrUrl) {
5642
+ const nameWidth = Math.max(...plugins.map((p) => p.name.length), "NAME".length);
5643
+ const versionWidth = Math.max(...plugins.map((p) => p.version.length), "VERSION".length);
5644
+ console.log(chalk16.bold(`
5645
+ Installed plugins (${plugins.length})
5646
+ `));
5647
+ console.log(` ${"NAME".padEnd(nameWidth)} ${"VERSION".padEnd(versionWidth)} LOCATION`);
5648
+ for (const plugin of plugins) {
5433
5649
  console.log(
5434
- ` Registration PR (against ${coreConfig.project.name}): ${session.outputs.registrationPrUrl}`
5650
+ ` ${plugin.name.padEnd(nameWidth)} ${plugin.version.padEnd(versionWidth)} ${plugin.location}`
5435
5651
  );
5436
5652
  }
5437
5653
  console.log(
5438
- `
5439
- Next steps:
5440
- 1. Merge the registration PR above \u2014 until it merges, baseurl.com/${pathPrefix} won't route anywhere.
5441
- 2. Add a SIBLING_GITHUB_TOKEN secret to this new repo (a PAT with repo scope) \u2014 needed by its
5442
- deploy workflow to export Terraform outputs as environment variables, same as the core project.
5443
- 3. Push to \`dev\` (or run the Deploy workflow manually) to provision this sibling's own AWS resources.
5444
- 4. Once the registration PR has ALSO merged and the core project has redeployed, set
5445
- PARENT_CLOUDFRONT_DISTRIBUTION_ARN on this repo and re-run its Deploy workflow \u2014 see this
5446
- repo's README, "The two-phase CDN registration".
5654
+ chalk16.dim(
5655
+ `
5656
+ Note: no "status" / "last-updated" columns \u2014 the CLI tracks no installed-plugin registry locally. Once pushed and deployed, live status is available from the running Core API's GET /admin/plugins/available.
5447
5657
  `
5658
+ )
5448
5659
  );
5449
5660
  }
5450
- async function runSiblingCreate(github, aws, coreAws, git, config, session, options) {
5451
- const totalSteps = 7;
5452
- const { org, repo } = githubRepo(config);
5453
- const pathPrefix = config.core.path_prefix ?? config.project.name;
5454
- if (!session.completedSteps.includes("verify_credentials")) {
5455
- log.step(1, totalSteps, "Verifying AWS credentials...");
5456
- await aws.verifyCredentials();
5457
- markSiblingStepComplete(session, "verify_credentials");
5458
- } else {
5459
- log.step(1, totalSteps, "AWS credentials already verified \u2014 skipping");
5460
- }
5461
- if (!session.completedSteps.includes("resolve_core_identity")) {
5462
- log.step(2, totalSteps, "Resolving core project's identity...");
5463
- session.outputs.coreIdentity = await resolveCoreIdentity(
5464
- coreAws,
5465
- options.coreConfig,
5466
- config.environments
5467
- );
5468
- markSiblingStepComplete(session, "resolve_core_identity");
5469
- } else {
5470
- log.step(2, totalSteps, "Core identity already resolved \u2014 skipping");
5471
- }
5472
- const coreIdentity = session.outputs.coreIdentity;
5473
- if (!coreIdentity) {
5474
- throw new Error(
5475
- "internal error: resolve_core_identity did not populate session.outputs.coreIdentity"
5476
- );
5477
- }
5478
- if (!session.completedSteps.includes("create_repo")) {
5479
- log.step(3, totalSteps, "Creating GitHub repository and pushing sibling skeleton...");
5480
- const cloneUrl = await github.createEmptyRepo(
5481
- org,
5482
- repo,
5483
- config.project.description || void 0
5484
- );
5485
- session.outputs.cloneUrl = cloneUrl;
5486
- await pushSkeleton(
5487
- git,
5488
- options.skeletonRoot,
5489
- cloneUrl,
5490
- config,
5491
- options.coreConfig,
5492
- options.githubToken
5493
- );
5494
- markSiblingStepComplete(session, "create_repo");
5495
- } else {
5496
- log.step(3, totalSteps, "GitHub repository already created \u2014 skipping");
5661
+
5662
+ // src/commands/plugin-sync-migrations.ts
5663
+ import { existsSync as existsSync21 } from "fs";
5664
+ import { join as join23, relative as relative4, resolve as resolve14 } from "path";
5665
+ import chalk17 from "chalk";
5666
+ import { Command as Command17 } from "commander";
5667
+ var pluginSyncMigrationsCommand = new Command17("sync-migrations").description(
5668
+ "Generate real, committed migration file(s) for installed-but-not-yet-migrated plugin(s): biffo plugin sync-migrations [name]"
5669
+ ).argument("[name]", "Restrict to this installed plugin (default: every plugin under services/)").option("--dry-run", "Generate nothing; just report what would be generated").option("--no-commit", "Generate and stage the file(s) but do not commit").option("--cwd <path>", "Project root (defaults to the current directory)").action(
5670
+ async (name, options) => {
5671
+ const cwd = options.cwd ? resolve14(options.cwd) : process.cwd();
5672
+ try {
5673
+ await runPluginSyncMigrations(
5674
+ name,
5675
+ { dryRun: options.dryRun ?? false, commit: options.commit, cwd },
5676
+ { migrations: new PluginMigrationsAdapter(), git: new GitAdapter() }
5677
+ );
5678
+ } catch (err) {
5679
+ log.error(err.message);
5680
+ process.exit(1);
5681
+ }
5497
5682
  }
5498
- if (!session.completedSteps.includes("oidc_trust")) {
5499
- log.step(4, totalSteps, "Configuring OIDC trust...");
5500
- session.outputs.oidcRoleArn = await aws.setupOidcTrust(
5501
- config,
5502
- await resolveRepoIds(github, config)
5503
- );
5504
- markSiblingStepComplete(session, "oidc_trust");
5505
- } else {
5506
- log.step(4, totalSteps, "OIDC trust already configured \u2014 skipping");
5683
+ );
5684
+ async function runPluginSyncMigrations(name, options, deps) {
5685
+ const servicesDir = join23(options.cwd, "services");
5686
+ if (!existsSync21(servicesDir)) {
5687
+ throw new Error(`${servicesDir} does not exist \u2014 is ${options.cwd} a Biffo project checkout?`);
5507
5688
  }
5508
- if (!session.completedSteps.includes("terraform_backend")) {
5509
- log.step(5, totalSteps, "Bootstrapping Terraform state backend...");
5510
- session.outputs.tfStateBucket = await aws.bootstrapTerraformBackend(config.project.name);
5511
- markSiblingStepComplete(session, "terraform_backend");
5512
- } else {
5513
- log.step(5, totalSteps, "Terraform backend already bootstrapped \u2014 skipping");
5689
+ if (name && !existsSync21(join23(servicesDir, name, "biffo.plugin.json"))) {
5690
+ throw new Error(`Plugin '${name}' is not installed at services/${name}/.`);
5514
5691
  }
5515
- if (!session.completedSteps.includes("github_config")) {
5516
- log.step(6, totalSteps, "Configuring GitHub repository...");
5517
- await configureSiblingGithub(github, config, options.coreConfig, session, coreIdentity);
5518
- markSiblingStepComplete(session, "github_config");
5519
- } else {
5520
- log.step(6, totalSteps, "GitHub already configured \u2014 skipping");
5692
+ if (options.dryRun) {
5693
+ console.log(chalk17.bold("\n Dry run \u2014 no changes will be made\n"));
5694
+ console.log(` Target: ${name ?? "all installed plugins under services/"}
5695
+ `);
5696
+ return;
5521
5697
  }
5522
- if (!session.completedSteps.includes("register_with_core")) {
5523
- log.step(7, totalSteps, "Opening a registration PR against the core project...");
5524
- session.outputs.registrationPrUrl = await registerWithCore(
5525
- git,
5526
- github,
5527
- config,
5528
- options.coreConfig,
5529
- pathPrefix,
5530
- options.githubToken
5698
+ const generated = await deps.migrations.generate(options.cwd, name ? [name] : void 0);
5699
+ if (generated.length === 0) {
5700
+ log.warn(
5701
+ name ? `${name} already has a committed migration (or declares no tables) \u2014 nothing to do.` : "Every installed plugin already has a committed migration (or declares no tables) \u2014 nothing to do."
5531
5702
  );
5532
- markSiblingStepComplete(session, "register_with_core");
5533
- } else {
5534
- log.step(7, totalSteps, "Already registered with the core project \u2014 skipping");
5703
+ return;
5535
5704
  }
5536
- deleteSiblingSession(config.project.name);
5537
- }
5538
- function readSiblingConfig(path) {
5539
- const raw = JSON.parse(readFileSync19(path, "utf8"));
5540
- const withDefaults = raw && typeof raw === "object" && "project" in raw && "core" in raw ? {
5541
- ...raw,
5542
- core: {
5543
- ...raw.core,
5544
- path_prefix: raw.core.path_prefix ?? raw.project.name
5705
+ const relativePaths = generated.map((p) => relative4(options.cwd, p));
5706
+ for (const p of relativePaths) {
5707
+ log.success(`Generated ${p}`);
5708
+ }
5709
+ if (options.commit) {
5710
+ const isRepo = await deps.git.isGitRepo(options.cwd);
5711
+ if (!isRepo) {
5712
+ throw new Error(`${options.cwd} is not a git repository \u2014 cannot commit.`);
5545
5713
  }
5546
- } : raw;
5547
- const result = SiblingConfigSchema.safeParse(withDefaults);
5548
- if (!result.success) {
5549
- throw new Error(
5550
- "Invalid sibling configuration:\n" + result.error.issues.map((issue) => ` ${issue.path.join(".")} \u2014 ${issue.message}`).join("\n")
5551
- );
5714
+ await deps.git.add(options.cwd, relativePaths);
5715
+ const label = name ?? `${String(generated.length)} plugin(s)`;
5716
+ const commitMessage = `chore(plugins): sync migration(s) for ${label}`;
5717
+ await deps.git.commit(options.cwd, commitMessage);
5718
+ log.success(`Committed: ${commitMessage}`);
5719
+ } else {
5720
+ log.warn("--no-commit: generated file(s) are on disk but not staged/committed.");
5552
5721
  }
5553
- return result.data;
5554
5722
  }
5555
- function resolveCoreConfig(config, configPath) {
5556
- if (config.core.config_path) {
5557
- const corePath = resolve16(dirname8(configPath), config.core.config_path);
5558
- return parseCoreConfig(corePath);
5723
+
5724
+ // src/commands/plugin-uninstall.ts
5725
+ import { existsSync as existsSync22, readFileSync as readFileSync18, rmSync as rmSync7 } from "fs";
5726
+ import { join as join24, resolve as resolve15 } from "path";
5727
+ import chalk18 from "chalk";
5728
+ import { Command as Command18 } from "commander";
5729
+ import inquirer6 from "inquirer";
5730
+ var NAME_PATTERN2 = /^[a-z][a-z0-9-]*$/;
5731
+ var pluginUninstallCommand = new Command18("uninstall").description("Remove an installed plugin: biffo plugin uninstall <name>").argument("<name>", "Plugin name").option("--dry-run", "Print planned changes without modifying the repo").option("--force", "Skip the confirmation prompt").option(
5732
+ "--keep-data",
5733
+ "No-op today (see notes) \u2014 the CLI never drops plugin data regardless of this flag"
5734
+ ).option("--cwd <path>", "Project root to uninstall from (defaults to the current directory)").action(
5735
+ async (name, options) => {
5736
+ const cwd = options.cwd ? resolve15(options.cwd) : process.cwd();
5737
+ try {
5738
+ await runPluginUninstall(
5739
+ name,
5740
+ {
5741
+ dryRun: options.dryRun ?? false,
5742
+ force: options.force ?? false,
5743
+ keepData: options.keepData ?? false,
5744
+ cwd
5745
+ },
5746
+ { git: new GitAdapter() }
5747
+ );
5748
+ } catch (err) {
5749
+ log.error(err.message);
5750
+ process.exit(1);
5751
+ }
5559
5752
  }
5560
- if (config.core.project_name) {
5561
- const saved = loadProjectConfig(config.core.project_name);
5562
- if (saved) return saved;
5563
- throw new Error(
5564
- `Core project '${config.core.project_name}' was not found in ~/.biffo/projects. Set core.config_path to the core project biffo.config.json instead.`
5565
- );
5753
+ );
5754
+ async function runPluginUninstall(name, options, deps) {
5755
+ if (!NAME_PATTERN2.test(name)) {
5756
+ throw new Error(`Invalid plugin name '${name}'. Expected a lowercase kebab-case slug.`);
5566
5757
  }
5567
- throw new Error("Either core.project_name or core.config_path is required.");
5568
- }
5569
- function parseCoreConfig(path) {
5570
- const result = BiffoConfigSchema.safeParse(JSON.parse(readFileSync19(path, "utf8")));
5571
- if (!result.success) {
5758
+ const servicesDir = join24(options.cwd, "services");
5759
+ if (!existsSync22(servicesDir)) {
5572
5760
  throw new Error(
5573
- `Invalid core configuration at ${path}:
5574
- ` + result.error.issues.map((issue) => ` ${issue.path.join(".")} \u2014 ${issue.message}`).join("\n")
5761
+ `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
5575
5762
  );
5576
5763
  }
5577
- return result.data;
5578
- }
5579
- async function resolveCoreIdentity(coreAws, coreConfig, environments) {
5580
- const coreAwsConfig = coreConfig.cloud.config;
5581
- const stateBucket = coreAwsConfig.tf_state_bucket ?? `${coreConfig.project.name}-terraform-state-${coreAwsConfig.account_id}`;
5582
- const coreIdentity = {};
5583
- for (const env of environments) {
5584
- const stateKey = `${env}/terraform.tfstate`;
5585
- log.info(`Reading ${coreConfig.project.name}'s Terraform outputs for ${env}...`);
5586
- const outputs = await coreAws.readTerraformOutputs(stateBucket, stateKey);
5587
- for (const key of [
5588
- "cognito_user_pool_id",
5589
- "cognito_client_id",
5590
- "api_gateway_url",
5591
- "portal_url"
5592
- ]) {
5593
- if (!outputs[key]) {
5594
- throw new Error(
5595
- `${key} not found in ${coreConfig.project.name}'s Terraform outputs for ${env}. Has the core project been deployed to ${env}? Run \`biffo deploy ${env}\` from the core project first.`
5596
- );
5597
- }
5764
+ const targetDir = join24(servicesDir, name);
5765
+ if (!existsSync22(targetDir)) {
5766
+ const firstParty = join24(servicesDir, FIRST_PARTY_PLUGINS_DIR, name);
5767
+ if (existsSync22(firstParty)) {
5768
+ throw new Error(
5769
+ `Plugin '${name}' is a first-party plugin at ${pluginDir(name, "first-party")}/, which is template-owned \u2014 \`biffo core upgrade\` would restore it on the next upgrade. Disable it instead by removing '${name}' from \`enabled_plugins\` in infra/environments/<env>/main.tf and re-applying.`
5770
+ );
5598
5771
  }
5599
- coreIdentity[env] = {
5600
- cognitoUserPoolId: outputs["cognito_user_pool_id"],
5601
- cognitoClientId: outputs["cognito_client_id"],
5602
- apiUrl: outputs["api_gateway_url"],
5603
- portalUrl: outputs["portal_url"]
5604
- };
5605
- }
5606
- return coreIdentity;
5607
- }
5608
- async function pushSkeleton(git, skeletonRoot, cloneUrl, config, coreConfig, githubToken) {
5609
- const workDir = mkdtempSync4(join25(tmpdir4(), `biffo-sibling-${config.project.name}-`));
5610
- try {
5611
- writeSiblingTemplate(skeletonRoot, workDir, config, {
5612
- coreProjectName: coreConfig.project.name,
5613
- pathPrefix: config.core.path_prefix ?? config.project.name
5614
- });
5615
- await git.init(workDir, "main");
5616
- await git.addRemote(workDir, "origin", cloneUrl);
5617
- await git.add(workDir, ["."]);
5618
- await git.commit(workDir, `feat: scaffold ${config.project.name} sibling app (ADR-0007)`);
5619
- await git.push(workDir, "main", { token: githubToken });
5620
- } finally {
5621
- git.cleanup(workDir);
5772
+ throw new Error(`Plugin '${name}' is not installed at services/${name}/.`);
5622
5773
  }
5623
- }
5624
- function writeSiblingTemplate(templateRoot, targetDir, config, context) {
5625
- if (!existsSync23(templateRoot)) {
5626
- throw new Error(`Sibling template not found at ${templateRoot}`);
5774
+ const version = readInstalledVersion(targetDir);
5775
+ const modulesDir = join24(options.cwd, "modules", "plugins", name);
5776
+ const stagePaths = [`services/${name}`];
5777
+ if (existsSync22(modulesDir)) {
5778
+ stagePaths.push(`modules/plugins/${name}`);
5627
5779
  }
5628
- cpSync4(templateRoot, targetDir, { recursive: true });
5629
- writeFileSync8(
5630
- join25(targetDir, "biffo.sibling.json"),
5631
- JSON.stringify(
5632
- {
5633
- name: config.project.name,
5634
- core_project: context.coreProjectName,
5635
- path_prefix: context.pathPrefix,
5636
- ...config.project.description ? { description: config.project.description } : {},
5637
- // Always written (even when empty) so the field is discoverable — declare
5638
- // this sibling's notable routes here and they surface on the core's
5639
- // Microservices tab. See SiblingConfigSchema.project.routes.
5640
- routes: config.project.routes
5641
- },
5642
- null,
5643
- 2
5644
- ) + "\n"
5645
- );
5646
- const envPath = join25(targetDir, "apps", "frontend", ".env.example");
5647
- try {
5648
- const path = `/${context.pathPrefix}`;
5649
- const content = readFileSync19(envPath, "utf8").replace(/^NEXT_PUBLIC_SIBLING_NAME=.*$/m, `NEXT_PUBLIC_SIBLING_NAME=${config.project.name}`).replace(/^NEXT_PUBLIC_SIBLING_PATH_PREFIX=.*$/m, `NEXT_PUBLIC_SIBLING_PATH_PREFIX=${path}`).replace(/^NEXT_PUBLIC_BASE_PATH=.*$/m, `NEXT_PUBLIC_BASE_PATH=${path}`);
5650
- writeFileSync8(envPath, content);
5651
- } catch (err) {
5652
- if (err.code !== "ENOENT") throw err;
5780
+ if (options.dryRun) {
5781
+ printDryRun5(name, version, stagePaths, options.keepData);
5782
+ return;
5653
5783
  }
5654
- }
5655
- async function configureSiblingGithub(github, config, coreConfig, session, coreIdentity) {
5656
- const { org, repo } = githubRepo(config);
5657
- await github.createBranch(org, repo, "dev", "main");
5658
- await github.createBranch(org, repo, "staging", "main");
5659
- await github.setDefaultBranch(org, repo, "dev");
5660
- await github.configureBranchProtection(config);
5661
- await github.createEnvironments(config);
5662
- await github.enableVulnerabilityAlerts(org, repo);
5663
- await github.setRepoVariable(org, repo, "PROJECT_NAME", config.project.name);
5664
- await github.setRepoVariable(
5665
- org,
5666
- repo,
5667
- "PATH_PREFIX",
5668
- config.core.path_prefix ?? config.project.name
5669
- );
5670
- await github.setRepoVariable(org, repo, "AWS_REGION", awsConfig(config).region);
5671
- await github.setRepoVariable(org, repo, "SIBLING_DEPLOY_ENABLED", "true");
5672
- try {
5673
- const { org: coreOrg, repo: coreRepo } = coreConfig.source_control.config;
5674
- const runnerLabel = await github.getRepoVariable(coreOrg, coreRepo, "RUNNER_LABEL");
5675
- if (runnerLabel && runnerLabel.trim()) {
5676
- await github.setRepoVariable(org, repo, "RUNNER_LABEL", runnerLabel);
5784
+ if (!options.force) {
5785
+ const proceed = await confirmUninstall(name, version);
5786
+ if (!proceed) {
5787
+ log.warn("Uninstall cancelled");
5788
+ return;
5677
5789
  }
5678
- } catch (err) {
5679
- log.warn(
5680
- `Could not propagate RUNNER_LABEL from the core project to ${org}/${repo}: ${err.message}. The sibling will default to ubuntu-latest runners.`
5790
+ }
5791
+ const isRepo = await deps.git.isGitRepo(options.cwd);
5792
+ if (!isRepo) {
5793
+ throw new Error(
5794
+ `${options.cwd} is not a git repository \u2014 biffo plugin uninstall must be run from a Biffo project checkout.`
5681
5795
  );
5682
5796
  }
5683
- if (session.outputs.tfStateBucket) {
5684
- await github.setRepoVariable(org, repo, "TF_STATE_BUCKET", session.outputs.tfStateBucket);
5797
+ rmSync7(targetDir, { recursive: true, force: true });
5798
+ log.success(`Removed services/${name}/`);
5799
+ if (existsSync22(modulesDir)) {
5800
+ rmSync7(modulesDir, { recursive: true, force: true });
5801
+ log.success(`Removed modules/plugins/${name}/`);
5802
+ const wiring = syncPluginTerraform(options.cwd);
5803
+ stagePaths.push(...wiring.changedPaths);
5804
+ if (wiring.changedPaths.length > 0) {
5805
+ log.success(
5806
+ `Unwired module "plugin_${name}" and its enabled_plugins entry from ${wiring.environments.length} environment(s): ${wiring.environments.join(", ")}`
5807
+ );
5808
+ }
5685
5809
  }
5686
- for (const env of config.environments) {
5687
- const identity = coreIdentity[env];
5688
- if (!identity) continue;
5689
- await github.setEnvVariable(
5690
- org,
5691
- repo,
5692
- env,
5693
- "CORE_COGNITO_USER_POOL_ID",
5694
- identity.cognitoUserPoolId
5810
+ const label = version ? `${name}@${version}` : name;
5811
+ const commitMessage = `chore(plugins): uninstall ${label}`;
5812
+ await deps.git.add(options.cwd, stagePaths);
5813
+ await deps.git.commit(options.cwd, commitMessage);
5814
+ log.success(`Committed: ${commitMessage}`);
5815
+ console.log(chalk18.bold("\n Plugin uninstalled!\n"));
5816
+ console.log(` services/${name}/ has been removed and committed.`);
5817
+ console.log(
5818
+ " Push and redeploy so the Core API stops discovering its routes at the next db-init:"
5819
+ );
5820
+ console.log(chalk18.dim(` git push`));
5821
+ console.log(chalk18.dim(` biffo deploy <environment> --app-only
5822
+ `));
5823
+ if (options.keepData) {
5824
+ console.log(
5825
+ chalk18.dim(
5826
+ " --keep-data: no action was needed \u2014 the CLI never drops plugin tables. See notes.\n"
5827
+ )
5695
5828
  );
5696
- await github.setEnvVariable(org, repo, env, "CORE_COGNITO_CLIENT_ID", identity.cognitoClientId);
5697
- await github.setEnvVariable(org, repo, env, "CORE_API_URL", identity.apiUrl);
5698
- await github.setEnvVariable(org, repo, env, "CORE_PORTAL_URL", identity.portalUrl);
5699
- await github.setEnvVariable(
5700
- org,
5701
- repo,
5702
- env,
5703
- "CORS_ORIGINS_JSON",
5704
- JSON.stringify([identity.portalUrl])
5829
+ } else {
5830
+ log.warn(
5831
+ "Any tables this plugin created remain in the database, and its migration file at services/api/migrations/versions/ is NOT removed (it is a permanent historical record \u2014 see notes). Dropping tables, if desired, requires a manual Alembic migration written against the Core API."
5705
5832
  );
5706
5833
  }
5707
- if (session.outputs.oidcRoleArn) {
5708
- await github.setRepoSecret(org, repo, "SIBLING_OIDC_ROLE_ARN", session.outputs.oidcRoleArn);
5834
+ }
5835
+ function readInstalledVersion(targetDir) {
5836
+ const manifestPath = join24(targetDir, "biffo.plugin.json");
5837
+ if (!existsSync22(manifestPath)) return void 0;
5838
+ try {
5839
+ return validateManifest(JSON.parse(readFileSync18(manifestPath, "utf8"))).version;
5840
+ } catch {
5841
+ return void 0;
5709
5842
  }
5710
5843
  }
5711
- function bucketRegionalDomain(bucketName, region) {
5712
- return region === "us-east-1" ? `${bucketName}.s3.amazonaws.com` : `${bucketName}.s3.${region}.amazonaws.com`;
5844
+ async function confirmUninstall(name, version) {
5845
+ const label = version ? `${name}@${version}` : name;
5846
+ const { confirmed } = await inquirer6.prompt([
5847
+ {
5848
+ type: "confirm",
5849
+ name: "confirmed",
5850
+ message: `Remove ${chalk18.bold(label)} from services/${name}/? This cannot be undone from the CLI.`,
5851
+ default: false
5852
+ }
5853
+ ]);
5854
+ return confirmed;
5713
5855
  }
5714
- function siteBucketName(projectName, environment, accountId) {
5715
- return `${projectName}-${environment}-site-${accountId}`;
5856
+ function printDryRun5(name, version, stagePaths, keepData) {
5857
+ const label = version ? `${name}@${version}` : name;
5858
+ console.log(chalk18.bold("\n Dry run \u2014 no changes will be made\n"));
5859
+ console.log(` Plugin: ${label}`);
5860
+ console.log(` Would remove: ${stagePaths.join(", ")}`);
5861
+ console.log(
5862
+ ` Would unwire: module "plugin_${name}" + its enabled_plugins entry from infra/environments/*/plugins.generated.tf`
5863
+ );
5864
+ console.log(` Would commit: chore(plugins): uninstall ${label}`);
5865
+ console.log(
5866
+ ` --keep-data: ${keepData ? "no-op \u2014 CLI never drops tables either way" : "not set \u2014 no DB action taken either way; see notes"}
5867
+ `
5868
+ );
5716
5869
  }
5717
- function readExistingSiblingOrigins(filePath) {
5870
+
5871
+ // src/commands/plugin-upgrade.ts
5872
+ import { cpSync as cpSync4, existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync19, rmSync as rmSync8 } from "fs";
5873
+ import { join as join25, relative as relative5, resolve as resolve16 } from "path";
5874
+ import chalk19 from "chalk";
5875
+ import { Command as Command19 } from "commander";
5876
+ import inquirer7 from "inquirer";
5877
+ var pluginUpgradeCommand = new Command19("upgrade").description(
5878
+ "Upgrade an installed plugin to a new minor version: biffo plugin upgrade <name>@<new-minor>"
5879
+ ).argument("<target>", "Plugin name and new minor version, e.g. rbac@1.1").option("--dry-run", "Resolve the new version and print planned changes without applying them").option("--force", "Skip the confirmation prompt").option("--cwd <path>", "Project root to upgrade in (defaults to the current directory)").action(async (target, options) => {
5880
+ const cwd = options.cwd ? resolve16(options.cwd) : process.cwd();
5718
5881
  try {
5719
- return JSON.parse(readFileSync19(filePath, "utf8"));
5882
+ await runPluginUpgrade(
5883
+ target,
5884
+ { dryRun: options.dryRun ?? false, force: options.force ?? false, cwd },
5885
+ {
5886
+ registry: new RegistryAdapter(),
5887
+ git: new GitAdapter(),
5888
+ migrations: new PluginMigrationsAdapter()
5889
+ }
5890
+ );
5720
5891
  } catch (err) {
5721
- if (err.code === "ENOENT") return {};
5722
- throw err;
5892
+ log.error(err.message);
5893
+ process.exit(1);
5723
5894
  }
5724
- }
5725
- function assertCoreSupportsSiblingRouting(cloneDir, coreRepo) {
5726
- const cdnVarsPath = join25(cloneDir, "modules", "cloud", "aws", "cdn", "variables.tf");
5727
- let declaresSiblingOrigins = false;
5728
- try {
5729
- declaresSiblingOrigins = /variable\s+"sibling_origins"/.test(readFileSync19(cdnVarsPath, "utf8"));
5730
- } catch {
5731
- declaresSiblingOrigins = false;
5895
+ });
5896
+ async function runPluginUpgrade(target, options, deps) {
5897
+ const { name, minor } = parsePluginTarget(target);
5898
+ const servicesDir = join25(options.cwd, "services");
5899
+ if (!existsSync23(servicesDir)) {
5900
+ throw new Error(
5901
+ `${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
5902
+ );
5732
5903
  }
5733
- if (!declaresSiblingOrigins) {
5904
+ const targetDir = join25(servicesDir, name);
5905
+ if (!existsSync23(targetDir)) {
5734
5906
  throw new Error(
5735
- `The core project "${coreRepo}" doesn't support sibling CDN routing yet (its modules/cloud/aws/cdn predates ADR-0007). Run \`biffo core upgrade\` against it first, then re-run \`biffo sibling create\`.`
5907
+ `Plugin '${name}' is not installed at services/${name}/. Use 'biffo plugin install ${name}@${minor}' instead.`
5736
5908
  );
5737
5909
  }
5738
- }
5739
- async function registerWithCore(git, github, config, coreConfig, pathPrefix, githubToken) {
5740
- const { org: coreOrg, repo: coreRepo } = coreConfig.source_control.config;
5741
- const coreCloneUrl = `https://github.com/${coreOrg}/${coreRepo}.git`;
5742
- const coreAwsRegion = awsConfig(coreConfig).region;
5743
- const siblingAccountId = config.cloud.config.account_id;
5744
- const cloneDir = await git.cloneForEditing(
5745
- coreCloneUrl,
5746
- `biffo-sibling-register-${config.project.name}`,
5747
- githubToken
5748
- );
5749
- try {
5750
- assertCoreSupportsSiblingRouting(cloneDir, coreRepo);
5751
- const base = await git.currentBranch(cloneDir);
5752
- const branch = `biffo/register-sibling-${config.project.name}`.replace(/[^a-zA-Z0-9._/-]/g, "-");
5753
- await git.createBranch(cloneDir, branch);
5754
- const touchedFiles = [];
5755
- for (const env of config.environments) {
5756
- const bucketName = siteBucketName(config.project.name, env, siblingAccountId);
5757
- const domain = bucketRegionalDomain(bucketName, coreAwsRegion);
5758
- const relativePath = join25("infra", "environments", env, "siblings.auto.tfvars.json");
5759
- const filePath = join25(cloneDir, relativePath);
5760
- const existing = readExistingSiblingOrigins(filePath);
5761
- const siblings = (existing.sibling_origins ?? []).filter((s) => s.name !== pathPrefix);
5762
- siblings.push({
5763
- name: pathPrefix,
5764
- bucket_regional_domain: domain,
5765
- ...config.project.description ? { description: config.project.description } : {},
5766
- ...config.project.routes.length > 0 ? { routes: config.project.routes } : {}
5767
- });
5768
- mkdirSync10(dirname8(filePath), { recursive: true });
5769
- writeFileSync8(filePath, JSON.stringify({ sibling_origins: siblings }, null, 2) + "\n");
5770
- touchedFiles.push(relativePath);
5910
+ const currentVersion = readInstalledVersion2(targetDir);
5911
+ log.info(`Resolving ${name}@${minor} from the plugin registry...`);
5912
+ const entry = await deps.registry.resolvePlugin(name, minor);
5913
+ log.success(`Resolved ${entry.name}@${entry.version} \u2014 ${entry.repo}`);
5914
+ if (entry.required_core_version) {
5915
+ log.warn(
5916
+ `Plugin declares required_core_version '${entry.required_core_version}'. The CLI cannot verify this against your deployment \u2014 the Core API exposes no version endpoint and services/api/pyproject.toml's version is a static placeholder, not a real release marker. Confirm compatibility yourself before deploying.`
5917
+ );
5918
+ }
5919
+ const modulesDir = join25(options.cwd, "modules", "plugins", entry.name);
5920
+ if (options.dryRun) {
5921
+ printDryRun6(entry, currentVersion);
5922
+ return;
5923
+ }
5924
+ if (currentVersion === entry.version) {
5925
+ log.warn(`services/${name}/ is already at ${entry.version} \u2014 nothing to upgrade.`);
5926
+ return;
5927
+ }
5928
+ if (!options.force) {
5929
+ const proceed = await confirmUpgrade(name, currentVersion, entry.version);
5930
+ if (!proceed) {
5931
+ log.warn("Upgrade cancelled");
5932
+ return;
5771
5933
  }
5772
- await git.add(cloneDir, touchedFiles);
5773
- await git.commit(
5774
- cloneDir,
5775
- `infra(cdn): register sibling "${pathPrefix}" for path-based routing (ADR-0007)`
5934
+ }
5935
+ const isRepo = await deps.git.isGitRepo(options.cwd);
5936
+ if (!isRepo) {
5937
+ throw new Error(
5938
+ `${options.cwd} is not a git repository \u2014 biffo plugin upgrade must be run from a Biffo project checkout.`
5776
5939
  );
5777
- await git.push(cloneDir, branch, { token: githubToken });
5778
- const remoteUrl = await git.getRemoteUrl(cloneDir);
5779
- const { owner, repo } = parseGitHubRepo(remoteUrl);
5780
- const pr = await github.createPullRequest({
5781
- owner,
5782
- repo,
5783
- head: branch,
5784
- base,
5785
- title: `Register sibling "${pathPrefix}" for CDN routing (ADR-0007)`,
5786
- body: buildRegistrationPrBody(config, pathPrefix, touchedFiles)
5787
- });
5788
- return pr.url;
5940
+ }
5941
+ log.info(`Cloning ${entry.repo}...`);
5942
+ const { tmpDir, manifest } = await cloneAndValidatePlugin(entry, deps.git);
5943
+ try {
5944
+ log.success(
5945
+ `Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
5946
+ );
5947
+ rmSync8(targetDir, { recursive: true, force: true });
5948
+ mkdirSync10(targetDir, { recursive: true });
5949
+ cpSync4(tmpDir, targetDir, { recursive: true });
5950
+ log.success(`Upgraded plugin source at services/${entry.name}/`);
5951
+ const stagePaths = [`services/${entry.name}`];
5952
+ if (existsSync23(modulesDir)) {
5953
+ rmSync8(modulesDir, { recursive: true, force: true });
5954
+ }
5955
+ const tfSourceDir = join25(targetDir, "terraform");
5956
+ if (existsSync23(tfSourceDir)) {
5957
+ mkdirSync10(modulesDir, { recursive: true });
5958
+ cpSync4(tfSourceDir, modulesDir, { recursive: true });
5959
+ stagePaths.push(`modules/plugins/${entry.name}`);
5960
+ log.success(`Copied Terraform module to modules/plugins/${entry.name}/`);
5961
+ }
5962
+ if (manifest.tables.length > 0) {
5963
+ log.info(
5964
+ `Generating migration for services/${entry.name}/'s ${manifest.tables.length} table(s)...`
5965
+ );
5966
+ const generatedPaths = await deps.migrations.generate(options.cwd, [entry.name]);
5967
+ for (const absPath of generatedPaths) {
5968
+ stagePaths.push(relative5(options.cwd, absPath));
5969
+ }
5970
+ if (generatedPaths.length > 0) {
5971
+ log.success(`Generated migration: ${relative5(options.cwd, generatedPaths[0])}`);
5972
+ }
5973
+ }
5974
+ const label = currentVersion ? `${entry.name} ${currentVersion} -> ${entry.version}` : `${entry.name} to ${entry.version}`;
5975
+ const commitMessage = `feat(plugins): upgrade ${label}`;
5976
+ await deps.git.add(options.cwd, stagePaths);
5977
+ await deps.git.commit(options.cwd, commitMessage);
5978
+ log.success(`Committed: ${commitMessage}`);
5979
+ console.log(chalk19.bold("\n Plugin upgraded!\n"));
5980
+ console.log(` ${entry.name}@${entry.version} is committed at services/${entry.name}/`);
5981
+ console.log(" Push and redeploy to apply its updated tables and routes:");
5982
+ console.log(chalk19.dim(` git push`));
5983
+ console.log(chalk19.dim(` biffo deploy <environment> --app-only
5984
+ `));
5789
5985
  } finally {
5790
- git.cleanup(cloneDir);
5986
+ deps.git.cleanup(tmpDir);
5791
5987
  }
5792
5988
  }
5793
- function buildRegistrationPrBody(config, pathPrefix, touchedFiles) {
5794
- const { org, repo } = githubRepo(config);
5795
- return [
5796
- "Automated sibling registration generated by `biffo sibling create` (ADR-0007).",
5797
- "",
5798
- `Adds **${org}/${repo}** to this project's CloudFront distribution as a new path-routed origin \u2014 once merged and redeployed, \`baseurl.com/${pathPrefix}/*\` routes to that sibling's own S3 bucket.`,
5799
- "",
5800
- `## Files changed (${touchedFiles.length})`,
5801
- "",
5802
- ...touchedFiles.map((f) => `- \`${f}\``),
5803
- "",
5804
- "## After merging",
5805
- "",
5806
- "This sibling's own S3 bucket policy still needs this distribution's real ARN (a two-phase handshake \u2014 see the sibling repo's README, \"The two-phase CDN registration\"). Once this PR merges and this project redeploys, set `PARENT_CLOUDFRONT_DISTRIBUTION_ARN` on the sibling repo and re-run its deploy workflow."
5807
- ].join("\n");
5808
- }
5809
- function printDryRun6(config, coreConfig, templateRoot) {
5810
- const { org, repo } = githubRepo(config);
5811
- const pathPrefix = config.core.path_prefix ?? config.project.name;
5812
- console.log(chalk19.bold("\n Dry run \u2014 no changes will be made\n"));
5813
- console.log(` Sibling: ${config.project.name}`);
5814
- console.log(` Repository: ${org}/${repo}`);
5815
- console.log(` Core project: ${coreConfig.project.name}`);
5816
- console.log(` Path prefix: /${pathPrefix}`);
5817
- console.log(` Environments: ${config.environments.join(", ")}`);
5818
- console.log(` Template: ${templateRoot}`);
5819
- console.log("\n Would:");
5820
- console.log(" - resolve the core project's Cognito/API identity for each environment");
5821
- console.log(" - create an empty private sibling GitHub repository");
5822
- console.log(" - copy and rewrite _skeletons/sibling-template into the repo");
5823
- console.log(" - push main, create dev/staging, and set dev as default");
5824
- console.log(" - create AWS OIDC trust and a Terraform state bucket");
5825
- console.log(" - configure repository secrets, variables, environments, and branch protection");
5826
- console.log(" - open a PR against the core project to register this sibling for CDN routing\n");
5827
- }
5828
- function githubRepo(config) {
5829
- return config.source_control.config;
5989
+ function readInstalledVersion2(targetDir) {
5990
+ const manifestPath = join25(targetDir, "biffo.plugin.json");
5991
+ if (!existsSync23(manifestPath)) return void 0;
5992
+ try {
5993
+ return validateManifest(JSON.parse(readFileSync19(manifestPath, "utf8"))).version;
5994
+ } catch {
5995
+ return void 0;
5996
+ }
5830
5997
  }
5831
- function awsConfig(config) {
5832
- return config.cloud.config;
5998
+ async function confirmUpgrade(name, currentVersion, newVersion) {
5999
+ const message = currentVersion ? `Upgrade ${chalk19.bold(name)} from ${currentVersion} to ${chalk19.bold(newVersion)}?` : `Upgrade ${chalk19.bold(name)} to ${chalk19.bold(newVersion)}?`;
6000
+ const { confirmed } = await inquirer7.prompt([
6001
+ { type: "confirm", name: "confirmed", message, default: false }
6002
+ ]);
6003
+ return confirmed;
5833
6004
  }
5834
- function defaultSiblingTemplateRoot() {
5835
- let dir = dirname8(new URL(import.meta.url).pathname);
5836
- for (; ; ) {
5837
- const candidate = join25(dir, "_skeletons", "sibling-template");
5838
- if (existsSync23(candidate)) return candidate;
5839
- const parent = dirname8(dir);
5840
- if (parent === dir) break;
5841
- dir = parent;
6005
+ function printDryRun6(entry, currentVersion) {
6006
+ console.log(chalk19.bold("\n Dry run \u2014 no changes will be made\n"));
6007
+ console.log(` Plugin: ${entry.name}`);
6008
+ console.log(` Current: ${currentVersion ?? "(unknown \u2014 manifest unreadable)"}`);
6009
+ console.log(` Would upgrade to: ${entry.version}`);
6010
+ console.log(` Source repo: ${entry.repo}`);
6011
+ console.log(` Would replace: services/${entry.name}/`);
6012
+ if (entry.infra_modules && entry.infra_modules.length > 0) {
6013
+ console.log(
6014
+ ` Would replace Terraform module at: modules/plugins/${entry.name}/ (if the repo has one)`
6015
+ );
5842
6016
  }
5843
- return resolve16(process.cwd(), "_skeletons", "sibling-template");
6017
+ console.log(` Would commit: feat(plugins): upgrade ${entry.name} to ${entry.version}
6018
+ `);
5844
6019
  }
5845
6020
 
6021
+ // src/commands/plugin.ts
6022
+ var pluginCommand = new Command20("plugin").description("Manage Biffo plugins");
6023
+ pluginCommand.addCommand(pluginCreateCommand);
6024
+ pluginCommand.addCommand(pluginListCommand);
6025
+ pluginCommand.addCommand(pluginInstallCommand);
6026
+ pluginCommand.addCommand(pluginUninstallCommand);
6027
+ pluginCommand.addCommand(pluginUpgradeCommand);
6028
+ pluginCommand.addCommand(pluginSyncMigrationsCommand);
6029
+ pluginCommand.addCommand(pluginInfoCommand);
6030
+
5846
6031
  // src/commands/sibling.ts
6032
+ import { Command as Command21 } from "commander";
5847
6033
  var siblingCommand = new Command21("sibling").description(
5848
6034
  "Create and manage sibling apps that share a Biffo core project (ADR-0007)"
5849
6035
  );
@@ -5946,7 +6132,9 @@ var SIBLING_MARKER_FILE = "biffo.sibling.json";
5946
6132
  function markerMatches(marker, coreProjectName, sibling) {
5947
6133
  if (!marker) return false;
5948
6134
  if (marker.core_project !== coreProjectName) return false;
5949
- return marker.path_prefix === sibling.pathPrefix || marker.name === sibling.projectName;
6135
+ if (marker.path_prefix === sibling.pathPrefix) return true;
6136
+ if (marker.path_prefix === "" && sibling.pathPrefix === ROOT_SIBLING_NAME) return true;
6137
+ return marker.name === sibling.projectName;
5950
6138
  }
5951
6139
  async function resolveSiblingRepos(github, coreOrg, coreProjectName, siblings) {
5952
6140
  const resolved = [];
@@ -6338,7 +6526,7 @@ function formatSiblingPlan(siblings, skipDestroy) {
6338
6526
  continue;
6339
6527
  }
6340
6528
  lines.push(
6341
- ` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${s.org}/${s.repo}`)} (routed at /${s.pathPrefix})`
6529
+ ` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
6342
6530
  );
6343
6531
  lines.push(
6344
6532
  ` ${chalk20.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`