@devdogsuga/devtools 0.1.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/README.md +44 -0
- package/bin/devtools-ci-bare.mjs +48 -0
- package/bin/devtools-ci.mjs +17 -0
- package/bin/devtools.mjs +27 -0
- package/dist/args.d.ts +26 -0
- package/dist/args.js +78 -0
- package/dist/bws/bw.d.ts +9 -0
- package/dist/bws/bw.js +49 -0
- package/dist/bws/client.d.ts +43 -0
- package/dist/bws/client.js +363 -0
- package/dist/bws/environments.d.ts +96 -0
- package/dist/bws/environments.js +111 -0
- package/dist/bws/pace.d.ts +27 -0
- package/dist/bws/pace.js +43 -0
- package/dist/bws/retry.d.ts +12 -0
- package/dist/bws/retry.js +39 -0
- package/dist/bws/store.d.ts +1 -0
- package/dist/bws/store.js +32 -0
- package/dist/bws/token.d.ts +31 -0
- package/dist/bws/token.js +88 -0
- package/dist/bws/vault.d.ts +29 -0
- package/dist/bws/vault.js +200 -0
- package/dist/catalog.d.ts +55 -0
- package/dist/catalog.js +81 -0
- package/dist/cf/commands.d.ts +1 -0
- package/dist/cf/commands.js +182 -0
- package/dist/cf/local-env.d.ts +44 -0
- package/dist/cf/local-env.js +148 -0
- package/dist/ci.d.ts +1 -0
- package/dist/ci.js +319 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +1077 -0
- package/dist/commands.d.ts +255 -0
- package/dist/commands.js +1279 -0
- package/dist/completions.d.ts +4 -0
- package/dist/completions.js +110 -0
- package/dist/cron/commands.d.ts +47 -0
- package/dist/cron/commands.js +372 -0
- package/dist/cron/discovery.d.ts +55 -0
- package/dist/cron/discovery.js +85 -0
- package/dist/cron/schema.d.ts +45 -0
- package/dist/cron/schema.js +35 -0
- package/dist/db/config-push.d.ts +8 -0
- package/dist/db/config-push.js +11 -0
- package/dist/db/connection.d.ts +79 -0
- package/dist/db/connection.js +114 -0
- package/dist/db/exec.d.ts +1 -0
- package/dist/db/exec.js +11 -0
- package/dist/db/generate-migration.d.ts +1 -0
- package/dist/db/generate-migration.js +67 -0
- package/dist/db/generate-types.d.ts +1 -0
- package/dist/db/generate-types.js +4 -0
- package/dist/db/introspect.d.ts +1 -0
- package/dist/db/introspect.js +113 -0
- package/dist/db/local-env.d.ts +65 -0
- package/dist/db/local-env.js +23 -0
- package/dist/db/new-migration.d.ts +1 -0
- package/dist/db/new-migration.js +8 -0
- package/dist/db/run.d.ts +70 -0
- package/dist/db/run.js +158 -0
- package/dist/db/seed-buckets.d.ts +2 -0
- package/dist/db/seed-buckets.js +4 -0
- package/dist/db/seed-production.d.ts +8 -0
- package/dist/db/seed-production.js +73 -0
- package/dist/db/seed-roles.d.ts +7 -0
- package/dist/db/seed-roles.js +31 -0
- package/dist/db/session-refresh.d.ts +43 -0
- package/dist/db/session-refresh.js +40 -0
- package/dist/deploy/migrations.d.ts +20 -0
- package/dist/deploy/migrations.js +68 -0
- package/dist/deploy/orphans.d.ts +20 -0
- package/dist/deploy/orphans.js +177 -0
- package/dist/deploy/preflight.d.ts +53 -0
- package/dist/deploy/preflight.js +189 -0
- package/dist/deploy/report.d.ts +26 -0
- package/dist/deploy/report.js +70 -0
- package/dist/deploy/require-planner.d.ts +2 -0
- package/dist/deploy/require-planner.js +60 -0
- package/dist/deploy/require-token.d.ts +15 -0
- package/dist/deploy/require-token.js +78 -0
- package/dist/deploy/secrets-file.d.ts +17 -0
- package/dist/deploy/secrets-file.js +167 -0
- package/dist/deploy/write-env.d.ts +40 -0
- package/dist/deploy/write-env.js +393 -0
- package/dist/docs/index-pages.d.ts +36 -0
- package/dist/docs/index-pages.js +184 -0
- package/dist/doctor.d.ts +62 -0
- package/dist/doctor.js +225 -0
- package/dist/emails/commands.d.ts +15 -0
- package/dist/emails/commands.js +130 -0
- package/dist/emails/email-types.d.ts +13 -0
- package/dist/emails/email-types.js +1 -0
- package/dist/emails/fixtures.d.ts +20 -0
- package/dist/emails/fixtures.js +20 -0
- package/dist/env/audit.d.ts +206 -0
- package/dist/env/audit.js +436 -0
- package/dist/env/cloudflare.d.ts +21 -0
- package/dist/env/cloudflare.js +61 -0
- package/dist/env/commands.d.ts +99 -0
- package/dist/env/commands.js +612 -0
- package/dist/env/discovery.d.ts +15 -0
- package/dist/env/discovery.js +226 -0
- package/dist/env/document.d.ts +119 -0
- package/dist/env/document.js +323 -0
- package/dist/env/example.d.ts +68 -0
- package/dist/env/example.js +603 -0
- package/dist/env/selection.d.ts +105 -0
- package/dist/env/selection.js +226 -0
- package/dist/environment.d.ts +92 -0
- package/dist/environment.js +206 -0
- package/dist/fingerprint.d.ts +10 -0
- package/dist/fingerprint.js +18 -0
- package/dist/gen/campus-map.d.ts +1 -0
- package/dist/gen/campus-map.js +473 -0
- package/dist/gen/commands.d.ts +1 -0
- package/dist/gen/commands.js +25 -0
- package/dist/gen/hypno.d.ts +1 -0
- package/dist/gen/hypno.js +134 -0
- package/dist/gh/client.d.ts +80 -0
- package/dist/gh/client.js +258 -0
- package/dist/gh/environments.d.ts +83 -0
- package/dist/gh/environments.js +178 -0
- package/dist/gh/rulesets/actors.d.ts +14 -0
- package/dist/gh/rulesets/actors.js +68 -0
- package/dist/gh/rulesets/api.d.ts +15 -0
- package/dist/gh/rulesets/api.js +99 -0
- package/dist/gh/rulesets/commands.d.ts +3 -0
- package/dist/gh/rulesets/commands.js +203 -0
- package/dist/gh/rulesets/desired.d.ts +112 -0
- package/dist/gh/rulesets/desired.js +247 -0
- package/dist/gh/rulesets/diff.d.ts +65 -0
- package/dist/gh/rulesets/diff.js +134 -0
- package/dist/gh/rulesets/types.d.ts +76 -0
- package/dist/gh/rulesets/types.js +13 -0
- package/dist/gh/settings/api.d.ts +40 -0
- package/dist/gh/settings/api.js +172 -0
- package/dist/gh/settings/commands.d.ts +1 -0
- package/dist/gh/settings/commands.js +245 -0
- package/dist/gh/settings/desired.d.ts +53 -0
- package/dist/gh/settings/desired.js +39 -0
- package/dist/gh/settings/diff.d.ts +57 -0
- package/dist/gh/settings/diff.js +156 -0
- package/dist/gh/settings/types.d.ts +92 -0
- package/dist/gh/settings/types.js +11 -0
- package/dist/gh/settings/workflows.d.ts +41 -0
- package/dist/gh/settings/workflows.js +124 -0
- package/dist/grantRoot.d.ts +42 -0
- package/dist/grantRoot.js +89 -0
- package/dist/help.d.ts +18 -0
- package/dist/help.js +169 -0
- package/dist/images/commands.d.ts +35 -0
- package/dist/images/commands.js +233 -0
- package/dist/images/events.d.ts +36 -0
- package/dist/images/events.js +152 -0
- package/dist/images/graphics.d.ts +70 -0
- package/dist/images/graphics.js +233 -0
- package/dist/images/open-graph-types.d.ts +75 -0
- package/dist/images/open-graph-types.js +1 -0
- package/dist/images/prompts.d.ts +34 -0
- package/dist/images/prompts.js +100 -0
- package/dist/images/render.d.ts +21 -0
- package/dist/images/render.js +57 -0
- package/dist/images/select.d.ts +64 -0
- package/dist/images/select.js +96 -0
- package/dist/instance.d.ts +67 -0
- package/dist/instance.js +129 -0
- package/dist/invocation.d.ts +49 -0
- package/dist/invocation.js +78 -0
- package/dist/launch-ci.d.ts +1 -0
- package/dist/launch-ci.js +86 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +371 -0
- package/dist/menu.d.ts +31 -0
- package/dist/menu.js +277 -0
- package/dist/newsletter/commands.d.ts +16 -0
- package/dist/newsletter/commands.js +311 -0
- package/dist/newsletter/imap.d.ts +17 -0
- package/dist/newsletter/imap.js +127 -0
- package/dist/newsletter/loopback.d.ts +24 -0
- package/dist/newsletter/loopback.js +100 -0
- package/dist/newsletter/oauth.d.ts +26 -0
- package/dist/newsletter/oauth.js +130 -0
- package/dist/newsletter/smtp.d.ts +23 -0
- package/dist/newsletter/smtp.js +133 -0
- package/dist/oauth/browser.d.ts +1 -0
- package/dist/oauth/browser.js +13 -0
- package/dist/oauth/config.d.ts +24 -0
- package/dist/oauth/config.js +24 -0
- package/dist/oauth/connect-url.d.ts +17 -0
- package/dist/oauth/connect-url.js +23 -0
- package/dist/oauth/db.d.ts +37 -0
- package/dist/oauth/db.js +108 -0
- package/dist/oauth/device.d.ts +93 -0
- package/dist/oauth/device.js +190 -0
- package/dist/oauth/discovery.d.ts +28 -0
- package/dist/oauth/discovery.js +54 -0
- package/dist/oauth/env-file.d.ts +6 -0
- package/dist/oauth/env-file.js +35 -0
- package/dist/oauth/exchange.d.ts +43 -0
- package/dist/oauth/exchange.js +87 -0
- package/dist/oauth/label.d.ts +1 -0
- package/dist/oauth/label.js +25 -0
- package/dist/oauth/loopback.d.ts +52 -0
- package/dist/oauth/loopback.js +162 -0
- package/dist/oauth/pkce.d.ts +6 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/target.d.ts +54 -0
- package/dist/oauth/target.js +102 -0
- package/dist/oauth/transport.d.ts +41 -0
- package/dist/oauth/transport.js +32 -0
- package/dist/oauth/wizard.d.ts +33 -0
- package/dist/oauth/wizard.js +571 -0
- package/dist/pick.d.ts +6 -0
- package/dist/pick.js +64 -0
- package/dist/planner/checks.d.ts +26 -0
- package/dist/planner/checks.js +104 -0
- package/dist/planner/commands.d.ts +10 -0
- package/dist/planner/commands.js +367 -0
- package/dist/planner/db.d.ts +15 -0
- package/dist/planner/db.js +33 -0
- package/dist/planner/role.d.ts +33 -0
- package/dist/planner/role.js +68 -0
- package/dist/repo/peers.d.ts +24 -0
- package/dist/repo/peers.js +114 -0
- package/dist/repo/preflight.d.ts +109 -0
- package/dist/repo/preflight.js +266 -0
- package/dist/repo/resolve.d.ts +41 -0
- package/dist/repo/resolve.js +152 -0
- package/dist/repo/root.d.ts +23 -0
- package/dist/repo/root.js +91 -0
- package/dist/repo/source.d.ts +14 -0
- package/dist/repo/source.js +54 -0
- package/dist/repo/tsx-loader.d.ts +6 -0
- package/dist/repo/tsx-loader.js +41 -0
- package/dist/run/pick.d.ts +60 -0
- package/dist/run/pick.js +559 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +152 -0
- package/dist/stack.d.ts +48 -0
- package/dist/stack.js +290 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +93 -0
- package/dist/tier.d.ts +40 -0
- package/dist/tier.js +93 -0
- package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
- package/dist/ui.d.ts +25 -0
- package/dist/ui.js +51 -0
- package/dist/workers.d.ts +5 -0
- package/dist/workers.js +35 -0
- package/dist/workflows/commands.d.ts +44 -0
- package/dist/workflows/commands.js +668 -0
- package/env.ts +158 -0
- package/package.json +80 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the numeric ids `desired.ts`'s bypass actors need, by NAME —
|
|
3
|
+
* never hardcoded, because a team or App id is only stable until somebody
|
|
4
|
+
* deletes and recreates it, and a stale id in a bypass actor is the
|
|
5
|
+
* dangerous failure: the ruleset then blocks the team or App it is named
|
|
6
|
+
* for while letting whoever inherited the old id through (the same
|
|
7
|
+
* reasoning `teamSync.ts`'s `ensureTeamRuleset` gives for always re-writing
|
|
8
|
+
* rather than skipping on an existing ruleset).
|
|
9
|
+
*
|
|
10
|
+
* Both come from plain, read-only `gh api` GETs — no Octokit, same as every
|
|
11
|
+
* other GitHub call in this package.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { GhRulesetsError } from "./api.js";
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
function describe(err) {
|
|
18
|
+
const e = err;
|
|
19
|
+
const stderr = (e.stderr ?? "").trim();
|
|
20
|
+
return stderr || e.message || "gh failed with no output.";
|
|
21
|
+
}
|
|
22
|
+
/** `GET /orgs/{org}/teams/{slug}` -> the team's numeric id. */
|
|
23
|
+
export async function resolveTeamId(org, slug) {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout } = await run("gh", ["api", `orgs/${org}/teams/${slug}`], {
|
|
26
|
+
shell: false,
|
|
27
|
+
});
|
|
28
|
+
const data = JSON.parse(stdout);
|
|
29
|
+
if (typeof data.id !== "number") {
|
|
30
|
+
throw new GhRulesetsError(`orgs/${org}/teams/${slug} returned no numeric id`);
|
|
31
|
+
}
|
|
32
|
+
return data.id;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err instanceof GhRulesetsError)
|
|
36
|
+
throw err;
|
|
37
|
+
throw new GhRulesetsError(`resolving team "${slug}" in ${org}: ${describe(err)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `GET /orgs/{org}/installations` -> the id of the App installation whose
|
|
42
|
+
* `app_slug` matches, e.g. `devdogs-platform` (production) or
|
|
43
|
+
* `devdogs-platform-staging`.
|
|
44
|
+
*
|
|
45
|
+
* `orgs/{org}/installations` rather than the App's own `/app` endpoint: the
|
|
46
|
+
* latter needs App-JWT auth this CLI has no path to, while the installations
|
|
47
|
+
* list is a plain org-admin GET — the exact command DevDogsUGA's own
|
|
48
|
+
* `docs/platform/guides/identity/github-app.md` already tells an operator to
|
|
49
|
+
* run to verify the App's permission grant.
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveAppId(org, appSlug) {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await run("gh", ["api", `orgs/${org}/installations`], {
|
|
54
|
+
shell: false,
|
|
55
|
+
});
|
|
56
|
+
const data = JSON.parse(stdout);
|
|
57
|
+
const match = (data.installations ?? []).find((i) => i.app_slug === appSlug);
|
|
58
|
+
if (!match) {
|
|
59
|
+
throw new GhRulesetsError(`no installation with app_slug "${appSlug}" found in orgs/${org}/installations`);
|
|
60
|
+
}
|
|
61
|
+
return match.app_id;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof GhRulesetsError)
|
|
65
|
+
throw err;
|
|
66
|
+
throw new GhRulesetsError(`resolving App "${appSlug}" in ${org}: ${describe(err)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DesiredRuleset, LiveRuleset, LiveRulesetSummary } from "./types.js";
|
|
2
|
+
export declare class GhRulesetsError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** `owner/repo`, e.g. `DevDogsUGA/DevDogsUGA`. */
|
|
5
|
+
export interface Repo {
|
|
6
|
+
owner: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
}
|
|
9
|
+
/** `GET /repos/{owner}/{repo}/rulesets` — id, name and target only. */
|
|
10
|
+
export declare function listRulesets(r: Repo): Promise<LiveRulesetSummary[]>;
|
|
11
|
+
/** `GET /repos/{owner}/{repo}/rulesets/{id}` — the full ruleset. */
|
|
12
|
+
export declare function getRuleset(r: Repo, id: number): Promise<LiveRuleset>;
|
|
13
|
+
export declare function createRuleset(r: Repo, desired: DesiredRuleset): Promise<LiveRuleset>;
|
|
14
|
+
export declare function updateRuleset(r: Repo, id: number, desired: DesiredRuleset): Promise<LiveRuleset>;
|
|
15
|
+
export declare function deleteRuleset(r: Repo, id: number): Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh api` calls this reconciler needs: listing and reading rulesets, and
|
|
3
|
+
* writing them (create/update/delete).
|
|
4
|
+
*
|
|
5
|
+
* `gh`, not Octokit — devtools has no Octokit dependency, and every other
|
|
6
|
+
* GitHub call in this package (`../client.ts`) already goes through the
|
|
7
|
+
* `gh` CLI for the same reason `bws` goes through its own CLI: `gh` already
|
|
8
|
+
* owns authentication (a contributor's `gh auth login`, or `GH_TOKEN` in
|
|
9
|
+
* CI), and re-deriving that here would mean a second credential path to
|
|
10
|
+
* keep in step with the first.
|
|
11
|
+
*
|
|
12
|
+
* Reads (`listRulesets`, `getRuleset`) run unauthenticated-safe GETs and are
|
|
13
|
+
* always allowed. Writes (`createRuleset`, `updateRuleset`, `deleteRuleset`)
|
|
14
|
+
* need `admin` on the repository — the same requirement `gh api
|
|
15
|
+
* .../rulesets -X POST` has always had — and `commands.ts` is the only
|
|
16
|
+
* caller that reaches them, gated behind `--apply`.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile, spawn } from "node:child_process";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
22
|
+
export class GhRulesetsError extends Error {
|
|
23
|
+
}
|
|
24
|
+
function describe(err) {
|
|
25
|
+
const e = err;
|
|
26
|
+
const stderr = (e.stderr ?? "").trim();
|
|
27
|
+
return stderr || e.message || "gh failed with no output.";
|
|
28
|
+
}
|
|
29
|
+
function path(r, suffix = "") {
|
|
30
|
+
return `repos/${r.owner}/${r.repo}/rulesets${suffix}`;
|
|
31
|
+
}
|
|
32
|
+
/** `GET /repos/{owner}/{repo}/rulesets` — id, name and target only. */
|
|
33
|
+
export async function listRulesets(r) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await run("gh", ["api", path(r), "--paginate"], {
|
|
36
|
+
maxBuffer: MAX_BUFFER,
|
|
37
|
+
shell: false,
|
|
38
|
+
});
|
|
39
|
+
return stdout.trim() === "" ? [] : JSON.parse(stdout);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new GhRulesetsError(describe(err));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** `GET /repos/{owner}/{repo}/rulesets/{id}` — the full ruleset. */
|
|
46
|
+
export async function getRuleset(r, id) {
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await run("gh", ["api", path(r, `/${id}`)], {
|
|
49
|
+
maxBuffer: MAX_BUFFER,
|
|
50
|
+
shell: false,
|
|
51
|
+
});
|
|
52
|
+
return JSON.parse(stdout);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new GhRulesetsError(describe(err));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Runs `gh api <path> -X <method> --input -`, writing `body` on stdin (JSON)
|
|
60
|
+
* and returning the parsed JSON response.
|
|
61
|
+
*
|
|
62
|
+
* stdin, not `-f`/`-F` field flags: this reconciler always sends the WHOLE
|
|
63
|
+
* ruleset payload as one JSON document (the shape `desired.ts` already
|
|
64
|
+
* builds), and `gh api`'s field flags exist for building that document one
|
|
65
|
+
* key at a time from shell arguments — a worse fit than handing over the
|
|
66
|
+
* object this code already has. Not `-X POST --input path` via a temp file
|
|
67
|
+
* either: stdin needs no cleanup and cannot be left behind by a crash.
|
|
68
|
+
*/
|
|
69
|
+
async function writeJson(method, apiPath, body) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn("gh", ["api", apiPath, "-X", method, "--input", "-"], {
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
shell: false,
|
|
74
|
+
});
|
|
75
|
+
let stdout = "";
|
|
76
|
+
let stderr = "";
|
|
77
|
+
child.stdout.on("data", (c) => (stdout += c.toString()));
|
|
78
|
+
child.stderr.on("data", (c) => (stderr += c.toString()));
|
|
79
|
+
child.on("error", (err) => reject(new GhRulesetsError(describe(err))));
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
reject(new GhRulesetsError(describe({ stderr })));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const trimmed = stdout.trim();
|
|
86
|
+
resolve(trimmed === "" ? {} : JSON.parse(trimmed));
|
|
87
|
+
});
|
|
88
|
+
child.stdin.end(JSON.stringify(body));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export async function createRuleset(r, desired) {
|
|
92
|
+
return writeJson("POST", path(r), desired);
|
|
93
|
+
}
|
|
94
|
+
export async function updateRuleset(r, id, desired) {
|
|
95
|
+
return writeJson("PATCH", path(r, `/${id}`), desired);
|
|
96
|
+
}
|
|
97
|
+
export async function deleteRuleset(r, id) {
|
|
98
|
+
await writeJson("DELETE", path(r, `/${id}`), {});
|
|
99
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `devtools github rulesets` — TASK-322.
|
|
3
|
+
*
|
|
4
|
+
* Prints the diff between the five fixed rulesets DevDogsUGA's
|
|
5
|
+
* `apps/platform/src/server/github/rulesets.ts` has always assumed exist
|
|
6
|
+
* (`main`, `production`, `~ALL`, `team/**`, and the tag ruleset — see
|
|
7
|
+
* `desired.ts`'s header) and what `gh api .../rulesets` actually reports.
|
|
8
|
+
* Defaults to a dry-run plan; `--apply` writes it, gated behind a
|
|
9
|
+
* confirmation unless `--yes`.
|
|
10
|
+
*
|
|
11
|
+
* Never touches a per-team ruleset (`teamRulesetName()`'s `team/<slug>`) —
|
|
12
|
+
* those are `teamSync.ts`'s, not this command's — and never deletes or
|
|
13
|
+
* creates anything outside `desired.ts`'s five names plus the one explicit
|
|
14
|
+
* deletion (`comp-branches`). See `diff.ts`'s header for the full
|
|
15
|
+
* classification.
|
|
16
|
+
*/
|
|
17
|
+
import { confirm } from "@clack/prompts";
|
|
18
|
+
import { resolveAppId, resolveTeamId } from "./actors.js";
|
|
19
|
+
import { createRuleset, deleteRuleset, getRuleset, listRulesets, updateRuleset, } from "./api.js";
|
|
20
|
+
import { buildDesiredRulesets, isPerTeamRulesetName } from "./desired.js";
|
|
21
|
+
import { planHasChanges, planRulesets } from "./diff.js";
|
|
22
|
+
import { unwrap } from "../../ui.js";
|
|
23
|
+
const DEFAULT_ORG = "DevDogsUGA";
|
|
24
|
+
const DEFAULT_REPO = "DevDogsUGA";
|
|
25
|
+
const DEFAULT_APP_SLUG = "devdogs-platform";
|
|
26
|
+
const DEFAULT_DEVOPS_SLUG = "devops";
|
|
27
|
+
const DEFAULT_ADMINS_SLUG = "admins";
|
|
28
|
+
/** TASK-299 — installed separately from the platform App; may not exist yet. */
|
|
29
|
+
const DEFAULT_RENOVATE_SLUG = "renovate";
|
|
30
|
+
function parseOptions(argv) {
|
|
31
|
+
const opts = {
|
|
32
|
+
org: DEFAULT_ORG,
|
|
33
|
+
repo: DEFAULT_REPO,
|
|
34
|
+
appSlug: DEFAULT_APP_SLUG,
|
|
35
|
+
apply: false,
|
|
36
|
+
yes: false,
|
|
37
|
+
json: false,
|
|
38
|
+
};
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (arg === "--org")
|
|
42
|
+
opts.org = argv[++i] ?? opts.org;
|
|
43
|
+
else if (arg === "--repo")
|
|
44
|
+
opts.repo = argv[++i] ?? opts.repo;
|
|
45
|
+
else if (arg === "--app-slug")
|
|
46
|
+
opts.appSlug = argv[++i] ?? opts.appSlug;
|
|
47
|
+
else if (arg === "--apply")
|
|
48
|
+
opts.apply = true;
|
|
49
|
+
else if (arg === "--yes")
|
|
50
|
+
opts.yes = true;
|
|
51
|
+
else if (arg === "--json")
|
|
52
|
+
opts.json = true;
|
|
53
|
+
}
|
|
54
|
+
return opts;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fetches full detail for every live ruleset EXCEPT per-team ones —
|
|
58
|
+
* `diff.ts`'s `planRulesets` never reads past a per-team ruleset's name, and
|
|
59
|
+
* there can be up to ~70 of them, so fetching each would be up to ~70 extra
|
|
60
|
+
* GETs this reconciler has no use for.
|
|
61
|
+
*/
|
|
62
|
+
async function fetchDetails(r, summaries) {
|
|
63
|
+
const details = new Map();
|
|
64
|
+
for (const s of summaries) {
|
|
65
|
+
if (isPerTeamRulesetName(s.name))
|
|
66
|
+
continue;
|
|
67
|
+
details.set(s.id, await getRuleset(r, s.id));
|
|
68
|
+
}
|
|
69
|
+
return details;
|
|
70
|
+
}
|
|
71
|
+
function renderRuleLine(rule) {
|
|
72
|
+
return rule.type;
|
|
73
|
+
}
|
|
74
|
+
function renderDesired(desired) {
|
|
75
|
+
return [
|
|
76
|
+
` rules: ${desired.rules.map(renderRuleLine).join(", ")}`,
|
|
77
|
+
` bypass: ${desired.bypass_actors.length === 0
|
|
78
|
+
? "(none)"
|
|
79
|
+
: desired.bypass_actors
|
|
80
|
+
.map((a) => `${a.actor_type}#${a.actor_id} (${a.bypass_mode})`)
|
|
81
|
+
.join(", ")}`,
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
export function renderPlan(plan) {
|
|
85
|
+
const lines = [];
|
|
86
|
+
for (const create of plan.creates) {
|
|
87
|
+
lines.push(`+ create "${create.desired.name}"`);
|
|
88
|
+
lines.push(...renderDesired(create.desired));
|
|
89
|
+
}
|
|
90
|
+
for (const update of plan.updates) {
|
|
91
|
+
const renameNote = update.liveName !== update.desired.name
|
|
92
|
+
? ` (renamed from "${update.liveName}")`
|
|
93
|
+
: "";
|
|
94
|
+
lines.push(`~ update "${update.desired.name}"${renameNote} [id ${update.id}]`);
|
|
95
|
+
lines.push(...renderDesired(update.desired));
|
|
96
|
+
}
|
|
97
|
+
for (const del of plan.deletes) {
|
|
98
|
+
lines.push(`- delete "${del.name}" [id ${del.id}]`);
|
|
99
|
+
}
|
|
100
|
+
for (const noop of plan.noops) {
|
|
101
|
+
lines.push(`= ok "${noop.name}" [id ${noop.id}] — already matches`);
|
|
102
|
+
}
|
|
103
|
+
if (plan.perTeamSkipped.length > 0) {
|
|
104
|
+
lines.push(` skipped ${plan.perTeamSkipped.length} per-team ruleset(s), owned by teamSync.ts: ` +
|
|
105
|
+
plan.perTeamSkipped.join(", "));
|
|
106
|
+
}
|
|
107
|
+
if (plan.unmanaged.length > 0) {
|
|
108
|
+
lines.push(` unmanaged ${plan.unmanaged.join(", ")} — not a fixed ruleset or a deletion target, left alone`);
|
|
109
|
+
}
|
|
110
|
+
if (lines.length === 0)
|
|
111
|
+
return "(nothing to report)";
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
async function confirmApply(yes) {
|
|
115
|
+
if (yes)
|
|
116
|
+
return true;
|
|
117
|
+
if (!process.stdin.isTTY) {
|
|
118
|
+
process.stderr.write("devtools github rulesets: --yes is required to apply with no terminal available.\n");
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return unwrap(await confirm({
|
|
122
|
+
message: "Apply this ruleset plan?",
|
|
123
|
+
initialValue: false,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
export async function runGithubRulesets(argv) {
|
|
127
|
+
const opts = parseOptions(argv);
|
|
128
|
+
const r = { owner: opts.org, repo: opts.repo };
|
|
129
|
+
let devopsTeamId;
|
|
130
|
+
let adminsTeamId;
|
|
131
|
+
let appId;
|
|
132
|
+
try {
|
|
133
|
+
[devopsTeamId, adminsTeamId, appId] = await Promise.all([
|
|
134
|
+
resolveTeamId(opts.org, DEFAULT_DEVOPS_SLUG),
|
|
135
|
+
resolveTeamId(opts.org, DEFAULT_ADMINS_SLUG),
|
|
136
|
+
resolveAppId(opts.org, opts.appSlug),
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
process.stderr.write(`devtools github rulesets: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
// Renovate (TASK-299) is resolved SEPARATELY and is allowed to fail: Sloan
|
|
144
|
+
// may not have installed it yet, and that must not block reconciling
|
|
145
|
+
// everything else. A failure here prints a warning and the plan simply
|
|
146
|
+
// omits it from `~ALL`'s bypass list — re-running after the App is
|
|
147
|
+
// installed picks it up with no further action.
|
|
148
|
+
let renovateAppId;
|
|
149
|
+
try {
|
|
150
|
+
renovateAppId = await resolveAppId(opts.org, DEFAULT_RENOVATE_SLUG);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
process.stderr.write(`devtools github rulesets: warning: could not resolve the Renovate App ` +
|
|
154
|
+
`("${DEFAULT_RENOVATE_SLUG}") — ~ALL's plan omits it as a bypass actor ` +
|
|
155
|
+
`until it is installed (TASK-299). ${err instanceof Error ? err.message : String(err)}\n`);
|
|
156
|
+
}
|
|
157
|
+
const actors = { devopsTeamId, adminsTeamId, appId, renovateAppId };
|
|
158
|
+
const desired = buildDesiredRulesets(actors);
|
|
159
|
+
let summaries;
|
|
160
|
+
let details;
|
|
161
|
+
try {
|
|
162
|
+
summaries = await listRulesets(r);
|
|
163
|
+
details = await fetchDetails(r, summaries);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
process.stderr.write(`devtools github rulesets: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const plan = planRulesets(summaries, details, actors, desired);
|
|
170
|
+
if (opts.json) {
|
|
171
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
console.log(renderPlan(plan));
|
|
175
|
+
}
|
|
176
|
+
if (!opts.apply) {
|
|
177
|
+
if (!opts.json && planHasChanges(plan)) {
|
|
178
|
+
console.log("\n(dry run — pass --apply to write this plan)");
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
if (!planHasChanges(plan))
|
|
183
|
+
return 0;
|
|
184
|
+
if (!(await confirmApply(opts.yes)))
|
|
185
|
+
return 1;
|
|
186
|
+
try {
|
|
187
|
+
for (const create of plan.creates) {
|
|
188
|
+
await createRuleset(r, create.desired);
|
|
189
|
+
}
|
|
190
|
+
for (const update of plan.updates) {
|
|
191
|
+
await updateRuleset(r, update.id, update.desired);
|
|
192
|
+
}
|
|
193
|
+
for (const del of plan.deletes) {
|
|
194
|
+
await deleteRuleset(r, del.id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
process.stderr.write(`devtools github rulesets: apply failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
console.log("Applied.");
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five fixed rulesets this repository must carry, as code.
|
|
3
|
+
*
|
|
4
|
+
* `apps/platform/src/server/github/rulesets.ts` (DevDogsUGA) has described
|
|
5
|
+
* these in prose since it was written — its module doc counts them toward
|
|
6
|
+
* the 75-ruleset cap — but nothing ever created them. TASK-322 is that gap:
|
|
7
|
+
* `main-protection` blocked only deletion and force-push (a push landed on
|
|
8
|
+
* `main` directly), `production` had no ruleset at all (a push there
|
|
9
|
+
* deploys), and a stale `comp-branches` ruleset survived a naming change.
|
|
10
|
+
* This module is the missing desired-state builder; `diff.ts` compares it
|
|
11
|
+
* against what `gh api .../rulesets` actually reports; `commands.ts` is the
|
|
12
|
+
* `devtools github rulesets` command that prints or applies the difference.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the fixed `team/**` ruleset is safe alongside per-team ones
|
|
15
|
+
*
|
|
16
|
+
* `rulesets.ts`'s constraint 2 warns against ever running a broad `team/**`
|
|
17
|
+
* ruleset alongside the per-team ones `teamSync.ts` creates: rules AGGREGATE
|
|
18
|
+
* across rulesets sharing a ref, but bypass does NOT, so a broad ruleset
|
|
19
|
+
* restricting `update`/`push` would leave every team blocked by the broad
|
|
20
|
+
* one while bypassing only its own — readable, unpushable, silently.
|
|
21
|
+
*
|
|
22
|
+
* That warning is about a ruleset carrying `update` on `team/**`. This one
|
|
23
|
+
* carries `creation`, `deletion` and `non_fast_forward` — deliberately NOT
|
|
24
|
+
* `update`. No per-team ruleset (`teamRulesetPayload` in DevDogsUGA) has
|
|
25
|
+
* ever granted a `creation` rule — `rulesets.ts`'s own doc says why: the
|
|
26
|
+
* branch is cut by `cutTeamBranch` with the App's token before the per-team
|
|
27
|
+
* ruleset exists, so a `creation` rule there would be inert on the first
|
|
28
|
+
* branch and would block re-provisioning after a deletion. Two rulesets
|
|
29
|
+
* governing disjoint rule TYPES on the same ref pattern don't aggregate into
|
|
30
|
+
* a conflict for `creation` — there is nothing for them to disagree about.
|
|
31
|
+
* What that rule closes is a real hole `rulesets.ts` never covered: every
|
|
32
|
+
* team member holds a repository-wide `push` grant (GitHub team permissions
|
|
33
|
+
* have no branch dimension), so *before* the platform provisions a team, any
|
|
34
|
+
* member could `git push origin HEAD:refs/heads/team/<slug>` and front-run
|
|
35
|
+
* the App — creating the ref with content of their choosing, or squatting a
|
|
36
|
+
* slug that never gets legitimately provisioned.
|
|
37
|
+
*
|
|
38
|
+
* `deletion` and `non_fast_forward` DO aggregate against the per-team
|
|
39
|
+
* ruleset's own `deletion` rule (and its deliberate absence of
|
|
40
|
+
* `non_fast_forward`) — deliberately. A team is a bypass actor on its OWN
|
|
41
|
+
* per-team ruleset, not on this one, so the aggregate result is that a team
|
|
42
|
+
* can no longer delete or force-push its own branch, even though its
|
|
43
|
+
* per-team ruleset alone would let it (bypass is ruleset-scoped, constraint
|
|
44
|
+
* 1) — TASK-324's access-control checklist requires exactly that refusal.
|
|
45
|
+
* `update` is excluded from this ruleset for the opposite reason: it is the
|
|
46
|
+
* one rule type the per-team ruleset itself carries and bypasses its own
|
|
47
|
+
* team for, so adding a second `update` rule here — which only the App and
|
|
48
|
+
* devops bypass — would block every team's ordinary pushes to its own
|
|
49
|
+
* branch despite its per-team ruleset correctly bypassing them. That is
|
|
50
|
+
* exactly the failure constraint 2 describes; leaving `update` out is what
|
|
51
|
+
* keeps this ruleset on the safe side of it.
|
|
52
|
+
*
|
|
53
|
+
* ## Fixed cost is now 5, not 4
|
|
54
|
+
*
|
|
55
|
+
* `rulesets.ts`'s constraint 3 said "Fixed cost is 4: `main`, `production`,
|
|
56
|
+
* `~ALL` and the tag ruleset, leaving 71 for teams." This adds `team/**` as
|
|
57
|
+
* a fifth fixed ruleset, so the constant there needs updating to 5 fixed /
|
|
58
|
+
* 70 for teams — done alongside this file, in the same DevDogsUGA commit
|
|
59
|
+
* that would eventually consume this reconciler's output.
|
|
60
|
+
*/
|
|
61
|
+
import type { DesiredRuleset } from "./types.js";
|
|
62
|
+
/** Resolved GitHub ids this module needs before it can build a plan. */
|
|
63
|
+
export interface RulesetActors {
|
|
64
|
+
/** The `devops` team's numeric id (`GET /orgs/{org}/teams/devops`). */
|
|
65
|
+
devopsTeamId: number;
|
|
66
|
+
/** The `admins` team's numeric id — bypasses the tag ruleset, as it does live today. */
|
|
67
|
+
adminsTeamId: number;
|
|
68
|
+
/** The DevDogs Platform GitHub App's id (`app_id`, not the installation id). */
|
|
69
|
+
appId: number;
|
|
70
|
+
/**
|
|
71
|
+
* The Renovate GitHub App's id, or `undefined` when it cannot be resolved
|
|
72
|
+
* yet (TASK-299 — Sloan has not installed it at the time this module was
|
|
73
|
+
* written). `undefined` means "omit it from `~ALL`'s bypass list", not "fail
|
|
74
|
+
* the plan" — `commands.ts` resolves this separately from the other three
|
|
75
|
+
* (all required) and prints a warning rather than refusing, so re-running
|
|
76
|
+
* after the App is installed picks it up with no code change here.
|
|
77
|
+
*/
|
|
78
|
+
renovateAppId?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Canonical fixed-ruleset name -> earlier name(s) this reconciler recognizes
|
|
82
|
+
* as the SAME slot, so the first run renames rather than creating a
|
|
83
|
+
* duplicate that leaves the old one live and unmanaged.
|
|
84
|
+
*
|
|
85
|
+
* Only `main` has a legacy alias today (`main-protection`, live on GitHub as
|
|
86
|
+
* of 2026-09-24). `production`, `~ALL` and `team/**` have no live ruleset to
|
|
87
|
+
* rename — they are created fresh. The tag ruleset keeps its live name,
|
|
88
|
+
* `tag-protection`, unchanged; nothing about its shape is wrong today, so
|
|
89
|
+
* there is no rename to make.
|
|
90
|
+
*/
|
|
91
|
+
export declare const LEGACY_NAME_ALIASES: Readonly<Record<string, readonly string[]>>;
|
|
92
|
+
/** Ruleset names this reconciler deletes outright — nothing in the desired set replaces them. */
|
|
93
|
+
export declare const DELETE_RULESET_NAMES: readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* A live ruleset name this reconciler must NEVER touch: one `teamSync.ts`
|
|
96
|
+
* created and owns (`team/<slug>`, an exact-ref ruleset), as opposed to the
|
|
97
|
+
* `team/**` wildcard-creation ruleset this module itself manages.
|
|
98
|
+
*
|
|
99
|
+
* `teamRulesetName()` in DevDogsUGA's `rulesets.ts` builds these as
|
|
100
|
+
* `team/${slug}`, and slugs never contain `/`, so a live name is a per-team
|
|
101
|
+
* ruleset iff it starts with `team/` and is not the literal `team/**` this
|
|
102
|
+
* reconciler owns.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isPerTeamRulesetName(name: string): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The five fixed rulesets, built from resolved actor ids.
|
|
107
|
+
*
|
|
108
|
+
* Pure — no network, no `gh` — so `desired.test.ts` can assert the shape
|
|
109
|
+
* without mocking anything, and `commands.ts` calls this only after
|
|
110
|
+
* resolving `RulesetActors` from the live org.
|
|
111
|
+
*/
|
|
112
|
+
export declare function buildDesiredRulesets(actors: RulesetActors): DesiredRuleset[];
|