@biffo/cli 0.164.0 → 0.166.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/dist/index.js +255 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1506,6 +1506,70 @@ var GitHubAdapter = class {
|
|
|
1506
1506
|
}
|
|
1507
1507
|
log.success("Branch protection configured on dev, staging, and main");
|
|
1508
1508
|
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Protect a single branch with caller-supplied required checks (#803).
|
|
1511
|
+
*
|
|
1512
|
+
* `configureBranchProtection` above exists for **deployable** repos and
|
|
1513
|
+
* protects `dev`, `staging` and `main` with `DEFAULT_STATUS_CHECKS` — the
|
|
1514
|
+
* core workflow's job names. Neither is right for a plugin repo:
|
|
1515
|
+
*
|
|
1516
|
+
* - A plugin repo is **non-deployable** and has `dev` only (AGENTS.md §2).
|
|
1517
|
+
* Reusing the deployable path would demand two branches it should not have,
|
|
1518
|
+
* and `waitForBranch` would hang on them for two minutes each before
|
|
1519
|
+
* failing.
|
|
1520
|
+
* - Its checks are its own CI's job names (`Lint`, `Type Check`, `Test`, …),
|
|
1521
|
+
* not the core workflow's. Requiring a context that is never reported
|
|
1522
|
+
* blocks every PR for ever, on a branch whose CI is entirely green.
|
|
1523
|
+
*
|
|
1524
|
+
* `statusChecks` must be non-empty. Protection that requires nothing is worse
|
|
1525
|
+
* than none: it reads as configured while gating on nothing, so callers that
|
|
1526
|
+
* could not determine the contexts must not silently land here.
|
|
1527
|
+
*/
|
|
1528
|
+
async protectSingleBranch(org, repo, branch, statusChecks, protectionIntervalMs = 3e3) {
|
|
1529
|
+
if (statusChecks.length === 0) {
|
|
1530
|
+
throw new Error(
|
|
1531
|
+
`Refusing to protect ${org}/${repo}@${branch} with no required status checks \u2014 that reads as protected while gating on nothing. Determine the workflow's job contexts first.`
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
log.info(`Waiting for ${branch} branch to be ready...`);
|
|
1535
|
+
await this.waitForBranch(org, repo, branch);
|
|
1536
|
+
log.info(`Configuring branch protection on ${branch}...`);
|
|
1537
|
+
const params = {
|
|
1538
|
+
owner: org,
|
|
1539
|
+
repo,
|
|
1540
|
+
branch,
|
|
1541
|
+
required_status_checks: { strict: true, contexts: statusChecks },
|
|
1542
|
+
enforce_admins: false,
|
|
1543
|
+
required_pull_request_reviews: {
|
|
1544
|
+
required_approving_review_count: 0,
|
|
1545
|
+
dismiss_stale_reviews: false
|
|
1546
|
+
},
|
|
1547
|
+
restrictions: null,
|
|
1548
|
+
required_linear_history: true,
|
|
1549
|
+
allow_force_pushes: false,
|
|
1550
|
+
allow_deletions: false
|
|
1551
|
+
};
|
|
1552
|
+
const deadline = Date.now() + 3e4;
|
|
1553
|
+
while (true) {
|
|
1554
|
+
try {
|
|
1555
|
+
await this.octokit.repos.updateBranchProtection(params);
|
|
1556
|
+
break;
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
const status = err.status;
|
|
1559
|
+
if (status === 403) {
|
|
1560
|
+
log.warn(`Branch protection unavailable for ${org}/${repo}: ${err.message}`);
|
|
1561
|
+
log.warn(" Add it later via GitHub once the plan allows it, or make the repo public.");
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (status !== 404 || Date.now() >= deadline) throw err;
|
|
1565
|
+
log.info("Branch protection endpoint not yet ready, retrying...");
|
|
1566
|
+
await new Promise((resolve18) => setTimeout(resolve18, protectionIntervalMs));
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
log.success(
|
|
1570
|
+
`Branch protection configured on ${branch} (${statusChecks.length} required check(s))`
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1509
1573
|
async createEnvironments(config) {
|
|
1510
1574
|
const { org, repo } = config.source_control.config;
|
|
1511
1575
|
for (const env of config.environments) {
|
|
@@ -6370,6 +6434,50 @@ import { fileURLToPath as fileURLToPath5 } from "url";
|
|
|
6370
6434
|
import chalk13 from "chalk";
|
|
6371
6435
|
import { Command as Command13 } from "commander";
|
|
6372
6436
|
|
|
6437
|
+
// src/lib/workflow-check-contexts.ts
|
|
6438
|
+
function unquote(value) {
|
|
6439
|
+
const trimmed = value.trim();
|
|
6440
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length > 1 || trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length > 1) {
|
|
6441
|
+
return trimmed.slice(1, -1);
|
|
6442
|
+
}
|
|
6443
|
+
return trimmed;
|
|
6444
|
+
}
|
|
6445
|
+
function workflowCheckContexts(workflow) {
|
|
6446
|
+
const lines = workflow.split("\n");
|
|
6447
|
+
const jobsIndex = lines.findIndex((line) => /^jobs:\s*(#.*)?$/.test(line));
|
|
6448
|
+
if (jobsIndex === -1) return [];
|
|
6449
|
+
const contexts = [];
|
|
6450
|
+
let currentJobId = null;
|
|
6451
|
+
let currentJobIndent = 0;
|
|
6452
|
+
let currentName = null;
|
|
6453
|
+
const flush = () => {
|
|
6454
|
+
if (currentJobId !== null) contexts.push(currentName ?? currentJobId);
|
|
6455
|
+
currentJobId = null;
|
|
6456
|
+
currentName = null;
|
|
6457
|
+
};
|
|
6458
|
+
for (const line of lines.slice(jobsIndex + 1)) {
|
|
6459
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
6460
|
+
if (!/^\s/.test(line)) break;
|
|
6461
|
+
const jobMatch = /^(\s+)([A-Za-z0-9_-]+):\s*(#.*)?$/.exec(line);
|
|
6462
|
+
if (jobMatch?.[1] !== void 0 && jobMatch[2] !== void 0) {
|
|
6463
|
+
const indent = jobMatch[1].length;
|
|
6464
|
+
if (currentJobId === null || indent === currentJobIndent) {
|
|
6465
|
+
flush();
|
|
6466
|
+
currentJobId = jobMatch[2];
|
|
6467
|
+
currentJobIndent = indent;
|
|
6468
|
+
continue;
|
|
6469
|
+
}
|
|
6470
|
+
}
|
|
6471
|
+
if (currentJobId === null) continue;
|
|
6472
|
+
const nameMatch = /^(\s+)name:\s*(.+?)\s*$/.exec(line);
|
|
6473
|
+
if (nameMatch?.[1] !== void 0 && nameMatch[2] !== void 0 && nameMatch[1].length === currentJobIndent + 2 && currentName === null) {
|
|
6474
|
+
currentName = unquote(nameMatch[2]);
|
|
6475
|
+
}
|
|
6476
|
+
}
|
|
6477
|
+
flush();
|
|
6478
|
+
return contexts;
|
|
6479
|
+
}
|
|
6480
|
+
|
|
6373
6481
|
// src/lib/plugin-locations.ts
|
|
6374
6482
|
import { existsSync as existsSync21, readdirSync as readdirSync10 } from "fs";
|
|
6375
6483
|
import { join as join21 } from "path";
|
|
@@ -6661,7 +6769,8 @@ function applySubstitutions(text, names) {
|
|
|
6661
6769
|
return out;
|
|
6662
6770
|
}
|
|
6663
6771
|
var BINARY_EXTENSIONS = /\.(png|jpe?g|gif|ico|woff2?|ttf|zip|gz)$/i;
|
|
6664
|
-
function scaffoldPlugin(skeletonRoot, destDir, names) {
|
|
6772
|
+
function scaffoldPlugin(skeletonRoot, destDir, names, options = {}) {
|
|
6773
|
+
const layout = options.layout ?? "in-tree";
|
|
6665
6774
|
if (!existsSync22(skeletonRoot)) {
|
|
6666
6775
|
throw new Error(`Plugin skeleton not found at ${skeletonRoot}`);
|
|
6667
6776
|
}
|
|
@@ -6678,7 +6787,7 @@ function scaffoldPlugin(skeletonRoot, destDir, names) {
|
|
|
6678
6787
|
(a, b) => a.name.localeCompare(b.name)
|
|
6679
6788
|
)) {
|
|
6680
6789
|
if (NEVER_COPY.has(entry.name)) continue;
|
|
6681
|
-
if (relDir === "" && entry.name in STANDALONE_ONLY_ENTRIES) {
|
|
6790
|
+
if (layout === "in-tree" && relDir === "" && entry.name in STANDALONE_ONLY_ENTRIES) {
|
|
6682
6791
|
skipped.push({ entry: entry.name, reason: STANDALONE_ONLY_ENTRIES[entry.name] });
|
|
6683
6792
|
continue;
|
|
6684
6793
|
}
|
|
@@ -6724,6 +6833,12 @@ function findSkeletonRoot(startDir, skeleton) {
|
|
|
6724
6833
|
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(
|
|
6725
6834
|
"--first-party",
|
|
6726
6835
|
"Scaffold into the template-owned services/_plugins/ carve-out. Only valid in the biffo-template repo itself \u2014 see notes."
|
|
6836
|
+
).option(
|
|
6837
|
+
"--standalone",
|
|
6838
|
+
"Scaffold a standalone plugin repo (ADR-0003 section 2) into ./biffo-plugin-<name>/ instead of into this checkout, keeping its own CI/CD workflows"
|
|
6839
|
+
).option(
|
|
6840
|
+
"--org <org>",
|
|
6841
|
+
"With --standalone, also create the GitHub repo under this org (or user), push, and protect dev"
|
|
6727
6842
|
).option(
|
|
6728
6843
|
"--skeleton <path>",
|
|
6729
6844
|
"Path to the plugin skeleton (defaults to _skeletons/plugin-template)"
|
|
@@ -6735,12 +6850,18 @@ var pluginCreateCommand = new Command13("create").description("Scaffold a new pl
|
|
|
6735
6850
|
name,
|
|
6736
6851
|
{
|
|
6737
6852
|
firstParty: options.firstParty ?? false,
|
|
6853
|
+
standalone: options.standalone ?? false,
|
|
6854
|
+
...options.org ? { org: options.org } : {},
|
|
6738
6855
|
...options.skeleton ? { skeletonRoot: resolve11(options.skeleton) } : {},
|
|
6739
6856
|
dryRun: options.dryRun ?? false,
|
|
6740
6857
|
commit: options.commit !== false,
|
|
6741
6858
|
cwd
|
|
6742
6859
|
},
|
|
6743
|
-
{
|
|
6860
|
+
{
|
|
6861
|
+
git: new GitAdapter(),
|
|
6862
|
+
makeGitHub: (token) => new GitHubAdapter(token),
|
|
6863
|
+
resolveToken: resolveGithubToken3
|
|
6864
|
+
}
|
|
6744
6865
|
);
|
|
6745
6866
|
} catch (err) {
|
|
6746
6867
|
log.error(err.message);
|
|
@@ -6750,6 +6871,15 @@ var pluginCreateCommand = new Command13("create").description("Scaffold a new pl
|
|
|
6750
6871
|
);
|
|
6751
6872
|
async function runPluginCreate(name, options, deps) {
|
|
6752
6873
|
const names = deriveNames(name);
|
|
6874
|
+
if (options.standalone) {
|
|
6875
|
+
if (options.firstParty) {
|
|
6876
|
+
throw new Error(
|
|
6877
|
+
"--standalone and --first-party are opposites: --first-party authors a plugin *inside* the template at services/_plugins/, while --standalone authors one in its own repository (ADR-0003 section 2). Pick one."
|
|
6878
|
+
);
|
|
6879
|
+
}
|
|
6880
|
+
await runStandaloneCreate(names, options, deps);
|
|
6881
|
+
return;
|
|
6882
|
+
}
|
|
6753
6883
|
const isInstance = existsSync23(join23(options.cwd, INSTANCE_CORE_FILE));
|
|
6754
6884
|
if (options.firstParty && isInstance) {
|
|
6755
6885
|
throw new Error(
|
|
@@ -6808,6 +6938,128 @@ async function runPluginCreate(name, options, deps) {
|
|
|
6808
6938
|
}
|
|
6809
6939
|
printNextSteps(names, relDir, channel);
|
|
6810
6940
|
}
|
|
6941
|
+
async function runStandaloneCreate(names, options, deps) {
|
|
6942
|
+
const destDir = join23(options.cwd, names.dist);
|
|
6943
|
+
if (existsSync23(destDir)) {
|
|
6944
|
+
throw new Error(`${names.dist}/ already exists. Choose a different name, or remove it first.`);
|
|
6945
|
+
}
|
|
6946
|
+
const skeletonRoot = resolveSkeletonRoot(options);
|
|
6947
|
+
if (options.dryRun) {
|
|
6948
|
+
console.log(chalk13.bold("\n Dry run \u2014 no changes will be made\n"));
|
|
6949
|
+
console.log(` Plugin: ${names.slug}`);
|
|
6950
|
+
console.log(" Channel: standalone repo");
|
|
6951
|
+
console.log(` Would scaffold: ${names.dist}/`);
|
|
6952
|
+
console.log(` From skeleton: ${skeletonRoot}`);
|
|
6953
|
+
console.log(` Python package: ${names.pkg} (dist: ${names.dist})`);
|
|
6954
|
+
console.log(" Would git init: yes, on branch dev, with an initial commit\n");
|
|
6955
|
+
return;
|
|
6956
|
+
}
|
|
6957
|
+
const { files } = scaffoldPlugin(skeletonRoot, destDir, names, { layout: "standalone" });
|
|
6958
|
+
restorePackagedDotfiles(destDir);
|
|
6959
|
+
log.success(`Scaffolded ${String(files.length)} file(s) into ${names.dist}/`);
|
|
6960
|
+
const manifest = validateManifest(
|
|
6961
|
+
JSON.parse(readFileSync17(join23(destDir, "biffo.plugin.json"), "utf8"))
|
|
6962
|
+
);
|
|
6963
|
+
if (manifest.name !== names.slug) {
|
|
6964
|
+
throw new Error(
|
|
6965
|
+
`Scaffolded manifest declares name '${manifest.name}', expected '${names.slug}'. The skeleton's manifest name may have diverged from 'example-plugin'.`
|
|
6966
|
+
);
|
|
6967
|
+
}
|
|
6968
|
+
log.success(
|
|
6969
|
+
`Manifest valid \u2014 ${String(manifest.tables.length)} table(s), ${String(manifest.api_routes.length)} route(s)`
|
|
6970
|
+
);
|
|
6971
|
+
if (options.commit) {
|
|
6972
|
+
await deps.git.init(destDir);
|
|
6973
|
+
await deps.git.add(destDir, ["."]);
|
|
6974
|
+
await deps.git.commit(destDir, `feat: scaffold ${names.slug} plugin`);
|
|
6975
|
+
log.success(`Initialised a git repo on dev with an initial commit`);
|
|
6976
|
+
}
|
|
6977
|
+
if (options.org !== void 0 && options.org !== "") {
|
|
6978
|
+
if (!options.commit) {
|
|
6979
|
+
throw new Error(
|
|
6980
|
+
"--org needs a commit to push. Drop --no-commit, or create the repo yourself using the steps printed by a --no-commit run."
|
|
6981
|
+
);
|
|
6982
|
+
}
|
|
6983
|
+
await createAndPushStandaloneRepo(options.org, names, destDir, deps);
|
|
6984
|
+
return;
|
|
6985
|
+
}
|
|
6986
|
+
printStandaloneNextSteps(names, minorOf(manifest.version));
|
|
6987
|
+
}
|
|
6988
|
+
async function createAndPushStandaloneRepo(org, names, destDir, deps) {
|
|
6989
|
+
if (deps.makeGitHub === void 0 || deps.resolveToken === void 0) {
|
|
6990
|
+
throw new Error("No GitHub adapter available \u2014 cannot create a repository.");
|
|
6991
|
+
}
|
|
6992
|
+
const token = await deps.resolveToken();
|
|
6993
|
+
const github = deps.makeGitHub(token);
|
|
6994
|
+
const cloneUrl = await github.createEmptyRepo(org, names.dist, `${names.slug} \u2014 a Biffo plugin`);
|
|
6995
|
+
await deps.git.addRemote(destDir, "origin", cloneUrl);
|
|
6996
|
+
await deps.git.push(destDir, "dev", { token });
|
|
6997
|
+
log.success(`Pushed dev to ${org}/${names.dist}`);
|
|
6998
|
+
await github.setDefaultBranch(org, names.dist, "dev");
|
|
6999
|
+
const ciPath = join23(destDir, ".github", "workflows", "ci.yml");
|
|
7000
|
+
const contexts = existsSync23(ciPath) ? workflowCheckContexts(readFileSync17(ciPath, "utf8")) : [];
|
|
7001
|
+
if (contexts.length === 0) {
|
|
7002
|
+
log.warn(
|
|
7003
|
+
`Could not determine required status checks from ${ciPath} \u2014 skipping branch protection. Configure it manually on dev once you know the CI job names.`
|
|
7004
|
+
);
|
|
7005
|
+
} else {
|
|
7006
|
+
await github.protectSingleBranch(org, names.dist, "dev", contexts);
|
|
7007
|
+
}
|
|
7008
|
+
printStandaloneRemoteNextSteps(names, org);
|
|
7009
|
+
}
|
|
7010
|
+
function printStandaloneRemoteNextSteps(names, org) {
|
|
7011
|
+
console.log(chalk13.bold("\n Standalone plugin repo created!\n"));
|
|
7012
|
+
console.log(` https://github.com/${org}/${names.dist} (dev, protected)
|
|
7013
|
+
`);
|
|
7014
|
+
console.log(" Next:");
|
|
7015
|
+
console.log(chalk13.dim(` 1. cd ${names.dist} && edit biffo.plugin.json`));
|
|
7016
|
+
console.log(
|
|
7017
|
+
chalk13.dim(" 2. Add it to the registry so it appears in the plugin store \u2014 either set")
|
|
7018
|
+
);
|
|
7019
|
+
console.log(
|
|
7020
|
+
chalk13.dim(
|
|
7021
|
+
" REGISTRY_PUBLISH_TOKEN on the repo, or add it to the registry\u2019s sources.json"
|
|
7022
|
+
)
|
|
7023
|
+
);
|
|
7024
|
+
console.log(
|
|
7025
|
+
chalk13.dim(` 3. Consumers install it with: biffo plugin install ${names.slug}@<minor>
|
|
7026
|
+
`)
|
|
7027
|
+
);
|
|
7028
|
+
}
|
|
7029
|
+
function minorOf(version) {
|
|
7030
|
+
const match = /^(\d+)\.(\d+)/.exec(version);
|
|
7031
|
+
return match ? `${match[1]}.${match[2]}` : version;
|
|
7032
|
+
}
|
|
7033
|
+
function printStandaloneNextSteps(names, minor) {
|
|
7034
|
+
console.log(chalk13.bold("\n Standalone plugin repo scaffolded!\n"));
|
|
7035
|
+
console.log(` ${names.dist}/ is a complete plugin repository: manifest, example table and`);
|
|
7036
|
+
console.log(" routes, terraform/ module, and its own CI, release and registry workflows.\n");
|
|
7037
|
+
console.log(" Next:");
|
|
7038
|
+
console.log(chalk13.dim(` 1. cd ${names.dist} && edit biffo.plugin.json`));
|
|
7039
|
+
console.log(
|
|
7040
|
+
chalk13.dim(" 2. Create the repo \u2014 plugin repos are dev-only (AGENTS.md section 2):")
|
|
7041
|
+
);
|
|
7042
|
+
console.log(chalk13.dim(` gh repo create <org>/${names.dist} --private --source=. --push`));
|
|
7043
|
+
console.log(
|
|
7044
|
+
chalk13.dim(" gh api -X PATCH repos/<org>/" + names.dist + " -f default_branch=dev")
|
|
7045
|
+
);
|
|
7046
|
+
console.log(chalk13.dim(" 3. Publish it to the registry so it appears in the plugin store:"));
|
|
7047
|
+
console.log(chalk13.dim(" see .github/workflows/publish-registry.yml in the new repo"));
|
|
7048
|
+
console.log(
|
|
7049
|
+
chalk13.dim(` 4. Consumers install it with: biffo plugin install ${names.slug}@${minor}
|
|
7050
|
+
`)
|
|
7051
|
+
);
|
|
7052
|
+
}
|
|
7053
|
+
function resolveSkeletonRoot(options) {
|
|
7054
|
+
const here = dirname8(fileURLToPath5(import.meta.url));
|
|
7055
|
+
const skeletonRoot = options.skeletonRoot ?? findSkeletonRoot(here, "plugin-template") ?? join23(options.cwd, "_skeletons", "plugin-template");
|
|
7056
|
+
if (!existsSync23(skeletonRoot)) {
|
|
7057
|
+
throw new Error(
|
|
7058
|
+
`Could not find the plugin skeleton (_skeletons/plugin-template/). Pass --skeleton <path> to point at it explicitly.`
|
|
7059
|
+
);
|
|
7060
|
+
}
|
|
7061
|
+
return skeletonRoot;
|
|
7062
|
+
}
|
|
6811
7063
|
function printDryRun3(names, relDir, skeletonRoot, channel) {
|
|
6812
7064
|
console.log(chalk13.bold("\n Dry run \u2014 no changes will be made\n"));
|
|
6813
7065
|
console.log(` Plugin: ${names.slug}`);
|