@biffo/cli 0.198.3 → 0.198.5
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.
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
+
import { SIBLING_TITLE } from '@/lib/branding'
|
|
3
4
|
import './globals.css'
|
|
4
5
|
|
|
5
6
|
export const metadata: Metadata = {
|
|
6
|
-
|
|
7
|
+
// Derived from this sibling's own name at build time — see lib/branding.ts.
|
|
8
|
+
// `title.template` composes per-page titles for free: a page exporting
|
|
9
|
+
// `metadata = { title: 'Weekly' }` then renders "<Sibling> - Weekly".
|
|
10
|
+
title: {
|
|
11
|
+
default: SIBLING_TITLE,
|
|
12
|
+
template: `${SIBLING_TITLE} - %s`,
|
|
13
|
+
},
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatSiblingTitle } from './branding'
|
|
4
|
+
|
|
5
|
+
// Regression coverage for biffo-template#963: the skeleton's root layout used
|
|
6
|
+
// to hard-code `metadata.title = 'Sibling App'`, so every sibling scaffolded
|
|
7
|
+
// from it shipped that literal in its deployed <title>. The title is now
|
|
8
|
+
// derived from NEXT_PUBLIC_SIBLING_NAME (the same build-time variable
|
|
9
|
+
// `page.tsx` reads), and this is that derivation.
|
|
10
|
+
describe('formatSiblingTitle', () => {
|
|
11
|
+
it('capitalises a single-word sibling name', () => {
|
|
12
|
+
expect(formatSiblingTitle('reports')).toBe('Reports')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('splits hyphenated and underscored names into words', () => {
|
|
16
|
+
expect(formatSiblingTitle('field-ops')).toBe('Field Ops')
|
|
17
|
+
expect(formatSiblingTitle('tabsii_crm')).toBe('Tabsii Crm')
|
|
18
|
+
expect(formatSiblingTitle('a-b-c')).toBe('A B C')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('leaves an already-capitalised name alone', () => {
|
|
22
|
+
expect(formatSiblingTitle('Reports')).toBe('Reports')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('tolerates stray separators without emitting empty words', () => {
|
|
26
|
+
expect(formatSiblingTitle('--field--ops--')).toBe('Field Ops')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('never returns the old hard-coded placeholder, even with no name set', () => {
|
|
30
|
+
for (const missing of [undefined, '', ' ', '---']) {
|
|
31
|
+
expect(formatSiblingTitle(missing)).toBe('Sibling')
|
|
32
|
+
expect(formatSiblingTitle(missing)).not.toBe('Sibling App')
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sibling branding (issue #963).
|
|
3
|
+
*
|
|
4
|
+
* The skeleton's root layout used to hard-code `metadata.title = 'Sibling App'`,
|
|
5
|
+
* and nothing at `biffo sibling create` time ever touched it — so every sibling
|
|
6
|
+
* ever scaffolded was born mislabeled in every browser tab, visibly in the
|
|
7
|
+
* deployed `out/index.html`, until a human noticed and fixed it by hand.
|
|
8
|
+
*
|
|
9
|
+
* Instead, derive it from the sibling's own name the same way `page.tsx`
|
|
10
|
+
* already does: the build-time `NEXT_PUBLIC_SIBLING_NAME`. That variable is
|
|
11
|
+
* already wired end to end — `biffo sibling create` writes the repo variable
|
|
12
|
+
* `PROJECT_NAME`, `.github/workflows/deploy.yml` passes it to the frontend
|
|
13
|
+
* build as `NEXT_PUBLIC_SIBLING_NAME`, and `apps/frontend/.env.example` is
|
|
14
|
+
* templated with the sibling's name at create time for local runs. The
|
|
15
|
+
* frontend builds with `output: 'export'`, so the value is baked into the
|
|
16
|
+
* static bundle; there is nothing to read at runtime.
|
|
17
|
+
*
|
|
18
|
+
* This mirrors `apps/portal/src/lib/branding.ts` upstream (issue #389), which
|
|
19
|
+
* solved the identical problem for the portal with a build-time env var rather
|
|
20
|
+
* than a scaffold-time source patch. Reading an env var beats regex-patching a
|
|
21
|
+
* `.tsx` file at create time: one mechanism, no fragile source rewrite, and it
|
|
22
|
+
* keeps working when the sibling is later renamed.
|
|
23
|
+
*
|
|
24
|
+
* The title is the sibling's OWN name only, never `<core project> - <sibling>`.
|
|
25
|
+
* The template is deliberately brand-agnostic and has no reliable way to know
|
|
26
|
+
* an instance's brand (as opposed to its `project.name`) at sibling-create
|
|
27
|
+
* time. A sibling that wants a brand prefix owns its own `layout.tsx` and can
|
|
28
|
+
* add one.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Turn a project slug into something presentable in a browser tab:
|
|
33
|
+
* `reports` → `Reports`, `field-ops` → `Field Ops`, `tabsii_crm` → `Tabsii Crm`.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately dumb and deterministic — it does not try to guess acronyms
|
|
36
|
+
* (`crm` → `CRM`), because guessing wrong is worse than being plain and a
|
|
37
|
+
* sibling that cares can override the title outright.
|
|
38
|
+
*/
|
|
39
|
+
export function formatSiblingTitle(rawName: string | undefined): string {
|
|
40
|
+
const words = (rawName ?? '')
|
|
41
|
+
.split(/[-_\s]+/)
|
|
42
|
+
.filter((word) => word.length > 0)
|
|
43
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
44
|
+
|
|
45
|
+
// Fallback for a frontend run with no env at all (a bare `pnpm dev` before
|
|
46
|
+
// `.env.example` is copied). Matches page.tsx's own fallback for the same
|
|
47
|
+
// variable, and is deliberately NOT the old 'Sibling App' literal.
|
|
48
|
+
return words.length > 0 ? words.join(' ') : 'Sibling'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const SIBLING_TITLE = formatSiblingTitle(process.env['NEXT_PUBLIC_SIBLING_NAME'])
|
package/dist/index.js
CHANGED
|
@@ -1167,6 +1167,53 @@ function injectToken(repoUrl, token) {
|
|
|
1167
1167
|
// src/adapters/source-control/github/index.ts
|
|
1168
1168
|
import { execSync } from "child_process";
|
|
1169
1169
|
import { Octokit } from "@octokit/rest";
|
|
1170
|
+
|
|
1171
|
+
// src/lib/branch-protection-outcome.ts
|
|
1172
|
+
var pending = [];
|
|
1173
|
+
function recordBranchProtectionOutcome(outcome) {
|
|
1174
|
+
pending.push(outcome);
|
|
1175
|
+
return outcome;
|
|
1176
|
+
}
|
|
1177
|
+
function isUnprotected(outcome) {
|
|
1178
|
+
return outcome.status !== "applied" || outcome.unprotectedBranches.length > 0;
|
|
1179
|
+
}
|
|
1180
|
+
function formatBranchProtectionSummary(outcomes) {
|
|
1181
|
+
if (outcomes.length === 0) return [];
|
|
1182
|
+
const unprotected = outcomes.filter(isUnprotected);
|
|
1183
|
+
if (unprotected.length === 0) {
|
|
1184
|
+
return [
|
|
1185
|
+
`Branch protection applied to ${outcomes.map((o) => `${o.org}/${o.repo}`).sort().join(", ")}`
|
|
1186
|
+
];
|
|
1187
|
+
}
|
|
1188
|
+
const lines = [
|
|
1189
|
+
`Branch protection was NOT fully applied \u2014 ${unprotected.length} of ${outcomes.length} repositor${outcomes.length === 1 ? "y" : "ies"} created by this run ${unprotected.length === 1 ? "is" : "are"} unprotected:`
|
|
1190
|
+
];
|
|
1191
|
+
for (const outcome of unprotected) {
|
|
1192
|
+
const left = outcome.unprotectedBranches.join(", ") || "unknown";
|
|
1193
|
+
const why = outcome.status === "skipped-403" ? "GitHub returned 403 \u2014 the org's plan does not allow branch protection on this repo" : outcome.status === "failed" ? "branch protection failed" : "branch protection incomplete";
|
|
1194
|
+
lines.push(` ${outcome.org}/${outcome.repo} \u2014 unprotected: ${left} (${why})`);
|
|
1195
|
+
if (outcome.reason) lines.push(` ${outcome.reason}`);
|
|
1196
|
+
}
|
|
1197
|
+
lines.push(
|
|
1198
|
+
" Direct pushes, force-pushes and merges with red or missing checks are all allowed on those branches right now.",
|
|
1199
|
+
" Fix it with: biffo check branch-protection --fix (after upgrading the plan, or making the repo public)"
|
|
1200
|
+
);
|
|
1201
|
+
return lines;
|
|
1202
|
+
}
|
|
1203
|
+
function reportBranchProtectionSummary() {
|
|
1204
|
+
const outcomes = pending.splice(0, pending.length);
|
|
1205
|
+
const lines = formatBranchProtectionSummary(outcomes);
|
|
1206
|
+
if (lines.length === 0) return outcomes;
|
|
1207
|
+
if (outcomes.some(isUnprotected)) {
|
|
1208
|
+
log.error(lines[0]);
|
|
1209
|
+
for (const line of lines.slice(1)) log.error(line);
|
|
1210
|
+
} else {
|
|
1211
|
+
log.success(lines[0]);
|
|
1212
|
+
}
|
|
1213
|
+
return outcomes;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/adapters/source-control/github/index.ts
|
|
1170
1217
|
var DEFAULT_STATUS_CHECKS = [
|
|
1171
1218
|
"JS (lint, types, test, audit)",
|
|
1172
1219
|
"Python (lint, types, test, security)",
|
|
@@ -1566,49 +1613,96 @@ var GitHubAdapter = class {
|
|
|
1566
1613
|
await this.octokit.repos.update({ owner: org, repo, default_branch: branch });
|
|
1567
1614
|
log.info(`Default branch set to ${branch}`);
|
|
1568
1615
|
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Protect `dev`, `staging` and `main`, and **say what actually happened**.
|
|
1618
|
+
*
|
|
1619
|
+
* This used to return `Promise<void>`, which made the 403 path (GitHub
|
|
1620
|
+
* refusing branch protection on a private org repo whose plan does not
|
|
1621
|
+
* include it) indistinguishable from success at every call site: the method
|
|
1622
|
+
* logged two warnings and returned, and the scaffold went on to report a
|
|
1623
|
+
* repo created. Nothing durable recorded that protection had been skipped,
|
|
1624
|
+
* so the only trace was a log line in the middle of a long provisioning
|
|
1625
|
+
* transcript. Three repos — including a live core platform — ran completely
|
|
1626
|
+
* unprotected for three weeks on the strength of that (#715, #737 item 2).
|
|
1627
|
+
*
|
|
1628
|
+
* The returned outcome is also pushed onto the run-scoped collector in
|
|
1629
|
+
* `lib/branch-protection-outcome.ts`, so callers that do nothing with the
|
|
1630
|
+
* return value still get named in the end-of-run summary.
|
|
1631
|
+
*/
|
|
1569
1632
|
async configureBranchProtection(config, protectionIntervalMs = 3e3, statusChecks = DEFAULT_STATUS_CHECKS) {
|
|
1570
1633
|
const { org, repo } = config.source_control.config;
|
|
1571
1634
|
const branches = ["dev", "staging", "main"];
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
)
|
|
1603
|
-
|
|
1635
|
+
const applied = [];
|
|
1636
|
+
const remaining = () => branches.filter((b) => !applied.includes(b));
|
|
1637
|
+
try {
|
|
1638
|
+
for (const branch of branches) {
|
|
1639
|
+
log.info(`Waiting for ${branch} branch to be ready...`);
|
|
1640
|
+
await this.waitForBranch(org, repo, branch);
|
|
1641
|
+
log.info(`Configuring branch protection on ${branch}...`);
|
|
1642
|
+
const params = {
|
|
1643
|
+
owner: org,
|
|
1644
|
+
repo,
|
|
1645
|
+
branch,
|
|
1646
|
+
required_status_checks: { strict: true, contexts: statusChecks },
|
|
1647
|
+
enforce_admins: false,
|
|
1648
|
+
required_pull_request_reviews: {
|
|
1649
|
+
required_approving_review_count: 0,
|
|
1650
|
+
dismiss_stale_reviews: false
|
|
1651
|
+
},
|
|
1652
|
+
restrictions: null,
|
|
1653
|
+
required_linear_history: true,
|
|
1654
|
+
allow_force_pushes: false,
|
|
1655
|
+
allow_deletions: false
|
|
1656
|
+
};
|
|
1657
|
+
const deadline = Date.now() + 3e4;
|
|
1658
|
+
while (true) {
|
|
1659
|
+
try {
|
|
1660
|
+
await this.octokit.repos.updateBranchProtection(params);
|
|
1661
|
+
applied.push(branch);
|
|
1662
|
+
break;
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
const status = err.status;
|
|
1665
|
+
if (status === 403) {
|
|
1666
|
+
log.warn(
|
|
1667
|
+
`Branch protection unavailable for ${org}/${repo}: ${err.message}`
|
|
1668
|
+
);
|
|
1669
|
+
log.warn(
|
|
1670
|
+
" This usually means the organization is on a plan that only supports branch protection on public repos (GitHub Team/Enterprise is required for private org repos). Skipping branch protection \u2014 add it later via GitHub once the plan allows it, or make the repo public."
|
|
1671
|
+
);
|
|
1672
|
+
return recordBranchProtectionOutcome({
|
|
1673
|
+
status: "skipped-403",
|
|
1674
|
+
org,
|
|
1675
|
+
repo,
|
|
1676
|
+
protectedBranches: applied,
|
|
1677
|
+
unprotectedBranches: remaining(),
|
|
1678
|
+
reason: err.message
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
if (status !== 404 || Date.now() >= deadline) throw err;
|
|
1682
|
+
log.info("Branch protection endpoint not yet ready, retrying...");
|
|
1683
|
+
await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
|
|
1604
1684
|
}
|
|
1605
|
-
if (status !== 404 || Date.now() >= deadline) throw err;
|
|
1606
|
-
log.info("Branch protection endpoint not yet ready, retrying...");
|
|
1607
|
-
await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
|
|
1608
1685
|
}
|
|
1609
1686
|
}
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
recordBranchProtectionOutcome({
|
|
1689
|
+
status: "failed",
|
|
1690
|
+
org,
|
|
1691
|
+
repo,
|
|
1692
|
+
protectedBranches: applied,
|
|
1693
|
+
unprotectedBranches: remaining(),
|
|
1694
|
+
reason: err.message
|
|
1695
|
+
});
|
|
1696
|
+
throw err;
|
|
1610
1697
|
}
|
|
1611
1698
|
log.success("Branch protection configured on dev, staging, and main");
|
|
1699
|
+
return recordBranchProtectionOutcome({
|
|
1700
|
+
status: "applied",
|
|
1701
|
+
org,
|
|
1702
|
+
repo,
|
|
1703
|
+
protectedBranches: applied,
|
|
1704
|
+
unprotectedBranches: []
|
|
1705
|
+
});
|
|
1612
1706
|
}
|
|
1613
1707
|
/**
|
|
1614
1708
|
* Protect a single branch with caller-supplied required checks (#803).
|
|
@@ -5688,11 +5782,15 @@ async function runSiblingCreateCommand(name, options) {
|
|
|
5688
5782
|
const aws = new AwsAdapter(config);
|
|
5689
5783
|
const coreAws = new AwsAdapter(coreConfig);
|
|
5690
5784
|
const git = new GitAdapter();
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5785
|
+
try {
|
|
5786
|
+
await runSiblingCreate(github, aws, coreAws, git, config, session, {
|
|
5787
|
+
coreConfig,
|
|
5788
|
+
skeletonRoot: options.templateRoot,
|
|
5789
|
+
githubToken: token
|
|
5790
|
+
});
|
|
5791
|
+
} finally {
|
|
5792
|
+
reportBranchProtectionSummary();
|
|
5793
|
+
}
|
|
5696
5794
|
const { org, repo } = githubRepo(config);
|
|
5697
5795
|
const pathPrefix = resolvePathPrefix(config);
|
|
5698
5796
|
log.success("\nSibling repo created successfully!");
|
|
@@ -5826,6 +5924,7 @@ async function runSiblingCreate(github, aws, coreAws, git, config, session, opti
|
|
|
5826
5924
|
} else {
|
|
5827
5925
|
log.step(8, totalSteps, "Already registered with the core project \u2014 skipping");
|
|
5828
5926
|
}
|
|
5927
|
+
reportBranchProtectionSummary();
|
|
5829
5928
|
deleteSiblingSession(config.project.name);
|
|
5830
5929
|
}
|
|
5831
5930
|
function resolvePathPrefix(config) {
|
|
@@ -6242,12 +6341,16 @@ var initCommand = new Command12("init").description("Scaffold a new project from
|
|
|
6242
6341
|
githubToken ??= await resolveGithubToken3(options.yes === true || Boolean(options.config));
|
|
6243
6342
|
const github = new GitHubAdapter(githubToken);
|
|
6244
6343
|
const aws = new AwsAdapter(config);
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6344
|
+
try {
|
|
6345
|
+
await runInit(github, aws, config, session, {
|
|
6346
|
+
git: new GitAdapter(),
|
|
6347
|
+
awsFor: (siblingConfig) => new AwsAdapter(siblingConfig),
|
|
6348
|
+
skeletonRoot: defaultSiblingTemplateRoot(),
|
|
6349
|
+
githubToken
|
|
6350
|
+
});
|
|
6351
|
+
} finally {
|
|
6352
|
+
reportBranchProtectionSummary();
|
|
6353
|
+
}
|
|
6251
6354
|
const { org, repo } = config.source_control.config;
|
|
6252
6355
|
const appRepo = rootSiblingProjectName(config.project.name);
|
|
6253
6356
|
log.success("\nProject initialised successfully!");
|
|
@@ -6385,6 +6488,7 @@ async function runInit(github, aws, config, session, appSibling) {
|
|
|
6385
6488
|
log.step(6, totalSteps, "Application sibling already created \u2014 skipping");
|
|
6386
6489
|
}
|
|
6387
6490
|
}
|
|
6491
|
+
reportBranchProtectionSummary();
|
|
6388
6492
|
deleteSession(config.project.name);
|
|
6389
6493
|
saveProjectConfig(config);
|
|
6390
6494
|
}
|