@bridge-ds/cli 0.2.0 → 0.3.1
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 +719 -144
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { existsSync as existsSync5 } from "node:fs";
|
|
5
5
|
import { readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
|
|
6
|
-
import { join as join5, relative as relative4, resolve as resolve3 } from "node:path";
|
|
6
|
+
import { isAbsolute, join as join5, relative as relative4, resolve as resolve3 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// ../core/src/audit/types.ts
|
|
9
9
|
var AUDIT_SCHEMA_VERSION = 1;
|
|
@@ -1115,9 +1115,9 @@ var DesignSystemIndex = class {
|
|
|
1115
1115
|
return generic ? this.make(generic, 1, 0.3) : void 0;
|
|
1116
1116
|
}
|
|
1117
1117
|
case "shadow": {
|
|
1118
|
-
const
|
|
1118
|
+
const norm4 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
|
|
1119
1119
|
const cands = this.entries(["shadow"]);
|
|
1120
|
-
const exact = cands.find((e) =>
|
|
1120
|
+
const exact = cands.find((e) => norm4(e.css) === norm4(value));
|
|
1121
1121
|
if (exact) return this.make(exact, 0, 1);
|
|
1122
1122
|
const t = shadowNums(value);
|
|
1123
1123
|
const scored = cands.map((e) => {
|
|
@@ -1766,6 +1766,226 @@ async function originRepo(root) {
|
|
|
1766
1766
|
// ../core/src/previews.ts
|
|
1767
1767
|
var PREVIEW_APP_LABELS = { docs: "Docs", storybook: "Storybook", admin: "Admin demo" };
|
|
1768
1768
|
|
|
1769
|
+
// ../core/src/teams.ts
|
|
1770
|
+
var LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
|
1771
|
+
var SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
1772
|
+
function parseOwnerEntry(raw) {
|
|
1773
|
+
if (typeof raw !== "string") return null;
|
|
1774
|
+
const s = raw.trim().replace(/^@/, "");
|
|
1775
|
+
const slash = s.indexOf("/");
|
|
1776
|
+
if (slash < 0) return LOGIN.test(s) ? { kind: "person", login: s } : null;
|
|
1777
|
+
const org = s.slice(0, slash);
|
|
1778
|
+
const slug = s.slice(slash + 1);
|
|
1779
|
+
return LOGIN.test(org) && SLUG.test(slug) ? { kind: "team", org, slug } : null;
|
|
1780
|
+
}
|
|
1781
|
+
var teamKey = (org, slug) => `@${org.toLowerCase()}/${slug.toLowerCase()}`;
|
|
1782
|
+
function ownerEntryProblems(raw) {
|
|
1783
|
+
if (raw === void 0) return [];
|
|
1784
|
+
const isObj2 = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
|
|
1785
|
+
if (!isObj2(raw)) return ['"governance.owners" must be { "design": [people or teams], "tech": [people or teams] }'];
|
|
1786
|
+
const out = [];
|
|
1787
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1788
|
+
if (k !== "design" && k !== "tech") {
|
|
1789
|
+
out.push(`"governance.owners.${k}" is not an owner group (design, tech)`);
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
if (!Array.isArray(v)) {
|
|
1793
|
+
out.push(`"governance.owners.${k}" must be a list of people or teams`);
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
v.forEach((x, i) => {
|
|
1797
|
+
if (typeof x !== "string" || !parseOwnerEntry(x))
|
|
1798
|
+
out.push(`"governance.owners.${k}[${i}]" (${JSON.stringify(x)}) is not a GitHub login or team. Write a person as "peterbono" or "@peterbono", a team as "@org/team-slug".`);
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
return out;
|
|
1802
|
+
}
|
|
1803
|
+
function teamUnreadableMessage(team, reason) {
|
|
1804
|
+
const name = `@${team.org}/${team.slug}`;
|
|
1805
|
+
switch (reason) {
|
|
1806
|
+
case "personal-account":
|
|
1807
|
+
return `${name} cannot be used: ${team.org} is a personal GitHub account, and teams only exist in organizations. List people instead, or move the repository to an organization.`;
|
|
1808
|
+
case "missing-permission":
|
|
1809
|
+
return `The members of ${name} cannot be read: the GitHub token needs read:org (classic token) or Members: Read on the ${team.org} organization (fine-grained token).`;
|
|
1810
|
+
case "no-token":
|
|
1811
|
+
return `The members of ${name} cannot be read without a working GitHub token. Connect GitHub with a token that has read:org.`;
|
|
1812
|
+
case "not-found":
|
|
1813
|
+
return `${name} was not found. Check the team slug on GitHub, or give the token read:org so it can see the team.`;
|
|
1814
|
+
case "unreachable":
|
|
1815
|
+
return `The members of ${name} cannot be read right now: GitHub did not answer. Try again in a moment.`;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
function teamsIn(owners) {
|
|
1819
|
+
const all = Array.isArray(owners) ? owners : [...owners.design ?? [], ...owners.tech ?? []];
|
|
1820
|
+
const out = /* @__PURE__ */ new Map();
|
|
1821
|
+
for (const raw of all) {
|
|
1822
|
+
const e = parseOwnerEntry(raw);
|
|
1823
|
+
if (e?.kind === "team") out.set(teamKey(e.org, e.slug), { key: teamKey(e.org, e.slug), org: e.org, slug: e.slug });
|
|
1824
|
+
}
|
|
1825
|
+
return [...out.values()];
|
|
1826
|
+
}
|
|
1827
|
+
var norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
|
|
1828
|
+
var people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
|
|
1829
|
+
function entryLabel(raw, teams) {
|
|
1830
|
+
const e = parseOwnerEntry(raw);
|
|
1831
|
+
if (!e) return raw;
|
|
1832
|
+
if (e.kind === "person") return `@${e.login}`;
|
|
1833
|
+
const state = teams?.[teamKey(e.org, e.slug)];
|
|
1834
|
+
const name = `@${e.org}/${e.slug}`;
|
|
1835
|
+
return state?.status === "ok" ? `${name} (${people(state.members.length)})` : name;
|
|
1836
|
+
}
|
|
1837
|
+
var TeamDirectory = class {
|
|
1838
|
+
constructor(reader, opts = {}) {
|
|
1839
|
+
this.reader = reader;
|
|
1840
|
+
this.ttl = opts.ttlMs ?? 5 * 6e4;
|
|
1841
|
+
this.now = opts.now ?? Date.now;
|
|
1842
|
+
}
|
|
1843
|
+
reader;
|
|
1844
|
+
teams = /* @__PURE__ */ new Map();
|
|
1845
|
+
accounts = /* @__PURE__ */ new Map();
|
|
1846
|
+
memberships = /* @__PURE__ */ new Map();
|
|
1847
|
+
ttl;
|
|
1848
|
+
now;
|
|
1849
|
+
accountType(org) {
|
|
1850
|
+
const k = org.toLowerCase();
|
|
1851
|
+
let p = this.accounts.get(k);
|
|
1852
|
+
if (!p) {
|
|
1853
|
+
p = this.reader.getAccountType(org).catch(() => null);
|
|
1854
|
+
this.accounts.set(k, p);
|
|
1855
|
+
}
|
|
1856
|
+
return p;
|
|
1857
|
+
}
|
|
1858
|
+
/** The reason a read of `org` failed, as a state. A 404 on a personal account says so; a 403 is a missing permission. */
|
|
1859
|
+
async failure(ref, err) {
|
|
1860
|
+
const e = err ?? {};
|
|
1861
|
+
let reason;
|
|
1862
|
+
if (!this.reader.hasToken) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "no-token";
|
|
1863
|
+
else if (e.status === 401) reason = "no-token";
|
|
1864
|
+
else if (e.status === 403 || e.auth === "missing-permission") reason = "missing-permission";
|
|
1865
|
+
else if (e.status === 404) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "not-found";
|
|
1866
|
+
else reason = "unreachable";
|
|
1867
|
+
return { ...ref, status: "unreadable", reason, message: teamUnreadableMessage(ref, reason) };
|
|
1868
|
+
}
|
|
1869
|
+
/** Members of one team, or why they cannot be read. Cached. */
|
|
1870
|
+
team(org, slug) {
|
|
1871
|
+
const ref = { key: teamKey(org, slug), org, slug };
|
|
1872
|
+
const hit = this.teams.get(ref.key);
|
|
1873
|
+
if (hit && this.now() - hit.at < this.ttl) return hit.value;
|
|
1874
|
+
const value = (async () => {
|
|
1875
|
+
if (!this.reader.hasToken) return this.failure(ref, void 0);
|
|
1876
|
+
try {
|
|
1877
|
+
const members = await this.reader.listTeamMembers(org, slug);
|
|
1878
|
+
return { ...ref, status: "ok", members };
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
return this.failure(ref, err);
|
|
1881
|
+
}
|
|
1882
|
+
})();
|
|
1883
|
+
this.teams.set(ref.key, { at: this.now(), value });
|
|
1884
|
+
void value.then((s) => {
|
|
1885
|
+
if (s.status === "unreadable" && s.reason === "unreachable") this.teams.delete(ref.key);
|
|
1886
|
+
});
|
|
1887
|
+
return value;
|
|
1888
|
+
}
|
|
1889
|
+
/** Every team named in the owner lists, read in parallel. Empty object when no team is named (no request). */
|
|
1890
|
+
async resolve(owners) {
|
|
1891
|
+
const refs = teamsIn(owners);
|
|
1892
|
+
const states = await Promise.all(refs.map((r) => this.team(r.org, r.slug)));
|
|
1893
|
+
return Object.fromEntries(states.map((s) => [s.key, s]));
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Whether `login` is an active member of one team: from the cached member list when there is one, else one
|
|
1897
|
+
* membership read (GET /orgs/{org}/teams/{slug}/memberships/{user}). An unreadable team returns its state.
|
|
1898
|
+
*/
|
|
1899
|
+
async isMember(org, slug, login) {
|
|
1900
|
+
const ref = { key: teamKey(org, slug), org, slug };
|
|
1901
|
+
const cached = this.teams.get(ref.key);
|
|
1902
|
+
if (cached && this.now() - cached.at < this.ttl) {
|
|
1903
|
+
const s = await cached.value;
|
|
1904
|
+
if (s.status === "ok") return s.members.some((m) => norm(m) === norm(login));
|
|
1905
|
+
}
|
|
1906
|
+
const k = `${ref.key}#${norm(login)}`;
|
|
1907
|
+
const hit = this.memberships.get(k);
|
|
1908
|
+
if (hit && this.now() - hit.at < this.ttl) return hit.value;
|
|
1909
|
+
const value = (async () => {
|
|
1910
|
+
if (!this.reader.hasToken) return this.failure(ref, void 0);
|
|
1911
|
+
try {
|
|
1912
|
+
return await this.reader.getTeamMembership(org, slug, login) === "active";
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
return this.failure(ref, err);
|
|
1915
|
+
}
|
|
1916
|
+
})();
|
|
1917
|
+
this.memberships.set(k, { at: this.now(), value });
|
|
1918
|
+
return value;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Whether `login` holds a right given to `entries` (people and teams): a person entry matches directly, a team
|
|
1922
|
+
* through its membership. Teams are only read when no person entry matches.
|
|
1923
|
+
*/
|
|
1924
|
+
async memberOfAny(entries, login) {
|
|
1925
|
+
const unreadable = [];
|
|
1926
|
+
const parsed = entries.map(parseOwnerEntry).filter((e) => Boolean(e));
|
|
1927
|
+
if (parsed.some((e) => e.kind === "person" && norm(e.login) === norm(login))) return { member: true, unreadable };
|
|
1928
|
+
for (const e of parsed) {
|
|
1929
|
+
if (e.kind !== "team") continue;
|
|
1930
|
+
const r = await this.isMember(e.org, e.slug, login);
|
|
1931
|
+
if (r === true) return { member: true, unreadable };
|
|
1932
|
+
if (typeof r === "object" && r.status === "unreadable") unreadable.push(r);
|
|
1933
|
+
}
|
|
1934
|
+
return { member: false, unreadable };
|
|
1935
|
+
}
|
|
1936
|
+
/** The organization's teams for a picker, or why they cannot be listed (a personal account has none). */
|
|
1937
|
+
async orgTeams(org) {
|
|
1938
|
+
if (await this.accountType(org) === "User") return { status: "unreadable", reason: "personal-account" };
|
|
1939
|
+
if (!this.reader.hasToken) return { status: "unreadable", reason: "no-token" };
|
|
1940
|
+
try {
|
|
1941
|
+
return { status: "ok", teams: await this.reader.listOrgTeams(org) };
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
const s = await this.failure({ key: teamKey(org, "*"), org, slug: "*" }, err);
|
|
1944
|
+
return { status: "unreadable", reason: s.status === "unreadable" ? s.reason : "unreachable" };
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/** Drops every cached read (after the token changes). */
|
|
1948
|
+
clear() {
|
|
1949
|
+
this.teams.clear();
|
|
1950
|
+
this.accounts.clear();
|
|
1951
|
+
this.memberships.clear();
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
|
|
1955
|
+
// ../core/src/rights.ts
|
|
1956
|
+
var OWNER_GROUPS = ["design", "tech"];
|
|
1957
|
+
var RIGHTS = ["approveDesign", "approveTech", "ship"];
|
|
1958
|
+
var DEFAULT_RIGHTS = { approveDesign: ["design"], approveTech: ["tech"], ship: ["tech"] };
|
|
1959
|
+
function normalizeRights(input) {
|
|
1960
|
+
const pick = (r) => {
|
|
1961
|
+
const v = input?.[r];
|
|
1962
|
+
if (!Array.isArray(v)) return [...DEFAULT_RIGHTS[r]];
|
|
1963
|
+
return OWNER_GROUPS.filter((g) => v.includes(g));
|
|
1964
|
+
};
|
|
1965
|
+
return { approveDesign: pick("approveDesign"), approveTech: pick("approveTech"), ship: pick("ship") };
|
|
1966
|
+
}
|
|
1967
|
+
function rightsProblems(raw) {
|
|
1968
|
+
if (raw === void 0) return [];
|
|
1969
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"governance.rights" must be { "approveDesign": [groups], "approveTech": [groups], "ship": [groups] }'];
|
|
1970
|
+
const out = [];
|
|
1971
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1972
|
+
if (!RIGHTS.includes(k)) out.push(`"governance.rights.${k}" is not a right (${RIGHTS.join(", ")})`);
|
|
1973
|
+
else if (!Array.isArray(v) || v.length === 0 || !v.every((g) => OWNER_GROUPS.includes(g)))
|
|
1974
|
+
out.push(`"governance.rights.${k}" must list one or more of "design", "tech"`);
|
|
1975
|
+
}
|
|
1976
|
+
return out;
|
|
1977
|
+
}
|
|
1978
|
+
function visualImpactProblems(raw) {
|
|
1979
|
+
if (raw === void 0) return [];
|
|
1980
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"visualImpact" must be { "mode": "auto" | "on-demand" }'];
|
|
1981
|
+
const out = [];
|
|
1982
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1983
|
+
if (k !== "mode") out.push(`"visualImpact.${k}" is not a setting (mode)`);
|
|
1984
|
+
else if (v !== "auto" && v !== "on-demand") out.push('"visualImpact.mode" must be "auto" or "on-demand"');
|
|
1985
|
+
}
|
|
1986
|
+
return out;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1769
1989
|
// ../core/src/config.ts
|
|
1770
1990
|
var CONFIG_FILE = "bridge.config.json";
|
|
1771
1991
|
var DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
|
|
@@ -1903,9 +2123,11 @@ function defineConfig(input) {
|
|
|
1903
2123
|
requireReason: g.override.requireReason ?? DEFAULT_OVERRIDE.requireReason,
|
|
1904
2124
|
minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
|
|
1905
2125
|
}
|
|
1906
|
-
} : {}
|
|
2126
|
+
} : {},
|
|
2127
|
+
...g.rights ? { rights: normalizeRights(g.rights) } : {}
|
|
1907
2128
|
},
|
|
1908
2129
|
...input.audit ? { audit: { ...input.audit } } : {},
|
|
2130
|
+
...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
|
|
1909
2131
|
...input.integrations ? { integrations: { ...input.integrations } } : {},
|
|
1910
2132
|
...input.previews ? {
|
|
1911
2133
|
previews: {
|
|
@@ -2012,9 +2234,7 @@ function loadConfig(source) {
|
|
|
2012
2234
|
else {
|
|
2013
2235
|
for (const k of ["protectedBranches", "labels", "guardrails"]) if (gov[k] !== void 0 && !(Array.isArray(gov[k]) && gov[k].every((x) => typeof x === "string"))) problems.push(`"governance.${k}" must be a list of strings`);
|
|
2014
2236
|
if (gov.branchPrefix !== void 0 && !isStr(gov.branchPrefix)) problems.push('"governance.branchPrefix" must be a non-empty string');
|
|
2015
|
-
|
|
2016
|
-
if (o !== void 0 && (!isObj(o) || ["design", "tech"].some((k) => o[k] !== void 0 && !(Array.isArray(o[k]) && o[k].every((x) => typeof x === "string")))))
|
|
2017
|
-
problems.push('"governance.owners" must be { "design": [logins], "tech": [logins] }');
|
|
2237
|
+
problems.push(...ownerEntryProblems(gov.owners));
|
|
2018
2238
|
const ov = gov.override;
|
|
2019
2239
|
if (ov !== void 0) {
|
|
2020
2240
|
if (!isObj(ov)) problems.push('"governance.override" must be an object');
|
|
@@ -2024,8 +2244,10 @@ function loadConfig(source) {
|
|
|
2024
2244
|
if (ov.minReasonLength !== void 0 && !(Number.isInteger(ov.minReasonLength) && ov.minReasonLength >= 0)) problems.push('"governance.override.minReasonLength" must be a whole number, 0 or more');
|
|
2025
2245
|
}
|
|
2026
2246
|
}
|
|
2247
|
+
problems.push(...rightsProblems(gov.rights));
|
|
2027
2248
|
}
|
|
2028
2249
|
}
|
|
2250
|
+
problems.push(...visualImpactProblems(d.visualImpact));
|
|
2029
2251
|
const audit2 = d.audit;
|
|
2030
2252
|
if (audit2 !== void 0) {
|
|
2031
2253
|
if (!isObj(audit2)) problems.push('"audit" must be an object');
|
|
@@ -2237,125 +2459,147 @@ import { mkdir as mkdir3, readFile as readFile3, rm as rm2, unlink, writeFile as
|
|
|
2237
2459
|
import { tmpdir } from "node:os";
|
|
2238
2460
|
import { join as join4, relative as relative3, resolve as resolve2 } from "node:path";
|
|
2239
2461
|
|
|
2240
|
-
// ../core/src/
|
|
2241
|
-
var
|
|
2242
|
-
var
|
|
2243
|
-
function
|
|
2244
|
-
const
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
const
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
var
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
if (
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
if (
|
|
2263
|
-
if (
|
|
2264
|
-
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
if (!p.
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
function
|
|
2278
|
-
const
|
|
2279
|
-
const
|
|
2280
|
-
const
|
|
2281
|
-
|
|
2282
|
-
const
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
|
|
2313
|
-
}
|
|
2314
|
-
for (const k of Object.keys(byVar)) byVar[k].sort();
|
|
2315
|
-
return { componentsDir: dir, modules, byVar, globalVars };
|
|
2316
|
-
}
|
|
2317
|
-
function impactedModules(index, input) {
|
|
2318
|
-
const reasons = /* @__PURE__ */ new Map();
|
|
2319
|
-
const add = (id, reason, indirect) => {
|
|
2320
|
-
const r = reasons.get(id);
|
|
2321
|
-
if (r) {
|
|
2322
|
-
if (!r.reasons.includes(reason)) r.reasons.push(reason);
|
|
2323
|
-
r.indirect &&= indirect;
|
|
2324
|
-
} else reasons.set(id, { reasons: [reason], indirect });
|
|
2325
|
-
};
|
|
2326
|
-
const changedVars = new Set(input.vars ?? []);
|
|
2327
|
-
for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
|
|
2328
|
-
for (const f of input.files ?? []) {
|
|
2329
|
-
const id = componentForPath(f, index.componentsDir);
|
|
2330
|
-
if (id && index.modules[id]) add(id, `file changed: ${norm(f)}`, false);
|
|
2331
|
-
}
|
|
2332
|
-
const renderedBy = /* @__PURE__ */ new Map();
|
|
2333
|
-
for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
|
|
2334
|
-
const queue = [...reasons.keys()];
|
|
2335
|
-
while (queue.length) {
|
|
2336
|
-
const id = queue.shift();
|
|
2337
|
-
for (const parent of renderedBy.get(id) ?? []) {
|
|
2338
|
-
const known = reasons.has(parent);
|
|
2339
|
-
add(parent, `renders ${id}`, true);
|
|
2340
|
-
if (!known) queue.push(parent);
|
|
2462
|
+
// ../core/src/protection.ts
|
|
2463
|
+
var PROTECTION_PARTS = ["review", "checks", "history", "deletion"];
|
|
2464
|
+
var protectionRulesetName = (defaultBranch) => `Protect the library (${defaultBranch})`;
|
|
2465
|
+
function refPattern(pattern) {
|
|
2466
|
+
const src = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
|
|
2467
|
+
return new RegExp(`^${src}$`);
|
|
2468
|
+
}
|
|
2469
|
+
function rulesetTargetsDefault(ruleset, defaultBranch) {
|
|
2470
|
+
if (ruleset.target && ruleset.target !== "branch") return false;
|
|
2471
|
+
const ref = `refs/heads/${defaultBranch}`;
|
|
2472
|
+
const hit = (p) => p === "~ALL" || p === "~DEFAULT_BRANCH" || p === ref || p === defaultBranch || refPattern(p.startsWith("refs/") ? p : `refs/heads/${p}`).test(ref);
|
|
2473
|
+
const cond = ruleset.conditions?.ref_name;
|
|
2474
|
+
if (!cond) return false;
|
|
2475
|
+
if ((cond.exclude ?? []).some(hit)) return false;
|
|
2476
|
+
return (cond.include ?? []).some(hit);
|
|
2477
|
+
}
|
|
2478
|
+
var count = (v) => typeof v === "number" ? v : 0;
|
|
2479
|
+
function rulesetParts(ruleset) {
|
|
2480
|
+
const parts = /* @__PURE__ */ new Set();
|
|
2481
|
+
if (ruleset.enforcement !== "active") return parts;
|
|
2482
|
+
for (const rule of ruleset.rules ?? []) {
|
|
2483
|
+
if (rule.type === "deletion") parts.add("deletion");
|
|
2484
|
+
if (rule.type === "non_fast_forward") parts.add("history");
|
|
2485
|
+
if (rule.type === "pull_request" && count(rule.parameters?.required_approving_review_count) >= 1) parts.add("review");
|
|
2486
|
+
if (rule.type === "required_status_checks" && Array.isArray(rule.parameters?.required_status_checks) && rule.parameters.required_status_checks.length > 0) parts.add("checks");
|
|
2487
|
+
}
|
|
2488
|
+
return parts;
|
|
2489
|
+
}
|
|
2490
|
+
function classicParts(p) {
|
|
2491
|
+
const parts = /* @__PURE__ */ new Set();
|
|
2492
|
+
if (count(p.required_pull_request_reviews?.required_approving_review_count) >= 1) parts.add("review");
|
|
2493
|
+
if (((p.required_status_checks?.contexts?.length ?? 0) || (p.required_status_checks?.checks?.length ?? 0)) > 0) parts.add("checks");
|
|
2494
|
+
if (!p.allow_force_pushes?.enabled) parts.add("history");
|
|
2495
|
+
if (!p.allow_deletions?.enabled) parts.add("deletion");
|
|
2496
|
+
return parts;
|
|
2497
|
+
}
|
|
2498
|
+
var rulesetUrl = (r) => r.html_url ?? r._links?.html?.href;
|
|
2499
|
+
function protectionState(rulesets, branchProtection, defaultBranch) {
|
|
2500
|
+
const bridge = rulesets?.find((r) => r.name === protectionRulesetName(defaultBranch));
|
|
2501
|
+
const extra = bridge?.id !== void 0 ? { bridgeRulesetId: bridge.id } : {};
|
|
2502
|
+
const covered = /* @__PURE__ */ new Set();
|
|
2503
|
+
let url;
|
|
2504
|
+
for (const r of rulesets ?? []) {
|
|
2505
|
+
if (!rulesetTargetsDefault(r, defaultBranch)) continue;
|
|
2506
|
+
const parts = rulesetParts(r);
|
|
2507
|
+
if (parts.size && !url) url = rulesetUrl(r);
|
|
2508
|
+
parts.forEach((p) => covered.add(p));
|
|
2509
|
+
}
|
|
2510
|
+
if (branchProtection) classicParts(branchProtection).forEach((p) => covered.add(p));
|
|
2511
|
+
const missing = PROTECTION_PARTS.filter((p) => !covered.has(p));
|
|
2512
|
+
const withUrl = url ? { url } : {};
|
|
2513
|
+
if (!missing.length) return { kind: "protected", missing, ...extra, ...withUrl };
|
|
2514
|
+
const unreadable = rulesets == null || branchProtection === void 0;
|
|
2515
|
+
if (covered.size === 0) return { kind: unreadable ? "unknown" : "unprotected", missing, ...extra };
|
|
2516
|
+
return { kind: "partial", missing, ...extra, ...withUrl };
|
|
2517
|
+
}
|
|
2518
|
+
var PROTECTION_PART_PHRASE = {
|
|
2519
|
+
review: "changes can skip review",
|
|
2520
|
+
checks: "checks do not have to pass",
|
|
2521
|
+
history: "past changes can be rewritten",
|
|
2522
|
+
deletion: "the library can be deleted"
|
|
2523
|
+
};
|
|
2524
|
+
var joinAnd = (xs) => xs.length <= 1 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
|
|
2525
|
+
function protectionSentence(state) {
|
|
2526
|
+
switch (state.kind) {
|
|
2527
|
+
case "protected":
|
|
2528
|
+
return "Protected: every change goes through review.";
|
|
2529
|
+
case "unprotected":
|
|
2530
|
+
return "Your library can be changed without review.";
|
|
2531
|
+
case "partial": {
|
|
2532
|
+
const s = joinAnd(state.missing.map((p) => PROTECTION_PART_PHRASE[p]));
|
|
2533
|
+
return `Partly protected: ${s}.`;
|
|
2341
2534
|
}
|
|
2535
|
+
default:
|
|
2536
|
+
return "Protection status unknown: GitHub did not let this token read it.";
|
|
2342
2537
|
}
|
|
2343
|
-
const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
|
|
2344
|
-
const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
|
|
2345
|
-
return { modules, globalHits };
|
|
2346
2538
|
}
|
|
2347
2539
|
|
|
2348
2540
|
// ../core/src/github-client.ts
|
|
2349
2541
|
var GitHubError = class extends Error {
|
|
2350
|
-
constructor(status, message, detail) {
|
|
2542
|
+
constructor(status, message, detail, info = {}) {
|
|
2351
2543
|
super(message);
|
|
2352
2544
|
this.status = status;
|
|
2353
2545
|
this.detail = detail;
|
|
2354
2546
|
this.name = "GitHubError";
|
|
2547
|
+
this.auth = info.auth;
|
|
2548
|
+
this.permission = info.permission;
|
|
2355
2549
|
}
|
|
2356
2550
|
status;
|
|
2357
2551
|
detail;
|
|
2552
|
+
/** Set when the failure is about the token: rejected (401) or missing a permission (403). */
|
|
2553
|
+
auth;
|
|
2554
|
+
/** With auth "missing-permission": the permission the call needed, when known. */
|
|
2555
|
+
permission;
|
|
2556
|
+
};
|
|
2557
|
+
var PERMISSION_LABELS = {
|
|
2558
|
+
issues: "Issues",
|
|
2559
|
+
pull_requests: "Pull requests",
|
|
2560
|
+
contents: "Contents",
|
|
2561
|
+
checks: "Checks",
|
|
2562
|
+
statuses: "Commit statuses",
|
|
2563
|
+
metadata: "Metadata",
|
|
2564
|
+
actions: "Actions",
|
|
2565
|
+
deployments: "Deployments",
|
|
2566
|
+
workflows: "Workflows",
|
|
2567
|
+
administration: "Administration"
|
|
2358
2568
|
};
|
|
2569
|
+
function permissionLabel(permission) {
|
|
2570
|
+
return PERMISSION_LABELS[permission] ?? permission.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
2571
|
+
}
|
|
2572
|
+
var TOKEN_REJECTED_MESSAGE = "Your GitHub token stopped working. It was revoked, regenerated or has expired: replace it with a new one.";
|
|
2573
|
+
function missingPermissionMessage(permission, action) {
|
|
2574
|
+
const what = action ? `This token cannot ${action}.` : "This token is missing a permission for this.";
|
|
2575
|
+
if (!permission) return `${what} Check its repository permissions on GitHub (Contents and Pull requests need Read and write).`;
|
|
2576
|
+
return `${what} Add ${permissionLabel(permission)}: Read and write on GitHub.`;
|
|
2577
|
+
}
|
|
2578
|
+
function requiredPermission(path, acceptedHeader) {
|
|
2579
|
+
if (acceptedHeader) {
|
|
2580
|
+
const first = acceptedHeader.split(";")[0].split(",").map((p2) => p2.trim().split("=")[0].trim()).filter((p2) => p2 && p2 !== "metadata");
|
|
2581
|
+
if (first.length) return first[0];
|
|
2582
|
+
}
|
|
2583
|
+
const p = path.replace(/[?#].*$/, "");
|
|
2584
|
+
if (/\/repos\/[^/]+\/[^/]+\/(issues|labels|milestones)(\/|$)/.test(p)) return "issues";
|
|
2585
|
+
if (/\/repos\/[^/]+\/[^/]+\/pulls(\/|$)/.test(p)) return "pull_requests";
|
|
2586
|
+
if (/\/repos\/[^/]+\/[^/]+\/(rulesets(\/|$)|branches\/.+\/protection$)/.test(p)) return "administration";
|
|
2587
|
+
if (/\/repos\/[^/]+\/[^/]+\/(contents|git|commits|branches|merges)(\/|$)/.test(p)) return "contents";
|
|
2588
|
+
if (/\/repos\/[^/]+\/[^/]+\/(check-runs|check-suites)(\/|$)/.test(p)) return "checks";
|
|
2589
|
+
if (/\/repos\/[^/]+\/[^/]+\/statuses(\/|$)/.test(p)) return "statuses";
|
|
2590
|
+
if (/\/repos\/[^/]+\/[^/]+\/deployments(\/|$)/.test(p)) return "deployments";
|
|
2591
|
+
if (/\/repos\/[^/]+\/[^/]+\/actions(\/|$)/.test(p)) return "actions";
|
|
2592
|
+
return void 0;
|
|
2593
|
+
}
|
|
2594
|
+
var PERMISSION_DENIED = /not accessible by (personal access token|integration)|must have (admin|push|write) (rights|access)|write access to repository not granted|permission to .* denied/i;
|
|
2595
|
+
function classifyAuth(status, detail, opts = {}) {
|
|
2596
|
+
if (status === 401 && opts.withToken !== false) return { auth: "rejected" };
|
|
2597
|
+
if (status !== 403 || /rate limit|archived/i.test(detail)) return {};
|
|
2598
|
+
if (opts.acceptedPermissions || PERMISSION_DENIED.test(detail)) {
|
|
2599
|
+
return { auth: "missing-permission", permission: requiredPermission(opts.path ?? "", opts.acceptedPermissions) };
|
|
2600
|
+
}
|
|
2601
|
+
return {};
|
|
2602
|
+
}
|
|
2359
2603
|
var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 600, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
|
|
2360
2604
|
var isTransient = (status, detail) => status === 0 || status >= 500 || status === 429 || status === 403 && /secondary rate limit/i.test(detail);
|
|
2361
2605
|
var API = "https://api.github.com";
|
|
@@ -2417,7 +2661,7 @@ var GitHubClient = class _GitHubClient {
|
|
|
2417
2661
|
}
|
|
2418
2662
|
}
|
|
2419
2663
|
assertToken(what) {
|
|
2420
|
-
if (!this.token) throw new GitHubError(401, `${what} needs a GitHub token (contents and pull requests, read and write, on this repository only). Without one Bridge is read-only
|
|
2664
|
+
if (!this.token) throw new GitHubError(401, `${what} needs a GitHub token (contents and pull requests, read and write, on this repository only). Without one Bridge is read-only.`, "no-token");
|
|
2421
2665
|
}
|
|
2422
2666
|
get repoPath() {
|
|
2423
2667
|
return `/repos/${this.owner}/${this.name}`;
|
|
@@ -2466,7 +2710,12 @@ var GitHubClient = class _GitHubClient {
|
|
|
2466
2710
|
detail
|
|
2467
2711
|
);
|
|
2468
2712
|
}
|
|
2469
|
-
|
|
2713
|
+
const info = classifyAuth(res.status, detail, {
|
|
2714
|
+
path,
|
|
2715
|
+
acceptedPermissions: res.headers?.get("x-accepted-github-permissions"),
|
|
2716
|
+
withToken: Boolean(this.token)
|
|
2717
|
+
});
|
|
2718
|
+
throw new GitHubError(res.status, friendlyMessage(res.status, detail, info), detail, info);
|
|
2470
2719
|
}
|
|
2471
2720
|
return await res.json();
|
|
2472
2721
|
}
|
|
@@ -2616,6 +2865,25 @@ var GitHubClient = class _GitHubClient {
|
|
|
2616
2865
|
throw new GitHubError(422, `GitHub refused to mark #${n} ready for review${detail ? `: ${detail}` : "."} Use "Ready for review" on GitHub instead.`, detail);
|
|
2617
2866
|
}
|
|
2618
2867
|
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Turns an open pull request back into a draft: the GraphQL mutation convertPullRequestToDraft (REST cannot set
|
|
2870
|
+
* `draft` either), same token. No-op when it is already a draft.
|
|
2871
|
+
*/
|
|
2872
|
+
async convertToDraft(n) {
|
|
2873
|
+
this.assertToken("Turning a pull request back into a draft");
|
|
2874
|
+
const pr = await this.request("GET", `${this.repoPath}/pulls/${n}`);
|
|
2875
|
+
if (pr.draft === true) return;
|
|
2876
|
+
if (pr.state && pr.state !== "open") throw new GitHubError(422, `Pull request #${n} is ${pr.state}: only an open one can go back to draft.`);
|
|
2877
|
+
if (!pr.node_id) throw new GitHubError(422, `Pull request #${n} has no node id: convert it to draft on GitHub.`);
|
|
2878
|
+
const r = await this.request("POST", "/graphql", {
|
|
2879
|
+
query: "mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { number isDraft } } }",
|
|
2880
|
+
variables: { id: pr.node_id }
|
|
2881
|
+
});
|
|
2882
|
+
if (r?.errors?.length) {
|
|
2883
|
+
const detail = r.errors.map((e) => e.message).filter(Boolean).join(" ");
|
|
2884
|
+
throw new GitHubError(422, `GitHub refused to turn #${n} back into a draft${detail ? `: ${detail}` : "."} Use "Convert to draft" on GitHub instead.`, detail);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2619
2887
|
async createPullRequest(input) {
|
|
2620
2888
|
this.assertWritableBranch(input.head, input.base);
|
|
2621
2889
|
const r = await this.request(
|
|
@@ -2645,6 +2913,11 @@ var GitHubClient = class _GitHubClient {
|
|
|
2645
2913
|
async addComment(issueNumber2, body) {
|
|
2646
2914
|
await this.request("POST", `${this.repoPath}/issues/${issueNumber2}/comments`, { body });
|
|
2647
2915
|
}
|
|
2916
|
+
/** Comments on an issue or pull request, oldest first (first 100: the sticky preview comment is posted early). */
|
|
2917
|
+
async listIssueComments(issueNumber2) {
|
|
2918
|
+
const r = await this.request("GET", `${this.repoPath}/issues/${issueNumber2}/comments?per_page=100`);
|
|
2919
|
+
return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", updatedAt: c.updated_at }));
|
|
2920
|
+
}
|
|
2648
2921
|
// --- Issues (component change requests) ---------------------------------------------------
|
|
2649
2922
|
async createIssue(input) {
|
|
2650
2923
|
const r = await this.request("POST", `${this.repoPath}/issues`, {
|
|
@@ -2665,6 +2938,67 @@ var GitHubClient = class _GitHubClient {
|
|
|
2665
2938
|
async getIssue(n) {
|
|
2666
2939
|
return toIssue(await this.request("GET", `${this.repoPath}/issues/${n}`));
|
|
2667
2940
|
}
|
|
2941
|
+
/** Edits an issue: its body, or closes it with a reason ("not_planned" for a declined request). */
|
|
2942
|
+
async updateIssue(n, patch) {
|
|
2943
|
+
this.assertToken("Editing an issue");
|
|
2944
|
+
return toIssue(await this.request("PATCH", `${this.repoPath}/issues/${n}`, patch));
|
|
2945
|
+
}
|
|
2946
|
+
/** Adds people to an issue's assignees (GitHub ignores logins that cannot be assigned: the result says who is). */
|
|
2947
|
+
async addAssignees(n, logins) {
|
|
2948
|
+
this.assertToken("Assigning an issue");
|
|
2949
|
+
const r = await this.request("POST", `${this.repoPath}/issues/${n}/assignees`, { assignees: logins });
|
|
2950
|
+
return (r.assignees ?? []).map((a) => a.login);
|
|
2951
|
+
}
|
|
2952
|
+
/** People with at least `permission` on the repository (reading the list needs push access). First 100. */
|
|
2953
|
+
async listCollaborators(permission = "push") {
|
|
2954
|
+
const r = await this.request("GET", `${this.repoPath}/collaborators?permission=${permission}&per_page=100`);
|
|
2955
|
+
return r.map((c) => ({ login: c.login, avatarUrl: c.avatar_url ?? "" }));
|
|
2956
|
+
}
|
|
2957
|
+
/** Whether a token is sent with each request (TeamReader). */
|
|
2958
|
+
get hasToken() {
|
|
2959
|
+
return Boolean(this.token);
|
|
2960
|
+
}
|
|
2961
|
+
/** Every page of a list endpoint (per_page 100), up to `maxPages`. */
|
|
2962
|
+
async paginate(path, maxPages = 20) {
|
|
2963
|
+
const out = [];
|
|
2964
|
+
const sep3 = path.includes("?") ? "&" : "?";
|
|
2965
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
2966
|
+
const r = await this.request("GET", `${path}${sep3}per_page=100&page=${page}`);
|
|
2967
|
+
out.push(...r);
|
|
2968
|
+
if (r.length < 100) break;
|
|
2969
|
+
}
|
|
2970
|
+
return out;
|
|
2971
|
+
}
|
|
2972
|
+
/** Every member login of an organization team (needs read:org, or Members: Read on a fine-grained token). */
|
|
2973
|
+
async listTeamMembers(org, slug) {
|
|
2974
|
+
const r = await this.paginate(`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(slug)}/members`);
|
|
2975
|
+
return r.map((m) => m.login);
|
|
2976
|
+
}
|
|
2977
|
+
/** A user's membership of a team: "active", "pending", or null when GitHub says they are not a member (404). */
|
|
2978
|
+
async getTeamMembership(org, slug, login) {
|
|
2979
|
+
try {
|
|
2980
|
+
const r = await this.request("GET", `/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(slug)}/memberships/${encodeURIComponent(login)}`);
|
|
2981
|
+
return r.state === "active" ? "active" : "pending";
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
if (err instanceof GitHubError && err.status === 404) return null;
|
|
2984
|
+
throw err;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
/** "User" or "Organization" for a login, null when GitHub does not know it. */
|
|
2988
|
+
async getAccountType(login) {
|
|
2989
|
+
try {
|
|
2990
|
+
const r = await this.request("GET", `/users/${encodeURIComponent(login)}`);
|
|
2991
|
+
return r.type === "Organization" ? "Organization" : r.type === "User" ? "User" : null;
|
|
2992
|
+
} catch (err) {
|
|
2993
|
+
if (err instanceof GitHubError && err.status === 404) return null;
|
|
2994
|
+
throw err;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
/** The organization's teams this token can see. */
|
|
2998
|
+
async listOrgTeams(org) {
|
|
2999
|
+
const r = await this.paginate(`/orgs/${encodeURIComponent(org)}/teams`);
|
|
3000
|
+
return r.map((t) => ({ slug: t.slug, name: t.name }));
|
|
3001
|
+
}
|
|
2668
3002
|
/** Create or update a binary file (base64 content) on a branch, e.g. a Figma export attached to a request. */
|
|
2669
3003
|
async putBase64File(input) {
|
|
2670
3004
|
this.assertWritableBranch(input.branch);
|
|
@@ -2800,6 +3134,27 @@ var GitHubClient = class _GitHubClient {
|
|
|
2800
3134
|
const pending = checks.some((c) => c.status !== "completed");
|
|
2801
3135
|
return { state: !checks.length ? "none" : failing ? "failure" : pending ? "pending" : "success", checks };
|
|
2802
3136
|
}
|
|
3137
|
+
/** Commit statuses of a commit, newest first (Vercel reports each project as "Vercel – <project>"). */
|
|
3138
|
+
async listCommitStatuses(sha) {
|
|
3139
|
+
const r = await this.request("GET", `${this.repoPath}/commits/${encodeRef(sha)}/statuses?per_page=100`);
|
|
3140
|
+
return r.map((s) => ({ context: s.context, state: s.state, target_url: s.target_url ?? null, description: s.description ?? null }));
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* GitHub deployments of a commit with their latest status (environment "Preview – <project>", environment_url the
|
|
3144
|
+
* preview). Latest per environment only, so it costs one read plus one per environment.
|
|
3145
|
+
*/
|
|
3146
|
+
async listDeployments(sha) {
|
|
3147
|
+
const list2 = await this.request("GET", `${this.repoPath}/deployments?sha=${encodeURIComponent(sha)}&per_page=30`);
|
|
3148
|
+
const latest = /* @__PURE__ */ new Map();
|
|
3149
|
+
for (const d of list2) if (!latest.has(d.environment)) latest.set(d.environment, d);
|
|
3150
|
+
return Promise.all(
|
|
3151
|
+
[...latest.values()].map(async (d) => {
|
|
3152
|
+
const st = await this.request("GET", `${this.repoPath}/deployments/${d.id}/statuses?per_page=1`).catch(() => []);
|
|
3153
|
+
const s = st[0];
|
|
3154
|
+
return { id: d.id, environment: d.environment, state: s?.state ?? null, environmentUrl: s?.environment_url || null };
|
|
3155
|
+
})
|
|
3156
|
+
);
|
|
3157
|
+
}
|
|
2803
3158
|
async getPullRequest(n) {
|
|
2804
3159
|
const r = await this.request("GET", `${this.repoPath}/pulls/${n}`);
|
|
2805
3160
|
return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null };
|
|
@@ -2826,7 +3181,44 @@ var GitHubClient = class _GitHubClient {
|
|
|
2826
3181
|
"GET",
|
|
2827
3182
|
`${this.repoPath}/commits/${encodeRef(ref)}/check-runs`
|
|
2828
3183
|
);
|
|
2829
|
-
return r.check_runs.map((c) => ({ id: c.id, name: c.name, status: c.status, conclusion: c.conclusion, url: c.html_url }));
|
|
3184
|
+
return r.check_runs.map((c) => ({ id: c.id, name: c.name, status: c.status, conclusion: c.conclusion, url: c.html_url, ...c.app?.slug ? { app: c.app.slug } : {} }));
|
|
3185
|
+
}
|
|
3186
|
+
/** Rulesets that apply to the repository, without their rules (GET /rulesets; read access is enough). */
|
|
3187
|
+
async listRulesets() {
|
|
3188
|
+
const r = await this.request("GET", `${this.repoPath}/rulesets?includes_parents=true&per_page=100`);
|
|
3189
|
+
return r.map(withRulesetUrl);
|
|
3190
|
+
}
|
|
3191
|
+
/** One ruleset with its rules. */
|
|
3192
|
+
async getRuleset(id) {
|
|
3193
|
+
return withRulesetUrl(await this.request("GET", `${this.repoPath}/rulesets/${id}`));
|
|
3194
|
+
}
|
|
3195
|
+
/** Creates a repository ruleset. Needs admin on the repo (fine-grained token: Administration read and write). */
|
|
3196
|
+
async createRuleset(payload) {
|
|
3197
|
+
this.assertToken("Protecting the library");
|
|
3198
|
+
return withRulesetUrl(await this.request("POST", `${this.repoPath}/rulesets`, payload));
|
|
3199
|
+
}
|
|
3200
|
+
/** Replaces a repository ruleset (same permissions as createRuleset). */
|
|
3201
|
+
async updateRuleset(id, payload) {
|
|
3202
|
+
this.assertToken("Protecting the library");
|
|
3203
|
+
return withRulesetUrl(await this.request("PUT", `${this.repoPath}/rulesets/${id}`, payload));
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* Classic branch protection of `branch`: the settings, null when the branch has none (404), undefined when this
|
|
3207
|
+
* token cannot read them (403: the endpoint needs admin). On a 403 the public branch summary still tells
|
|
3208
|
+
* whether the branch is protected at all: `protected: false` means there is nothing to read, so null.
|
|
3209
|
+
*/
|
|
3210
|
+
async getBranchProtection(branch) {
|
|
3211
|
+
try {
|
|
3212
|
+
return await this.request("GET", `${this.repoPath}/branches/${encodeRef(branch)}/protection`);
|
|
3213
|
+
} catch (err) {
|
|
3214
|
+
if (!(err instanceof GitHubError)) throw err;
|
|
3215
|
+
if (err.status === 404) return null;
|
|
3216
|
+
if (err.status === 403 || err.status === 401) {
|
|
3217
|
+
const b = await this.request("GET", `${this.repoPath}/branches/${encodeRef(branch)}`).catch(() => void 0);
|
|
3218
|
+
return b?.protected === false ? null : void 0;
|
|
3219
|
+
}
|
|
3220
|
+
throw err;
|
|
3221
|
+
}
|
|
2830
3222
|
}
|
|
2831
3223
|
/** Create a check run (needs a GitHub App token; personal tokens are refused by GitHub). */
|
|
2832
3224
|
async createCheckRun(input) {
|
|
@@ -2868,7 +3260,9 @@ function toSummary(r) {
|
|
|
2868
3260
|
...typeof r.body === "string" ? { body: r.body } : {}
|
|
2869
3261
|
};
|
|
2870
3262
|
}
|
|
2871
|
-
function friendlyMessage(status, detail) {
|
|
3263
|
+
function friendlyMessage(status, detail, info = {}) {
|
|
3264
|
+
if (info.auth === "rejected") return TOKEN_REJECTED_MESSAGE;
|
|
3265
|
+
if (info.auth === "missing-permission") return missingPermissionMessage(info.permission);
|
|
2872
3266
|
switch (status) {
|
|
2873
3267
|
case 401:
|
|
2874
3268
|
return "GitHub rejected the token. It may be expired or mistyped: create a new one and save it in Settings.";
|
|
@@ -2888,6 +3282,10 @@ function friendlyMessage(status, detail) {
|
|
|
2888
3282
|
return `GitHub error ${status}${detail ? `: ${detail}` : ""}`;
|
|
2889
3283
|
}
|
|
2890
3284
|
}
|
|
3285
|
+
function withRulesetUrl(r) {
|
|
3286
|
+
const href = r._links?.html?.href;
|
|
3287
|
+
return href && !r.html_url ? { ...r, html_url: href } : r;
|
|
3288
|
+
}
|
|
2891
3289
|
function toIssue(r) {
|
|
2892
3290
|
return {
|
|
2893
3291
|
number: r.number,
|
|
@@ -2903,6 +3301,122 @@ function toIssue(r) {
|
|
|
2903
3301
|
};
|
|
2904
3302
|
}
|
|
2905
3303
|
|
|
3304
|
+
// ../core/src/protection-github.ts
|
|
3305
|
+
async function loadProtection(client, defaultBranch) {
|
|
3306
|
+
const rulesets = await client.listRulesets().then((list2) => Promise.all(list2.map((r) => r.enforcement === "disabled" || r.id === void 0 ? r : client.getRuleset(r.id)))).catch(() => null);
|
|
3307
|
+
const classic = await client.getBranchProtection(defaultBranch).catch(() => void 0);
|
|
3308
|
+
return protectionState(rulesets, classic, defaultBranch);
|
|
3309
|
+
}
|
|
3310
|
+
var rulesSettingsUrl = (owner, repo) => `https://github.com/${owner}/${repo}/settings/rules`;
|
|
3311
|
+
|
|
3312
|
+
// ../core/src/usage.ts
|
|
3313
|
+
var VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
|
3314
|
+
var IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
3315
|
+
function cssVarsIn(text) {
|
|
3316
|
+
const out = /* @__PURE__ */ new Set();
|
|
3317
|
+
for (const m of text.matchAll(VAR_RE)) out.add(m[1]);
|
|
3318
|
+
return [...out];
|
|
3319
|
+
}
|
|
3320
|
+
function importsIn(text) {
|
|
3321
|
+
const out = /* @__PURE__ */ new Set();
|
|
3322
|
+
for (const m of text.matchAll(IMPORT_RE)) out.add(m[1] ?? m[2]);
|
|
3323
|
+
return [...out];
|
|
3324
|
+
}
|
|
3325
|
+
var norm2 = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
3326
|
+
var isStory = (p) => /\.stories\.[cm]?[jt]sx?$/.test(p);
|
|
3327
|
+
var isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
|
|
3328
|
+
var isCss = (p) => /\.(css|scss|less)$/.test(p);
|
|
3329
|
+
var isScript = (p) => /\.[cm]?[jt]sx?$/.test(p);
|
|
3330
|
+
function componentOf(from, spec, componentsDir) {
|
|
3331
|
+
if (!spec.startsWith(".")) return null;
|
|
3332
|
+
const parts = from.split("/").slice(0, -1);
|
|
3333
|
+
for (const seg of spec.split("/")) {
|
|
3334
|
+
if (seg === "." || seg === "") continue;
|
|
3335
|
+
if (seg === "..") parts.pop();
|
|
3336
|
+
else parts.push(seg);
|
|
3337
|
+
}
|
|
3338
|
+
const abs = parts.join("/");
|
|
3339
|
+
if (!abs.startsWith(`${componentsDir}/`)) return null;
|
|
3340
|
+
return abs.slice(componentsDir.length + 1).split("/")[0] || null;
|
|
3341
|
+
}
|
|
3342
|
+
function componentForPath(path, componentsDir) {
|
|
3343
|
+
const dir = norm2(componentsDir);
|
|
3344
|
+
const p = norm2(path);
|
|
3345
|
+
if (!p.startsWith(`${dir}/`)) return null;
|
|
3346
|
+
const rest = p.slice(dir.length + 1).split("/");
|
|
3347
|
+
return rest.length >= 2 ? rest[0] : null;
|
|
3348
|
+
}
|
|
3349
|
+
function buildUsageIndex(files, componentsDir) {
|
|
3350
|
+
const dir = norm2(componentsDir);
|
|
3351
|
+
const modules = {};
|
|
3352
|
+
const vars = /* @__PURE__ */ new Map();
|
|
3353
|
+
const imports = /* @__PURE__ */ new Map();
|
|
3354
|
+
const storyImports = /* @__PURE__ */ new Map();
|
|
3355
|
+
const globalVars = {};
|
|
3356
|
+
const get = (m, k) => m.get(k) ?? m.set(k, /* @__PURE__ */ new Set()).get(k);
|
|
3357
|
+
for (const f of files) {
|
|
3358
|
+
const path = norm2(f.path);
|
|
3359
|
+
const id = componentForPath(path, dir);
|
|
3360
|
+
if (!id) {
|
|
3361
|
+
if (isCss(path)) {
|
|
3362
|
+
const v = cssVarsIn(f.text);
|
|
3363
|
+
if (v.length) globalVars[path] = v.sort();
|
|
3364
|
+
}
|
|
3365
|
+
continue;
|
|
3366
|
+
}
|
|
3367
|
+
const mod = modules[id] ??= { id, dir: `${dir}/${id}`, files: [], vars: [], imports: [], storyImports: [] };
|
|
3368
|
+
mod.files.push(path);
|
|
3369
|
+
if (isTest(path)) continue;
|
|
3370
|
+
for (const v of cssVarsIn(f.text)) get(vars, id).add(v);
|
|
3371
|
+
if (!isScript(path)) continue;
|
|
3372
|
+
for (const spec of importsIn(f.text)) {
|
|
3373
|
+
const dep = componentOf(path, spec, dir);
|
|
3374
|
+
if (!dep || dep === id) continue;
|
|
3375
|
+
get(isStory(path) ? storyImports : imports, id).add(dep);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
const byVar = {};
|
|
3379
|
+
for (const mod of Object.values(modules)) {
|
|
3380
|
+
mod.files.sort();
|
|
3381
|
+
mod.vars = [...vars.get(mod.id) ?? []].sort();
|
|
3382
|
+
mod.imports = [...imports.get(mod.id) ?? []].sort();
|
|
3383
|
+
mod.storyImports = [...storyImports.get(mod.id) ?? []].filter((d) => !mod.imports.includes(d)).sort();
|
|
3384
|
+
for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
|
|
3385
|
+
}
|
|
3386
|
+
for (const k of Object.keys(byVar)) byVar[k].sort();
|
|
3387
|
+
return { componentsDir: dir, modules, byVar, globalVars };
|
|
3388
|
+
}
|
|
3389
|
+
function impactedModules(index, input) {
|
|
3390
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
3391
|
+
const add = (id, reason, indirect) => {
|
|
3392
|
+
const r = reasons.get(id);
|
|
3393
|
+
if (r) {
|
|
3394
|
+
if (!r.reasons.includes(reason)) r.reasons.push(reason);
|
|
3395
|
+
r.indirect &&= indirect;
|
|
3396
|
+
} else reasons.set(id, { reasons: [reason], indirect });
|
|
3397
|
+
};
|
|
3398
|
+
const changedVars = new Set(input.vars ?? []);
|
|
3399
|
+
for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
|
|
3400
|
+
for (const f of input.files ?? []) {
|
|
3401
|
+
const id = componentForPath(f, index.componentsDir);
|
|
3402
|
+
if (id && index.modules[id]) add(id, `file changed: ${norm2(f)}`, false);
|
|
3403
|
+
}
|
|
3404
|
+
const renderedBy = /* @__PURE__ */ new Map();
|
|
3405
|
+
for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
|
|
3406
|
+
const queue = [...reasons.keys()];
|
|
3407
|
+
while (queue.length) {
|
|
3408
|
+
const id = queue.shift();
|
|
3409
|
+
for (const parent of renderedBy.get(id) ?? []) {
|
|
3410
|
+
const known = reasons.has(parent);
|
|
3411
|
+
add(parent, `renders ${id}`, true);
|
|
3412
|
+
if (!known) queue.push(parent);
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
|
|
3416
|
+
const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
|
|
3417
|
+
return { modules, globalHits };
|
|
3418
|
+
}
|
|
3419
|
+
|
|
2906
3420
|
// ../core/src/explorations.ts
|
|
2907
3421
|
var EXPLORATION_USE_CASES = [
|
|
2908
3422
|
{ value: "user-test", label: "Prototype for a user test", hint: "A coded prototype on a product staging branch, with the real components and tokens." },
|
|
@@ -2917,7 +3431,8 @@ var EXPLORATION_STATUS_LABEL = { active: "Running", promoted: "Promoted to revie
|
|
|
2917
3431
|
var REQUEST_KINDS = [
|
|
2918
3432
|
{ value: "new-component", label: "New component", hint: "A component the design system does not have yet." },
|
|
2919
3433
|
{ value: "new-variant", label: "New variant", hint: "A size, tone or state the component does not have yet." },
|
|
2920
|
-
{ value: "prop-change", label: "
|
|
3434
|
+
{ value: "prop-change", label: "Option change (prop)", hint: "Add, rename or change an option (a prop in code) or its default." },
|
|
3435
|
+
{ value: "structure", label: "Structure change", hint: "A part was added, removed or moved." },
|
|
2921
3436
|
{ value: "visual", label: "Visual change", hint: "Spacing, radius, layout or icon placement." },
|
|
2922
3437
|
{ value: "token-binding", label: "Token change", hint: "The component should paint with another token." },
|
|
2923
3438
|
{ value: "bug", label: "Bug", hint: "It looks or behaves differently from the guidance." },
|
|
@@ -2949,6 +3464,7 @@ function jiraIssueFields(r, jira, issueUrl) {
|
|
|
2949
3464
|
version: 1,
|
|
2950
3465
|
content: [
|
|
2951
3466
|
para(text(r.summary.trim() || r.title)),
|
|
3467
|
+
...r.line?.trim() ? [para(text(r.line.trim()))] : [],
|
|
2952
3468
|
...r.changes.map((c) => para(text(`${c.field}: ${c.before ?? "(none)"} -> ${c.after ?? "(removed)"}`))),
|
|
2953
3469
|
para({ type: "text", text: "GitHub request", marks: [{ type: "link", attrs: { href: issueUrl } }] })
|
|
2954
3470
|
]
|
|
@@ -3213,14 +3729,14 @@ function union(a, b) {
|
|
|
3213
3729
|
// ../visual/src/manifest.ts
|
|
3214
3730
|
var COMMENT_MARKER = "<!-- bridge-visual-impact -->";
|
|
3215
3731
|
function summarize(shots) {
|
|
3216
|
-
const
|
|
3732
|
+
const count2 = (s) => shots.filter((x) => x.status === s).length;
|
|
3217
3733
|
return {
|
|
3218
3734
|
shots: shots.length,
|
|
3219
|
-
changed:
|
|
3220
|
-
unchanged:
|
|
3221
|
-
added:
|
|
3222
|
-
removed:
|
|
3223
|
-
errors:
|
|
3735
|
+
changed: count2("changed"),
|
|
3736
|
+
unchanged: count2("unchanged"),
|
|
3737
|
+
added: count2("added"),
|
|
3738
|
+
removed: count2("removed"),
|
|
3739
|
+
errors: count2("error"),
|
|
3224
3740
|
maxPercent: shots.reduce((m, s) => Math.max(m, s.percent), 0)
|
|
3225
3741
|
};
|
|
3226
3742
|
}
|
|
@@ -3356,17 +3872,17 @@ var shotKey = (...parts) => parts.map((p) => p.toLowerCase().replace(/[^a-z0-9-]
|
|
|
3356
3872
|
|
|
3357
3873
|
// ../visual/src/select.ts
|
|
3358
3874
|
var NO_VISUAL = /(\.test\.[cm]?[jt]sx?|\.spec\.[cm]?[jt]sx?|\.docs\.json|\.mdx?|README[^/]*)$/i;
|
|
3359
|
-
var
|
|
3875
|
+
var norm3 = (p) => p.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
3360
3876
|
var isAllStates = (s) => /--all-states$/.test(s.id) || /^all ?states$/i.test(s.name);
|
|
3361
3877
|
function storyComponent(s, componentsDir) {
|
|
3362
|
-
return componentForPath(
|
|
3878
|
+
return componentForPath(norm3(s.importPath), componentsDir);
|
|
3363
3879
|
}
|
|
3364
3880
|
function selectImpact(input) {
|
|
3365
3881
|
const { usage, stories } = input;
|
|
3366
3882
|
const dir = usage.componentsDir;
|
|
3367
3883
|
const lib = input.librarySrc.replace(/\/$/, "");
|
|
3368
3884
|
const cap = input.maxStoriesPerComponent ?? 4;
|
|
3369
|
-
const visualFiles = input.files.map(
|
|
3885
|
+
const visualFiles = input.files.map(norm3).filter((f) => !NO_VISUAL.test(f));
|
|
3370
3886
|
const vars = [...new Set(input.tokens.map((t) => input.cssVar(t.path)))].sort();
|
|
3371
3887
|
const impact2 = impactedModules(usage, { vars, files: visualFiles });
|
|
3372
3888
|
let fallback = null;
|
|
@@ -3406,7 +3922,7 @@ function selectImpact(input) {
|
|
|
3406
3922
|
return {
|
|
3407
3923
|
tokens: input.tokens,
|
|
3408
3924
|
vars,
|
|
3409
|
-
files: input.files.map(
|
|
3925
|
+
files: input.files.map(norm3),
|
|
3410
3926
|
components: components.map((c) => ({ id: c.id, reasons: c.reasons, indirect: c.indirect })),
|
|
3411
3927
|
stories: [...new Set(picked)],
|
|
3412
3928
|
fallback,
|
|
@@ -3425,7 +3941,7 @@ function storiesFromSource(files) {
|
|
|
3425
3941
|
if (!title) continue;
|
|
3426
3942
|
for (const m of f.text.matchAll(/export\s+const\s+([A-Z]\w*)\s*[:=]/g)) {
|
|
3427
3943
|
const name = m[1];
|
|
3428
|
-
out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${
|
|
3944
|
+
out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${norm3(f.path)}` });
|
|
3429
3945
|
}
|
|
3430
3946
|
}
|
|
3431
3947
|
return out;
|
|
@@ -4232,8 +4748,9 @@ Usage:
|
|
|
4232
4748
|
GitHub token: the branch and the draft pull request on GitHub. Without one, in a git repo:
|
|
4233
4749
|
a local branch only, nothing pushed
|
|
4234
4750
|
bridge explore list [--all] [--json]
|
|
4235
|
-
running and promoted explorations, with
|
|
4236
|
-
|
|
4751
|
+
running and promoted explorations, with the preview links Vercel reports, the install
|
|
4752
|
+
command from the pull request (once the snapshot is built) and the requests implemented
|
|
4753
|
+
on each (--all: closed ones too)
|
|
4237
4754
|
bridge explore promote <name|#pr> [--dry-run] [--json]
|
|
4238
4755
|
ready for review, tier labels, exploration label and note removed (never merges)
|
|
4239
4756
|
bridge explore close <name|#pr> [--json]
|
|
@@ -4249,6 +4766,7 @@ async function context2(root, io) {
|
|
|
4249
4766
|
const token = io.env.BRIDGE_GITHUB_TOKEN || io.env.GITHUB_TOKEN || io.ghToken();
|
|
4250
4767
|
return BridgeContext3.create({ root, ...token ? { token } : {}, ...io.fetch ? { fetch: io.fetch } : {} });
|
|
4251
4768
|
}
|
|
4769
|
+
var exploreSource = (env) => ({ source: "cli", ...env.CLAUDECODE ? { agent: "claude" } : {} });
|
|
4252
4770
|
var date = (iso) => iso ? iso.slice(0, 10) : "";
|
|
4253
4771
|
async function runExplore(a, root, io = defaultIO()) {
|
|
4254
4772
|
const sub = a._[1];
|
|
@@ -4264,7 +4782,7 @@ async function runExplore(a, root, io = defaultIO()) {
|
|
|
4264
4782
|
if (!name || !purpose) throw new RequestUsageError('Usage: bridge explore start <name> --purpose "What you want to learn"');
|
|
4265
4783
|
const useCase = flag2(a, "use-case");
|
|
4266
4784
|
const ctx = await context2(root, io);
|
|
4267
|
-
const r = await explorations.startExploration(ctx, { name, purpose, ...useCase ? { useCase } : {}, ...a.flags.experimental ? { experimental: a.flags.experimental } : {}, dryRun: has2(a, "dry-run") });
|
|
4785
|
+
const r = await explorations.startExploration(ctx, { name, purpose, ...exploreSource(io.env), ...useCase ? { useCase } : {}, ...a.flags.experimental ? { experimental: a.flags.experimental } : {}, dryRun: has2(a, "dry-run") });
|
|
4268
4786
|
if (json) return io.print(JSON.stringify(r, null, 2)), 0;
|
|
4269
4787
|
if (r.status === "dry-run") {
|
|
4270
4788
|
io.print(`Dry run, nothing written.
|
|
@@ -4279,8 +4797,7 @@ ${r.prBody}`);
|
|
|
4279
4797
|
if (r.status === "started") {
|
|
4280
4798
|
io.print(`Started ${r.branch}: draft pull request #${r.pr} ${r.url}`);
|
|
4281
4799
|
io.print(`
|
|
4282
|
-
|
|
4283
|
-
${r.install.command}`);
|
|
4800
|
+
${r.install.note}`);
|
|
4284
4801
|
for (const p of r.previews ?? []) io.print(`${p.label}: ${p.url}`);
|
|
4285
4802
|
} else io.print(`Created the local branch ${r.branch} (${String(r.commit).slice(0, 7)}).`);
|
|
4286
4803
|
io.print(`
|
|
@@ -4297,8 +4814,8 @@ Next: ${r.next}`);
|
|
|
4297
4814
|
#${x.number} ${x.name} ${EXPLORATION_STATUS_LABEL[x.status]} ${x.branch} @${x.author} ${date(x.updatedAt)}`);
|
|
4298
4815
|
if (x.purpose) io.print(` ${x.purpose}`);
|
|
4299
4816
|
io.print(` ${x.url}`);
|
|
4300
|
-
for (const p of x.previews) io.print(` ${p.label}: ${p.url}`);
|
|
4301
|
-
if (x.install
|
|
4817
|
+
for (const p of x.previews) io.print(` ${p.label}: ${p.url ? `${p.url}${p.state === "building" ? " (building)" : p.state === "failed" ? " (build failed)" : ""}` : "link not reported by Vercel yet, see the pull request"}`);
|
|
4818
|
+
if (x.install) io.print(x.install.command ? ` Install: ${x.install.command}` : ` Install: ${x.install.note}`);
|
|
4302
4819
|
if (x.requests.length) io.print(` Requests: ${x.requests.map((q) => `#${q.number}`).join(", ")}`);
|
|
4303
4820
|
}
|
|
4304
4821
|
for (const b of r.localBranches ?? []) io.print(`
|
|
@@ -4335,13 +4852,50 @@ ${EXPLORE_HELP}`);
|
|
|
4335
4852
|
}
|
|
4336
4853
|
}
|
|
4337
4854
|
|
|
4855
|
+
// src/protection.ts
|
|
4856
|
+
var UNKNOWN = { kind: "unknown", missing: ["review", "checks", "history", "deletion"] };
|
|
4857
|
+
async function protectionReport(opts) {
|
|
4858
|
+
let state = UNKNOWN;
|
|
4859
|
+
try {
|
|
4860
|
+
const client = new GitHubClient(opts.repo, opts.token ?? "", opts.fetch, { attempts: 1, baseDelayMs: 0, sleep: async () => {
|
|
4861
|
+
} });
|
|
4862
|
+
state = await loadProtection(client, opts.defaultBranch);
|
|
4863
|
+
} catch {
|
|
4864
|
+
state = UNKNOWN;
|
|
4865
|
+
}
|
|
4866
|
+
const [owner, name] = opts.repo.split("/");
|
|
4867
|
+
const lines = [`protection: ${protectionSentence(state)}`];
|
|
4868
|
+
if (state.kind === "protected" && state.url) lines.push(` ${state.url}`);
|
|
4869
|
+
if (state.kind === "unprotected" || state.kind === "partial") lines.push(` Protect it in Studio > Settings, or GitHub settings > Rules: ${rulesSettingsUrl(owner, name)}`);
|
|
4870
|
+
return { state, lines };
|
|
4871
|
+
}
|
|
4872
|
+
|
|
4873
|
+
// src/roles.ts
|
|
4874
|
+
async function rolesReport(opts) {
|
|
4875
|
+
if (!teamsIn(opts.owners).length) return { teams: [], lines: [] };
|
|
4876
|
+
const client = new GitHubClient(opts.repo, opts.token ?? "", opts.fetch, { attempts: 1, baseDelayMs: 0, sleep: async () => {
|
|
4877
|
+
} });
|
|
4878
|
+
const states = await new TeamDirectory(client).resolve(opts.owners);
|
|
4879
|
+
const teams = Object.values(states);
|
|
4880
|
+
const group = (entries) => entries.length ? entries.map((e) => entryLabel(e, states)).join(", ") : "nobody listed";
|
|
4881
|
+
const lines = [`roles: design owners ${group(opts.owners.design)}; tech owners ${group(opts.owners.tech)}`];
|
|
4882
|
+
for (const t of teams) if (t.status === "unreadable") lines.push(` warn team-unreadable ${t.message}`);
|
|
4883
|
+
return { teams, lines };
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4338
4886
|
// src/index.ts
|
|
4339
4887
|
import { brand, BridgeContext as BridgeContext4, CONFIG_FILE as CONFIG_FILE2, core, detectConfig, GitRefSource, history as hist, LocalSource, ops, optionsFromArgv, serveStdio, toConfigJson, VERSION } from "@bridge-ds/mcp";
|
|
4888
|
+
var MCP_PACKAGE = "@bridge-ds/mcp";
|
|
4340
4889
|
var HELP = `bridge ${VERSION}: design system as code, for you and your Claude
|
|
4341
4890
|
|
|
4342
4891
|
Usage:
|
|
4343
4892
|
bridge init [--force] write bridge.config.json (detected) and .mcp.json for Claude Code
|
|
4344
|
-
bridge check [--json] [--strict]
|
|
4893
|
+
bridge check [--json] [--strict] [--offline]
|
|
4894
|
+
validate tokens + contrast gate, and say whether the library is
|
|
4895
|
+
protected (reviewed pull requests only) and who is in the GitHub
|
|
4896
|
+
teams named as owners, reporting teams it cannot read (neither
|
|
4897
|
+
changes the exit code; --offline skips both). Exit 0 ok, 1 failures,
|
|
4898
|
+
2 setup error
|
|
4345
4899
|
bridge tokens <query> [--json] search tokens by name, description or value
|
|
4346
4900
|
bridge diff <base> [--json|--md] designer-readable token diff: working tree vs <base> (git ref)
|
|
4347
4901
|
bridge propose --title "..." --set path=value [--set ...] [--file f | --mode theme=dark]
|
|
@@ -4408,7 +4962,7 @@ function parse(argv) {
|
|
|
4408
4962
|
const [k, inline] = a.slice(2).split(/=(.*)/s, 2);
|
|
4409
4963
|
const next = argv[i + 1];
|
|
4410
4964
|
const fileArg = k === "json" && /\.json$/i.test(next ?? "") || k === "md" && /\.(md|markdown)$/i.test(next ?? "");
|
|
4411
|
-
const takesValue = inline === void 0 && next !== void 0 && !next.startsWith("--") && (fileArg || !["force", "json", "md", "strict", "dry-run", "help", "fix", "write", "no-regress", "plan", "keep-unchanged", "restore-conflicts", "breaking", "claude", "all"].includes(k));
|
|
4965
|
+
const takesValue = inline === void 0 && next !== void 0 && !next.startsWith("--") && (fileArg || !["force", "json", "md", "strict", "dry-run", "help", "fix", "write", "no-regress", "plan", "keep-unchanged", "restore-conflicts", "breaking", "claude", "all", "offline"].includes(k));
|
|
4412
4966
|
(out.flags[k] ??= []).push(inline ?? (takesValue ? argv[++i] : "true"));
|
|
4413
4967
|
} else out._.push(a);
|
|
4414
4968
|
}
|
|
@@ -4437,18 +4991,19 @@ async function init(a, root) {
|
|
|
4437
4991
|
}
|
|
4438
4992
|
const serverJs = fileURLToPath(new URL("./index.js", import.meta.resolve("@bridge-ds/mcp")));
|
|
4439
4993
|
const relServer = relative4(root, serverJs);
|
|
4440
|
-
const inRepo = !relServer.startsWith("..");
|
|
4994
|
+
const inRepo = !relServer.startsWith("..") && !isAbsolute(relServer);
|
|
4995
|
+
const server = (dir) => inRepo ? { command: "node", args: [relServer, "--root", dir] } : { command: "npx", args: ["-y", MCP_PACKAGE, "--root", dir] };
|
|
4441
4996
|
const mcpPath = join5(root, ".mcp.json");
|
|
4442
4997
|
const existing = existsSync5(mcpPath) ? JSON.parse(await readFile5(mcpPath, "utf8")) : {};
|
|
4443
|
-
existing.mcpServers = { ...existing.mcpServers ?? {}, bridge:
|
|
4998
|
+
existing.mcpServers = { ...existing.mcpServers ?? {}, bridge: server(".") };
|
|
4444
4999
|
await writeFile4(mcpPath, `${JSON.stringify(existing, null, 2)}
|
|
4445
5000
|
`);
|
|
4446
5001
|
print(`Wrote .mcp.json: Claude Code picks up the "bridge" server next time it starts in ${root}.`);
|
|
5002
|
+
const desktop = inRepo ? { command: "node", args: [serverJs, "--root", root] } : server(root);
|
|
4447
5003
|
print("\nClaude Desktop: add this to claude_desktop_config.json (Settings > Developer > Edit config):");
|
|
4448
|
-
print(JSON.stringify({ mcpServers: { bridge:
|
|
5004
|
+
print(JSON.stringify({ mcpServers: { bridge: desktop } }, null, 2));
|
|
4449
5005
|
print("\nOr with the Claude Code CLI:");
|
|
4450
|
-
print(` claude mcp add bridge --
|
|
4451
|
-
print("(Once @bridge-ds/mcp is published: claude mcp add bridge -- npx -y @bridge-ds/mcp)");
|
|
5006
|
+
print(` claude mcp add bridge -- ${desktop.command} ${desktop.args.join(" ")}`);
|
|
4452
5007
|
}
|
|
4453
5008
|
async function check(a, root) {
|
|
4454
5009
|
const ctx = await BridgeContext4.create({ root });
|
|
@@ -4456,8 +5011,10 @@ async function check(a, root) {
|
|
|
4456
5011
|
const c = await ops.checkContrast(ctx, {});
|
|
4457
5012
|
const warnings = "warnings" in v ? v.warnings ?? [] : [];
|
|
4458
5013
|
const failed = !v.valid || "failing" in c && (c.failing ?? 0) > 0 || has3(a, "strict") && warnings.length > 0;
|
|
5014
|
+
const protection = await protectionOf(a, root, ctx);
|
|
5015
|
+
const roles = await rolesOf(a, root, ctx);
|
|
4459
5016
|
if (has3(a, "json")) {
|
|
4460
|
-
print(JSON.stringify({ ok: !failed, validation: v, contrast: c }, null, 2));
|
|
5017
|
+
print(JSON.stringify({ ok: !failed, validation: v, contrast: c, ...protection ? { protection: protection.state } : {}, ...roles?.teams.length ? { teams: roles.teams } : {} }, null, 2));
|
|
4461
5018
|
return failed ? 1 : 0;
|
|
4462
5019
|
}
|
|
4463
5020
|
print(`tokens: ${v.tokens} in ${v.files} files, ${v.modeCombinations} mode combinations`);
|
|
@@ -4469,9 +5026,27 @@ async function check(a, root) {
|
|
|
4469
5026
|
const tight = c.tightest?.[0];
|
|
4470
5027
|
if (tight) print(` tightest: ${tight.pair} [${tight.modes}] ${tight.ratio}:1 (min ${tight.min})`);
|
|
4471
5028
|
} else print(`contrast: ${c.message}`);
|
|
5029
|
+
for (const line of protection?.lines ?? []) print(line);
|
|
5030
|
+
for (const line of roles?.lines ?? []) print(line);
|
|
4472
5031
|
print(failed ? "FAIL" : "OK");
|
|
4473
5032
|
return failed ? 1 : 0;
|
|
4474
5033
|
}
|
|
5034
|
+
async function protectionOf(a, root, ctx) {
|
|
5035
|
+
if (has3(a, "offline") || process.env.BRIDGE_OFFLINE === "1") return void 0;
|
|
5036
|
+
const { config } = await ctx.config();
|
|
5037
|
+
const repo = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(root);
|
|
5038
|
+
if (!repo) return void 0;
|
|
5039
|
+
const token = process.env.BRIDGE_GITHUB_TOKEN || process.env.GITHUB_TOKEN || void 0;
|
|
5040
|
+
return protectionReport({ repo, defaultBranch: config.repo?.defaultBranch ?? "main", token });
|
|
5041
|
+
}
|
|
5042
|
+
async function rolesOf(a, root, ctx) {
|
|
5043
|
+
if (has3(a, "offline") || process.env.BRIDGE_OFFLINE === "1") return void 0;
|
|
5044
|
+
const { config } = await ctx.config();
|
|
5045
|
+
const repo = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(root);
|
|
5046
|
+
if (!repo) return void 0;
|
|
5047
|
+
const token = process.env.BRIDGE_GITHUB_TOKEN || process.env.GITHUB_TOKEN || void 0;
|
|
5048
|
+
return rolesReport({ repo, owners: config.governance.owners, token });
|
|
5049
|
+
}
|
|
4475
5050
|
async function tokens(a, root) {
|
|
4476
5051
|
const q = a._.slice(1).join(" ");
|
|
4477
5052
|
if (!q) throw new UsageError("bridge tokens <query>");
|
|
@@ -4834,7 +5409,7 @@ async function main() {
|
|
|
4834
5409
|
if (cmd === "explore") return runExplore(a, root);
|
|
4835
5410
|
if (!cmd || has3(a, "help") || cmd === "help") {
|
|
4836
5411
|
print(HELP);
|
|
4837
|
-
return cmd ? 0 : 2;
|
|
5412
|
+
return cmd || has3(a, "help") ? 0 : 2;
|
|
4838
5413
|
}
|
|
4839
5414
|
if (cmd === "--version" || cmd === "version") return print(VERSION), 0;
|
|
4840
5415
|
switch (cmd) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bridge-ds/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "bridge CLI: set up Bridge in a design system repo, check tokens in CI, search and diff tokens, propose changes, start the MCP server.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"build:package": "node ../../scripts/build-packages.mjs cli"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@bridge-ds/mcp": "^0.
|
|
47
|
-
"@bridge-ds/visual": "^0.2.
|
|
46
|
+
"@bridge-ds/mcp": "^0.3.1",
|
|
47
|
+
"@bridge-ds/visual": "^0.2.2",
|
|
48
48
|
"typescript": "5.9.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|