@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.
- package/core.version +1 -1
- package/dist/index.js +2300 -2112
- 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
|
|
3138
|
-
import { resolve as
|
|
3139
|
-
import
|
|
3140
|
-
import { Command as
|
|
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/
|
|
3463
|
-
var
|
|
3464
|
-
|
|
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
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
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
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3554
|
-
|
|
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
|
-
|
|
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("
|
|
3563
|
-
log.step(
|
|
3564
|
-
|
|
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
|
-
|
|
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(
|
|
3767
|
+
log.step(3, totalSteps, "GitHub repository already created \u2014 skipping");
|
|
3569
3768
|
}
|
|
3570
3769
|
if (!session.completedSteps.includes("oidc_trust")) {
|
|
3571
|
-
log.step(
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
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(
|
|
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(
|
|
3590
|
-
|
|
3591
|
-
session
|
|
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(
|
|
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(
|
|
3608
|
-
|
|
3609
|
-
|
|
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(
|
|
3791
|
+
log.step(6, totalSteps, "GitHub already configured \u2014 skipping");
|
|
3641
3792
|
}
|
|
3642
|
-
|
|
3643
|
-
|
|
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
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
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
|
|
3663
|
-
const
|
|
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
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
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
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
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
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
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
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
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
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
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
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
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
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
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
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
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
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
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
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
}
|
|
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
|
-
|
|
4343
|
+
deleteSession(config.project.name);
|
|
4344
|
+
saveProjectConfig(config);
|
|
3960
4345
|
}
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
}
|
|
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
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
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
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
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
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
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
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
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
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
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
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
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
|
-
|
|
4097
|
-
|
|
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
|
-
{
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
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
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
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
|
|
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/
|
|
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
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
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
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
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
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
}
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
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
|
-
|
|
4302
|
-
const
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
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
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
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
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
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
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
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
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
}
|
|
4438
|
-
|
|
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
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
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
|
|
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
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
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
|
|
4512
|
-
|
|
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
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
const
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
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-
|
|
4550
|
-
var
|
|
4551
|
-
|
|
4552
|
-
"
|
|
4553
|
-
).
|
|
4554
|
-
"--
|
|
4555
|
-
"
|
|
4556
|
-
).option("--dry-run", "
|
|
4557
|
-
async (
|
|
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
|
|
4561
|
-
|
|
4883
|
+
await runPluginCreate(
|
|
4884
|
+
name,
|
|
4562
4885
|
{
|
|
4563
|
-
|
|
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
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4660
|
-
|
|
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
|
|
4671
|
-
const
|
|
4672
|
-
|
|
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
|
-
`
|
|
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(
|
|
4928
|
+
printDryRun3(names, relDir, skeletonRoot, channel);
|
|
4682
4929
|
return;
|
|
4683
4930
|
}
|
|
4684
|
-
const
|
|
4685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
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
|
-
|
|
4749
|
-
|
|
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
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
}
|
|
4778
|
-
|
|
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
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
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
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
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
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
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
|
-
|
|
4809
|
-
`);
|
|
4810
|
-
}
|
|
5080
|
+
};
|
|
4811
5081
|
|
|
4812
|
-
// src/commands/plugin-
|
|
4813
|
-
|
|
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
|
|
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
|
|
4827
|
-
const
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
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
|
-
|
|
4849
|
-
|
|
4850
|
-
return;
|
|
5097
|
+
for (const entry of matches) {
|
|
5098
|
+
printEntry(entry);
|
|
4851
5099
|
}
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
console.log(
|
|
4855
|
-
|
|
5100
|
+
}
|
|
5101
|
+
function printEntry(entry) {
|
|
5102
|
+
console.log(chalk14.bold(`
|
|
5103
|
+
${entry.name}@${entry.version}
|
|
4856
5104
|
`));
|
|
4857
|
-
console.log(` ${
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
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
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
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-
|
|
4873
|
-
import { existsSync as existsSync19 } from "fs";
|
|
4874
|
-
import { join as join21, relative as
|
|
4875
|
-
import
|
|
4876
|
-
import { Command as
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
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
|
|
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
|
-
|
|
4890
|
-
|
|
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
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
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
|
-
|
|
4900
|
-
|
|
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
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
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
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
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
|
|
4916
|
-
|
|
4917
|
-
|
|
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
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
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
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
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-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
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
|
|
4949
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
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 (!
|
|
4995
|
-
|
|
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
|
|
5002
|
-
if (!
|
|
5387
|
+
const manifestPath = join21(localPath, "biffo.plugin.json");
|
|
5388
|
+
if (!existsSync19(manifestPath)) {
|
|
5003
5389
|
throw new Error(
|
|
5004
|
-
`${
|
|
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
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
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
|
|
5067
|
-
const
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
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
|
-
|
|
5103
|
-
|
|
5428
|
+
git.cleanup(tmpDir);
|
|
5429
|
+
throw err;
|
|
5104
5430
|
}
|
|
5105
|
-
}
|
|
5106
|
-
async function
|
|
5107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5115
|
-
if (!existsSync21(targetDir)) {
|
|
5438
|
+
if (!options.local && !target) {
|
|
5116
5439
|
throw new Error(
|
|
5117
|
-
`
|
|
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
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
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
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
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
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
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 (
|
|
5139
|
-
|
|
5140
|
-
|
|
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
|
|
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
|
-
|
|
5152
|
-
|
|
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
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
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
|
|
5166
|
-
|
|
5167
|
-
|
|
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/${
|
|
5170
|
-
log.success(`Copied Terraform module to modules/plugins/${
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
log.
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
}
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
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
|
|
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(
|
|
5336
|
-
} catch {
|
|
5337
|
-
|
|
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
|
|
5341
|
-
const
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
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
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
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/
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
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
|
|
5377
|
-
|
|
5378
|
-
|
|
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
|
-
|
|
5620
|
+
`${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
|
|
5382
5621
|
);
|
|
5383
5622
|
}
|
|
5384
|
-
const
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
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 (
|
|
5406
|
-
|
|
5407
|
-
|
|
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
|
|
5417
|
-
const
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
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
|
-
`
|
|
5650
|
+
` ${plugin.name.padEnd(nameWidth)} ${plugin.version.padEnd(versionWidth)} ${plugin.location}`
|
|
5435
5651
|
);
|
|
5436
5652
|
}
|
|
5437
5653
|
console.log(
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
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
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
)
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
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
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
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 (!
|
|
5509
|
-
|
|
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 (
|
|
5516
|
-
log.
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
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
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
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
|
-
|
|
5533
|
-
} else {
|
|
5534
|
-
log.step(7, totalSteps, "Already registered with the core project \u2014 skipping");
|
|
5703
|
+
return;
|
|
5535
5704
|
}
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
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
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
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
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
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
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
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
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
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
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
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
|
-
}
|
|
5679
|
-
|
|
5680
|
-
|
|
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
|
-
|
|
5684
|
-
|
|
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
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
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
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
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
|
-
|
|
5708
|
-
|
|
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
|
|
5712
|
-
|
|
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
|
|
5715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5722
|
-
|
|
5892
|
+
log.error(err.message);
|
|
5893
|
+
process.exit(1);
|
|
5723
5894
|
}
|
|
5724
|
-
}
|
|
5725
|
-
function
|
|
5726
|
-
const
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
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
|
-
|
|
5904
|
+
const targetDir = join25(servicesDir, name);
|
|
5905
|
+
if (!existsSync23(targetDir)) {
|
|
5734
5906
|
throw new Error(
|
|
5735
|
-
`
|
|
5907
|
+
`Plugin '${name}' is not installed at services/${name}/. Use 'biffo plugin install ${name}@${minor}' instead.`
|
|
5736
5908
|
);
|
|
5737
5909
|
}
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
const
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
)
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
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
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
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
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
});
|
|
5788
|
-
|
|
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(
|
|
5986
|
+
deps.git.cleanup(tmpDir);
|
|
5791
5987
|
}
|
|
5792
5988
|
}
|
|
5793
|
-
function
|
|
5794
|
-
const
|
|
5795
|
-
return
|
|
5796
|
-
|
|
5797
|
-
""
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
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
|
|
5832
|
-
|
|
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
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`}`
|