@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.
Files changed (253) hide show
  1. package/README.md +44 -0
  2. package/bin/devtools-ci-bare.mjs +48 -0
  3. package/bin/devtools-ci.mjs +17 -0
  4. package/bin/devtools.mjs +27 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.js +78 -0
  7. package/dist/bws/bw.d.ts +9 -0
  8. package/dist/bws/bw.js +49 -0
  9. package/dist/bws/client.d.ts +43 -0
  10. package/dist/bws/client.js +363 -0
  11. package/dist/bws/environments.d.ts +96 -0
  12. package/dist/bws/environments.js +111 -0
  13. package/dist/bws/pace.d.ts +27 -0
  14. package/dist/bws/pace.js +43 -0
  15. package/dist/bws/retry.d.ts +12 -0
  16. package/dist/bws/retry.js +39 -0
  17. package/dist/bws/store.d.ts +1 -0
  18. package/dist/bws/store.js +32 -0
  19. package/dist/bws/token.d.ts +31 -0
  20. package/dist/bws/token.js +88 -0
  21. package/dist/bws/vault.d.ts +29 -0
  22. package/dist/bws/vault.js +200 -0
  23. package/dist/catalog.d.ts +55 -0
  24. package/dist/catalog.js +81 -0
  25. package/dist/cf/commands.d.ts +1 -0
  26. package/dist/cf/commands.js +182 -0
  27. package/dist/cf/local-env.d.ts +44 -0
  28. package/dist/cf/local-env.js +148 -0
  29. package/dist/ci.d.ts +1 -0
  30. package/dist/ci.js +319 -0
  31. package/dist/cli.d.ts +11 -0
  32. package/dist/cli.js +1077 -0
  33. package/dist/commands.d.ts +255 -0
  34. package/dist/commands.js +1279 -0
  35. package/dist/completions.d.ts +4 -0
  36. package/dist/completions.js +110 -0
  37. package/dist/cron/commands.d.ts +47 -0
  38. package/dist/cron/commands.js +372 -0
  39. package/dist/cron/discovery.d.ts +55 -0
  40. package/dist/cron/discovery.js +85 -0
  41. package/dist/cron/schema.d.ts +45 -0
  42. package/dist/cron/schema.js +35 -0
  43. package/dist/db/config-push.d.ts +8 -0
  44. package/dist/db/config-push.js +11 -0
  45. package/dist/db/connection.d.ts +79 -0
  46. package/dist/db/connection.js +114 -0
  47. package/dist/db/exec.d.ts +1 -0
  48. package/dist/db/exec.js +11 -0
  49. package/dist/db/generate-migration.d.ts +1 -0
  50. package/dist/db/generate-migration.js +67 -0
  51. package/dist/db/generate-types.d.ts +1 -0
  52. package/dist/db/generate-types.js +4 -0
  53. package/dist/db/introspect.d.ts +1 -0
  54. package/dist/db/introspect.js +113 -0
  55. package/dist/db/local-env.d.ts +65 -0
  56. package/dist/db/local-env.js +23 -0
  57. package/dist/db/new-migration.d.ts +1 -0
  58. package/dist/db/new-migration.js +8 -0
  59. package/dist/db/run.d.ts +70 -0
  60. package/dist/db/run.js +158 -0
  61. package/dist/db/seed-buckets.d.ts +2 -0
  62. package/dist/db/seed-buckets.js +4 -0
  63. package/dist/db/seed-production.d.ts +8 -0
  64. package/dist/db/seed-production.js +73 -0
  65. package/dist/db/seed-roles.d.ts +7 -0
  66. package/dist/db/seed-roles.js +31 -0
  67. package/dist/db/session-refresh.d.ts +43 -0
  68. package/dist/db/session-refresh.js +40 -0
  69. package/dist/deploy/migrations.d.ts +20 -0
  70. package/dist/deploy/migrations.js +68 -0
  71. package/dist/deploy/orphans.d.ts +20 -0
  72. package/dist/deploy/orphans.js +177 -0
  73. package/dist/deploy/preflight.d.ts +53 -0
  74. package/dist/deploy/preflight.js +189 -0
  75. package/dist/deploy/report.d.ts +26 -0
  76. package/dist/deploy/report.js +70 -0
  77. package/dist/deploy/require-planner.d.ts +2 -0
  78. package/dist/deploy/require-planner.js +60 -0
  79. package/dist/deploy/require-token.d.ts +15 -0
  80. package/dist/deploy/require-token.js +78 -0
  81. package/dist/deploy/secrets-file.d.ts +17 -0
  82. package/dist/deploy/secrets-file.js +167 -0
  83. package/dist/deploy/write-env.d.ts +40 -0
  84. package/dist/deploy/write-env.js +393 -0
  85. package/dist/docs/index-pages.d.ts +36 -0
  86. package/dist/docs/index-pages.js +184 -0
  87. package/dist/doctor.d.ts +62 -0
  88. package/dist/doctor.js +225 -0
  89. package/dist/emails/commands.d.ts +15 -0
  90. package/dist/emails/commands.js +130 -0
  91. package/dist/emails/email-types.d.ts +13 -0
  92. package/dist/emails/email-types.js +1 -0
  93. package/dist/emails/fixtures.d.ts +20 -0
  94. package/dist/emails/fixtures.js +20 -0
  95. package/dist/env/audit.d.ts +206 -0
  96. package/dist/env/audit.js +436 -0
  97. package/dist/env/cloudflare.d.ts +21 -0
  98. package/dist/env/cloudflare.js +61 -0
  99. package/dist/env/commands.d.ts +99 -0
  100. package/dist/env/commands.js +612 -0
  101. package/dist/env/discovery.d.ts +15 -0
  102. package/dist/env/discovery.js +226 -0
  103. package/dist/env/document.d.ts +119 -0
  104. package/dist/env/document.js +323 -0
  105. package/dist/env/example.d.ts +68 -0
  106. package/dist/env/example.js +603 -0
  107. package/dist/env/selection.d.ts +105 -0
  108. package/dist/env/selection.js +226 -0
  109. package/dist/environment.d.ts +92 -0
  110. package/dist/environment.js +206 -0
  111. package/dist/fingerprint.d.ts +10 -0
  112. package/dist/fingerprint.js +18 -0
  113. package/dist/gen/campus-map.d.ts +1 -0
  114. package/dist/gen/campus-map.js +473 -0
  115. package/dist/gen/commands.d.ts +1 -0
  116. package/dist/gen/commands.js +25 -0
  117. package/dist/gen/hypno.d.ts +1 -0
  118. package/dist/gen/hypno.js +134 -0
  119. package/dist/gh/client.d.ts +80 -0
  120. package/dist/gh/client.js +258 -0
  121. package/dist/gh/environments.d.ts +83 -0
  122. package/dist/gh/environments.js +178 -0
  123. package/dist/gh/rulesets/actors.d.ts +14 -0
  124. package/dist/gh/rulesets/actors.js +68 -0
  125. package/dist/gh/rulesets/api.d.ts +15 -0
  126. package/dist/gh/rulesets/api.js +99 -0
  127. package/dist/gh/rulesets/commands.d.ts +3 -0
  128. package/dist/gh/rulesets/commands.js +203 -0
  129. package/dist/gh/rulesets/desired.d.ts +112 -0
  130. package/dist/gh/rulesets/desired.js +247 -0
  131. package/dist/gh/rulesets/diff.d.ts +65 -0
  132. package/dist/gh/rulesets/diff.js +134 -0
  133. package/dist/gh/rulesets/types.d.ts +76 -0
  134. package/dist/gh/rulesets/types.js +13 -0
  135. package/dist/gh/settings/api.d.ts +40 -0
  136. package/dist/gh/settings/api.js +172 -0
  137. package/dist/gh/settings/commands.d.ts +1 -0
  138. package/dist/gh/settings/commands.js +245 -0
  139. package/dist/gh/settings/desired.d.ts +53 -0
  140. package/dist/gh/settings/desired.js +39 -0
  141. package/dist/gh/settings/diff.d.ts +57 -0
  142. package/dist/gh/settings/diff.js +156 -0
  143. package/dist/gh/settings/types.d.ts +92 -0
  144. package/dist/gh/settings/types.js +11 -0
  145. package/dist/gh/settings/workflows.d.ts +41 -0
  146. package/dist/gh/settings/workflows.js +124 -0
  147. package/dist/grantRoot.d.ts +42 -0
  148. package/dist/grantRoot.js +89 -0
  149. package/dist/help.d.ts +18 -0
  150. package/dist/help.js +169 -0
  151. package/dist/images/commands.d.ts +35 -0
  152. package/dist/images/commands.js +233 -0
  153. package/dist/images/events.d.ts +36 -0
  154. package/dist/images/events.js +152 -0
  155. package/dist/images/graphics.d.ts +70 -0
  156. package/dist/images/graphics.js +233 -0
  157. package/dist/images/open-graph-types.d.ts +75 -0
  158. package/dist/images/open-graph-types.js +1 -0
  159. package/dist/images/prompts.d.ts +34 -0
  160. package/dist/images/prompts.js +100 -0
  161. package/dist/images/render.d.ts +21 -0
  162. package/dist/images/render.js +57 -0
  163. package/dist/images/select.d.ts +64 -0
  164. package/dist/images/select.js +96 -0
  165. package/dist/instance.d.ts +67 -0
  166. package/dist/instance.js +129 -0
  167. package/dist/invocation.d.ts +49 -0
  168. package/dist/invocation.js +78 -0
  169. package/dist/launch-ci.d.ts +1 -0
  170. package/dist/launch-ci.js +86 -0
  171. package/dist/launch.d.ts +29 -0
  172. package/dist/launch.js +371 -0
  173. package/dist/menu.d.ts +31 -0
  174. package/dist/menu.js +277 -0
  175. package/dist/newsletter/commands.d.ts +16 -0
  176. package/dist/newsletter/commands.js +311 -0
  177. package/dist/newsletter/imap.d.ts +17 -0
  178. package/dist/newsletter/imap.js +127 -0
  179. package/dist/newsletter/loopback.d.ts +24 -0
  180. package/dist/newsletter/loopback.js +100 -0
  181. package/dist/newsletter/oauth.d.ts +26 -0
  182. package/dist/newsletter/oauth.js +130 -0
  183. package/dist/newsletter/smtp.d.ts +23 -0
  184. package/dist/newsletter/smtp.js +133 -0
  185. package/dist/oauth/browser.d.ts +1 -0
  186. package/dist/oauth/browser.js +13 -0
  187. package/dist/oauth/config.d.ts +24 -0
  188. package/dist/oauth/config.js +24 -0
  189. package/dist/oauth/connect-url.d.ts +17 -0
  190. package/dist/oauth/connect-url.js +23 -0
  191. package/dist/oauth/db.d.ts +37 -0
  192. package/dist/oauth/db.js +108 -0
  193. package/dist/oauth/device.d.ts +93 -0
  194. package/dist/oauth/device.js +190 -0
  195. package/dist/oauth/discovery.d.ts +28 -0
  196. package/dist/oauth/discovery.js +54 -0
  197. package/dist/oauth/env-file.d.ts +6 -0
  198. package/dist/oauth/env-file.js +35 -0
  199. package/dist/oauth/exchange.d.ts +43 -0
  200. package/dist/oauth/exchange.js +87 -0
  201. package/dist/oauth/label.d.ts +1 -0
  202. package/dist/oauth/label.js +25 -0
  203. package/dist/oauth/loopback.d.ts +52 -0
  204. package/dist/oauth/loopback.js +162 -0
  205. package/dist/oauth/pkce.d.ts +6 -0
  206. package/dist/oauth/pkce.js +34 -0
  207. package/dist/oauth/target.d.ts +54 -0
  208. package/dist/oauth/target.js +102 -0
  209. package/dist/oauth/transport.d.ts +41 -0
  210. package/dist/oauth/transport.js +32 -0
  211. package/dist/oauth/wizard.d.ts +33 -0
  212. package/dist/oauth/wizard.js +571 -0
  213. package/dist/pick.d.ts +6 -0
  214. package/dist/pick.js +64 -0
  215. package/dist/planner/checks.d.ts +26 -0
  216. package/dist/planner/checks.js +104 -0
  217. package/dist/planner/commands.d.ts +10 -0
  218. package/dist/planner/commands.js +367 -0
  219. package/dist/planner/db.d.ts +15 -0
  220. package/dist/planner/db.js +33 -0
  221. package/dist/planner/role.d.ts +33 -0
  222. package/dist/planner/role.js +68 -0
  223. package/dist/repo/peers.d.ts +24 -0
  224. package/dist/repo/peers.js +114 -0
  225. package/dist/repo/preflight.d.ts +109 -0
  226. package/dist/repo/preflight.js +266 -0
  227. package/dist/repo/resolve.d.ts +41 -0
  228. package/dist/repo/resolve.js +152 -0
  229. package/dist/repo/root.d.ts +23 -0
  230. package/dist/repo/root.js +91 -0
  231. package/dist/repo/source.d.ts +14 -0
  232. package/dist/repo/source.js +54 -0
  233. package/dist/repo/tsx-loader.d.ts +6 -0
  234. package/dist/repo/tsx-loader.js +41 -0
  235. package/dist/run/pick.d.ts +60 -0
  236. package/dist/run/pick.js +559 -0
  237. package/dist/setup.d.ts +1 -0
  238. package/dist/setup.js +152 -0
  239. package/dist/stack.d.ts +48 -0
  240. package/dist/stack.js +290 -0
  241. package/dist/telemetry.d.ts +34 -0
  242. package/dist/telemetry.js +93 -0
  243. package/dist/tier.d.ts +40 -0
  244. package/dist/tier.js +93 -0
  245. package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
  246. package/dist/ui.d.ts +25 -0
  247. package/dist/ui.js +51 -0
  248. package/dist/workers.d.ts +5 -0
  249. package/dist/workers.js +35 -0
  250. package/dist/workflows/commands.d.ts +44 -0
  251. package/dist/workflows/commands.js +668 -0
  252. package/env.ts +158 -0
  253. 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,3 @@
1
+ import { type RulesetPlan } from "./diff.js";
2
+ export declare function renderPlan(plan: RulesetPlan): string;
3
+ export declare function runGithubRulesets(argv: readonly string[]): Promise<number>;
@@ -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[];