@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,80 @@
1
+ export declare class GhError extends Error {
2
+ }
3
+ export interface GhSecret {
4
+ name: string;
5
+ updatedAt: string;
6
+ }
7
+ /** Unlike `GhSecret`, this carries the value: variables are readable. */
8
+ export interface GhVariable {
9
+ name: string;
10
+ value: string;
11
+ updatedAt: string;
12
+ }
13
+ /** Environment secret names and when each last changed. Never values. */
14
+ export declare function listSecrets(environment: string): Promise<GhSecret[]>;
15
+ /**
16
+ * Sets one secret, passing the value on **stdin**.
17
+ *
18
+ * Not `--body`, which would put a live credential in argv where `ps` can read
19
+ * it. Not `--env-file` either, despite it being one call for the whole set:
20
+ * that hands the file to `gh`'s own dotenv parser, and this tool has already
21
+ * parsed it with different rules. Two parsers over one file agree right up
22
+ * until a multi-line value, a PEM private key being the case, and then disagree
23
+ * silently, storing something that looks like a key and is not.
24
+ *
25
+ * One process per secret is the cost. It is paid once per rotation.
26
+ */
27
+ export declare function setSecret(environment: string, name: string, value: string): Promise<void>;
28
+ export declare function deleteSecret(environment: string, name: string): Promise<void>;
29
+ /**
30
+ * Environment variable names, VALUES, and when each last changed.
31
+ *
32
+ * The value is the point. It is what turns the GitHub half of `env audit` from
33
+ * a presence check into a real comparison for these keys: a variable whose
34
+ * value drifted from Bitwarden is detectable, where the same drift in a secret
35
+ * is not detectable by anything.
36
+ */
37
+ export declare function listVariables(environment: string): Promise<GhVariable[]>;
38
+ /**
39
+ * Sets one variable, passing the value on **stdin**.
40
+ *
41
+ * Stdin rather than `--body` even though a variable is public by definition:
42
+ * argv is shared machinery, and a rule with an exception ("stdin for secrets,
43
+ * argv for variables") is a rule somebody applies to the wrong call. The
44
+ * `--env-file` form is refused for the reason `setSecret` refuses it: it would
45
+ * hand the file to `gh`'s own dotenv parser, which agrees with this tool's
46
+ * parser right up until a multi-line value and then disagrees silently.
47
+ */
48
+ export declare function setVariable(environment: string, name: string, value: string): Promise<void>;
49
+ export declare function deleteVariable(environment: string, name: string): Promise<void>;
50
+ /**
51
+ * A repository-level variable. NAME ONLY, and that is deliberate.
52
+ *
53
+ * `gh variable list` will return the value here as readily as it does for an
54
+ * environment variable, and fetching it would invite the obvious next step,
55
+ * comparing it against Bitwarden. That comparison has no correct answer. This
56
+ * copy is unmanaged whatever it holds, so a value that matches today proves
57
+ * nothing except that somebody set them the same way once; reporting only the
58
+ * mismatch would then read as "the matching one is fine", which is the belief
59
+ * this whole check exists to break.
60
+ */
61
+ export interface GhRepositoryVariable {
62
+ name: string;
63
+ updatedAt: string;
64
+ }
65
+ /**
66
+ * The repository's OWN variables, the ones with no environment.
67
+ *
68
+ * ⚠️ The reason this exists at all: an environment variable of the same name
69
+ * **shadows** a repository one, and nothing in GitHub's UI says so. Every job
70
+ * that runs in the environment reads the environment copy, so a repository
71
+ * variable a person set by hand before `env push` routed that key keeps its
72
+ * stale value indefinitely and invisibly, right up until somebody deletes the
73
+ * environment copy, at which point the stale value silently becomes live.
74
+ *
75
+ * No `--env`, and that missing flag IS the call. Everything else in this file
76
+ * addresses one environment; this addresses the repository itself, which is
77
+ * the scope nothing here writes to and therefore the scope nothing here would
78
+ * otherwise notice.
79
+ */
80
+ export declare function listRepositoryVariables(): Promise<GhRepositoryVariable[]>;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * A thin wrapper over the `gh` CLI, for environment secrets and variables.
3
+ *
4
+ * `gh` rather than the REST API for the same reason `bws` is used over its API:
5
+ * a secret's value has to be encrypted client-side with a libsodium sealed box
6
+ * against the environment's public key before it can be sent. `gh` does that
7
+ * locally. Doing it by hand means fetching the key, pulling in a sodium
8
+ * binding, and owning that code forever to avoid one dependency that is already
9
+ * installed.
10
+ *
11
+ * ## Secrets and variables are two different stores
12
+ *
13
+ * They share an environment and nothing else, and the differences are the whole
14
+ * reason the public per-environment values go to the second:
15
+ *
16
+ * | | Sent as | Readable back | In logs |
17
+ * |------------|------------|---------------|----------------|
18
+ * | secret | sealed box | never | masked, by substring |
19
+ * | variable | plaintext | value and all | verbatim |
20
+ *
21
+ * The masking is not a nicety either way. `PROJECT_REF` as a *secret* rewrites
22
+ * `https://supabase.com/dashboard/project/<ref>` to `.../***`, which is the
23
+ * paused-project gate's whole output. And because the ref is a substring of
24
+ * every Supabase hostname, it corrupts unrelated log lines across the repo. The
25
+ * readability is what lets `env audit` compare a variable by VALUE, which it
26
+ * cannot do for anything on the top row.
27
+ *
28
+ * ⚠️ The corollary, and the reason each setter is its own function rather than
29
+ * one with a flag: putting a secret in the variable store publishes its value
30
+ * to anyone with read access to the repository's Actions config, silently and
31
+ * irreversibly. There is no operation here that can be pointed at the wrong
32
+ * store by a boolean somebody passed the wrong way round.
33
+ */
34
+ import { spawn } from "node:child_process";
35
+ import { promisify } from "node:util";
36
+ import { execFile } from "node:child_process";
37
+ const run = promisify(execFile);
38
+ const MAX_BUFFER = 16 * 1024 * 1024;
39
+ export class GhError extends Error {
40
+ }
41
+ let warnedAboutTokenOverride = false;
42
+ /**
43
+ * Says, once, when `gh` is about to ignore its own login.
44
+ *
45
+ * `gh` prefers `GH_TOKEN` / `GITHUB_TOKEN` from the environment over the
46
+ * account somebody logged in with, and `with-env` loads `.env` for every
47
+ * devtools command, so a token line in that file takes over EVERY `gh` call
48
+ * silently. That is how a leftover classic PAT kept authenticating after the
49
+ * operator's real login was a perfectly valid OAuth token, right up until
50
+ * the org's classic-PAT block turned it into a 403 with no visible cause.
51
+ * stderr and not clack, so it shows the same way from every command shape.
52
+ */
53
+ function warnOnEnvTokenOverride() {
54
+ if (warnedAboutTokenOverride)
55
+ return;
56
+ const source = process.env.GH_TOKEN
57
+ ? "GH_TOKEN"
58
+ : process.env.GITHUB_TOKEN
59
+ ? "GITHUB_TOKEN"
60
+ : undefined;
61
+ if (!source)
62
+ return;
63
+ warnedAboutTokenOverride = true;
64
+ console.error(`gh: authenticating with ${source} from the environment, NOT your ` +
65
+ "`gh auth login` account. with-env loads .env, so a token line there " +
66
+ "takes over silently — comment it out if that is not what you meant.");
67
+ }
68
+ /**
69
+ * Turns a spawn failure into something with a next step in it.
70
+ *
71
+ * The three that happen are a missing binary, an unauthenticated CLI, and an
72
+ * environment that does not exist yet. The last reports as a bare 404, because
73
+ * an environment you cannot see and one that was never created look identical
74
+ * from here.
75
+ */
76
+ function describe(err) {
77
+ const e = err;
78
+ if (e.code === "ENOENT") {
79
+ return ("The `gh` CLI is not installed, or is not on PATH.\n" +
80
+ "Install it from https://cli.github.com, then run `gh auth login`.");
81
+ }
82
+ const stderr = (e.stderr ?? "").trim();
83
+ if (/personal access token \(classic\)/i.test(stderr)) {
84
+ return (`${stderr}\n\n` +
85
+ "The organization forbids classic PATs (by design — §0.3), and gh " +
86
+ "authenticated with one anyway. gh prefers GH_TOKEN / GITHUB_TOKEN " +
87
+ "from the environment over your `gh auth login`, and with-env loads " +
88
+ ".env — so the usual culprit is a leftover ghp_ token line in that " +
89
+ "file. Comment it out (and revoke the token at " +
90
+ "github.com/settings/tokens); your stored gh login then takes over.");
91
+ }
92
+ if (/not logged into|authentication required|gh auth login/i.test(stderr)) {
93
+ return `${stderr}\n\nRun \`gh auth login\`. Managing environment secrets needs the \`repo\` scope.`;
94
+ }
95
+ if (/404|not found/i.test(stderr)) {
96
+ return (`${stderr}\n\n` +
97
+ "A 404 usually means the environment does not exist yet — GitHub creates " +
98
+ "environments explicitly, and a typo silently addresses a different one. " +
99
+ "Check Settings → Environments.");
100
+ }
101
+ if (/403|forbidden|must have admin/i.test(stderr)) {
102
+ return `${stderr}\n\nSetting environment secrets needs admin on the repository.`;
103
+ }
104
+ if (/\b429\b|rate.?limit|too many requests/i.test(stderr)) {
105
+ return (`${stderr}\n\n` +
106
+ "GitHub rate-limited this. A push is one API call per secret, so a " +
107
+ "burst of pushes can trip the secondary limit — wait a minute and " +
108
+ "re-run; already-written values are simply overwritten with themselves.");
109
+ }
110
+ return stderr || e.message || "gh failed with no output.";
111
+ }
112
+ /** Environment secret names and when each last changed. Never values. */
113
+ export async function listSecrets(environment) {
114
+ warnOnEnvTokenOverride();
115
+ try {
116
+ const { stdout } = await run("gh", ["secret", "list", "--env", environment, "--json", "name,updatedAt"], { maxBuffer: MAX_BUFFER, shell: false });
117
+ return stdout.trim() === "" ? [] : JSON.parse(stdout);
118
+ }
119
+ catch (err) {
120
+ throw new GhError(describe(err));
121
+ }
122
+ }
123
+ /**
124
+ * Sets one secret, passing the value on **stdin**.
125
+ *
126
+ * Not `--body`, which would put a live credential in argv where `ps` can read
127
+ * it. Not `--env-file` either, despite it being one call for the whole set:
128
+ * that hands the file to `gh`'s own dotenv parser, and this tool has already
129
+ * parsed it with different rules. Two parsers over one file agree right up
130
+ * until a multi-line value, a PEM private key being the case, and then disagree
131
+ * silently, storing something that looks like a key and is not.
132
+ *
133
+ * One process per secret is the cost. It is paid once per rotation.
134
+ */
135
+ export async function setSecret(environment, name, value) {
136
+ warnOnEnvTokenOverride();
137
+ await new Promise((resolve, reject) => {
138
+ const child = spawn("gh", ["secret", "set", name, "--env", environment], {
139
+ stdio: ["pipe", "ignore", "pipe"],
140
+ shell: false,
141
+ });
142
+ let stderr = "";
143
+ child.stderr.on("data", (c) => (stderr += c.toString()));
144
+ child.on("error", (err) => reject(new GhError(describe(err))));
145
+ child.on("close", (code) => code === 0
146
+ ? resolve()
147
+ : reject(new GhError(describe({ stderr, message: `gh exited ${code}` }))));
148
+ // Exactly the bytes, with no trailing newline: `gh` stores stdin verbatim,
149
+ // so a stray "\n" becomes part of the secret and every comparison against
150
+ // it fails in a way that reads like a wrong value rather than a stray byte.
151
+ child.stdin.end(value);
152
+ });
153
+ }
154
+ export async function deleteSecret(environment, name) {
155
+ warnOnEnvTokenOverride();
156
+ try {
157
+ await run("gh", ["secret", "delete", name, "--env", environment], {
158
+ shell: false,
159
+ });
160
+ }
161
+ catch (err) {
162
+ throw new GhError(describe(err));
163
+ }
164
+ }
165
+ // ── variables ────────────────────────────────────────────────────────────────
166
+ /**
167
+ * Environment variable names, VALUES, and when each last changed.
168
+ *
169
+ * The value is the point. It is what turns the GitHub half of `env audit` from
170
+ * a presence check into a real comparison for these keys: a variable whose
171
+ * value drifted from Bitwarden is detectable, where the same drift in a secret
172
+ * is not detectable by anything.
173
+ */
174
+ export async function listVariables(environment) {
175
+ warnOnEnvTokenOverride();
176
+ try {
177
+ const { stdout } = await run("gh", [
178
+ "variable",
179
+ "list",
180
+ "--env",
181
+ environment,
182
+ "--json",
183
+ "name,value,updatedAt",
184
+ ], { maxBuffer: MAX_BUFFER, shell: false });
185
+ return stdout.trim() === "" ? [] : JSON.parse(stdout);
186
+ }
187
+ catch (err) {
188
+ throw new GhError(describe(err));
189
+ }
190
+ }
191
+ /**
192
+ * Sets one variable, passing the value on **stdin**.
193
+ *
194
+ * Stdin rather than `--body` even though a variable is public by definition:
195
+ * argv is shared machinery, and a rule with an exception ("stdin for secrets,
196
+ * argv for variables") is a rule somebody applies to the wrong call. The
197
+ * `--env-file` form is refused for the reason `setSecret` refuses it: it would
198
+ * hand the file to `gh`'s own dotenv parser, which agrees with this tool's
199
+ * parser right up until a multi-line value and then disagrees silently.
200
+ */
201
+ export async function setVariable(environment, name, value) {
202
+ warnOnEnvTokenOverride();
203
+ await new Promise((resolve, reject) => {
204
+ const child = spawn("gh", ["variable", "set", name, "--env", environment], {
205
+ stdio: ["pipe", "ignore", "pipe"],
206
+ shell: false,
207
+ });
208
+ let stderr = "";
209
+ child.stderr.on("data", (c) => (stderr += c.toString()));
210
+ child.on("error", (err) => reject(new GhError(describe(err))));
211
+ child.on("close", (code) => code === 0
212
+ ? resolve()
213
+ : reject(new GhError(describe({ stderr, message: `gh exited ${code}` }))));
214
+ // No trailing newline, for a reason that bites harder here than it does for
215
+ // a secret: the stored value IS readable, so a stray "\n" turns every
216
+ // `audit` run into a value-drift error against a Bitwarden copy that is
217
+ // actually identical, and no amount of re-pushing fixes it.
218
+ child.stdin.end(value);
219
+ });
220
+ }
221
+ export async function deleteVariable(environment, name) {
222
+ warnOnEnvTokenOverride();
223
+ try {
224
+ await run("gh", ["variable", "delete", name, "--env", environment], {
225
+ shell: false,
226
+ });
227
+ }
228
+ catch (err) {
229
+ throw new GhError(describe(err));
230
+ }
231
+ }
232
+ /**
233
+ * The repository's OWN variables, the ones with no environment.
234
+ *
235
+ * ⚠️ The reason this exists at all: an environment variable of the same name
236
+ * **shadows** a repository one, and nothing in GitHub's UI says so. Every job
237
+ * that runs in the environment reads the environment copy, so a repository
238
+ * variable a person set by hand before `env push` routed that key keeps its
239
+ * stale value indefinitely and invisibly, right up until somebody deletes the
240
+ * environment copy, at which point the stale value silently becomes live.
241
+ *
242
+ * No `--env`, and that missing flag IS the call. Everything else in this file
243
+ * addresses one environment; this addresses the repository itself, which is
244
+ * the scope nothing here writes to and therefore the scope nothing here would
245
+ * otherwise notice.
246
+ */
247
+ export async function listRepositoryVariables() {
248
+ warnOnEnvTokenOverride();
249
+ try {
250
+ const { stdout } = await run("gh", ["variable", "list", "--json", "name,updatedAt"], { maxBuffer: MAX_BUFFER, shell: false });
251
+ return stdout.trim() === ""
252
+ ? []
253
+ : JSON.parse(stdout);
254
+ }
255
+ catch (err) {
256
+ throw new GhError(describe(err));
257
+ }
258
+ }
@@ -0,0 +1,83 @@
1
+ export declare const GITHUB_ENVIRONMENTS: readonly ["preflight", "staging", "production", "production-apply"];
2
+ export type GithubEnvironment = (typeof GITHUB_ENVIRONMENTS)[number];
3
+ export declare function isGithubEnvironment(v: string): v is GithubEnvironment;
4
+ export interface GithubEnvironmentSpec {
5
+ /** BWS project to compare against, or null when nothing backs it. */
6
+ bwsProject: string | null;
7
+ /** Deployment branch policy, for the summary line. */
8
+ branch: string;
9
+ /**
10
+ * Keys allowed here, or null for "whatever the file defines".
11
+ *
12
+ * ⚠️ NULL ON EVERY ROW TODAY, and that is the deliberate state rather than a
13
+ * field nobody got round to filling in. `production-apply` used to carry the
14
+ * apply-tier pair here, on the reasoning that "holding a third credential
15
+ * makes the reviewer gate stop meaning anything", which conflated two
16
+ * constraints and kept only the one that was not the gate. An allowlist here
17
+ * restricts what the REVIEWED environment may hold; the gate is
18
+ * `production.excludeKeys`, which restricts what the UNREVIEWED one may hold.
19
+ * Only the second is a security property, and it is enforced below.
20
+ *
21
+ * Kept as a field because the mechanism is worth having and costs one branch
22
+ * in `accepts()`: a future environment that genuinely holds a fixed short
23
+ * list, a third-party integration's two keys and nothing else, states it here
24
+ * rather than as an `excludeKeys` of everything else.
25
+ */
26
+ onlyKeys: readonly string[] | null;
27
+ /**
28
+ * Keys that must never reach this environment.
29
+ *
30
+ * The whole reviewer gate now lives in this field: `production` (and
31
+ * `staging`) exclude the apply-tier set, and nothing else stops a
32
+ * write-capable credential from landing in an environment that deploys with
33
+ * nobody in front of it.
34
+ */
35
+ excludeKeys: readonly string[];
36
+ /** Extra confirmation before writing. */
37
+ guarded: boolean;
38
+ }
39
+ export declare const GITHUB_ENVIRONMENT_SPECS: Record<GithubEnvironment, GithubEnvironmentSpec>;
40
+ /** The GitHub environments fed by one Bitwarden project, in precedence order. */
41
+ export declare function githubTargets(bwsProject: string): GithubEnvironment[];
42
+ /** Whether one environment takes a given key. */
43
+ export declare function accepts(environment: GithubEnvironment, key: string): boolean;
44
+ /**
45
+ * The PRIMARY environment a key belongs in, or `null` for "nowhere here".
46
+ *
47
+ * `null` is not an error. Pushing `staging` with an apply-only credential in
48
+ * the file is the ordinary case: that key belongs to production and has no home
49
+ * in the staging environment.
50
+ *
51
+ * ⚠️ "Primary", not "only". Since `production-apply` became a superset, an
52
+ * ordinary production key legitimately lives in TWO environments and this
53
+ * returns the first, the unreviewed one, where the deploy reads it. A caller
54
+ * asking "is this copy misplaced?" wants `acceptedBy()` instead; answering that
55
+ * question with this function reports every correctly-pushed copy in
56
+ * `production-apply` as a stray that somebody should delete.
57
+ */
58
+ export declare function routeTo(bwsProject: string, key: string): GithubEnvironment | null;
59
+ /**
60
+ * EVERY environment of a project that takes a key, in precedence order.
61
+ *
62
+ * The set-shaped question, for callers that compare against what a push
63
+ * actually wrote rather than against one destination. `routeTo()` is this with
64
+ * `[0]` taken, and the difference is only ever visible for `production`, the
65
+ * one project that fans out to two environments.
66
+ */
67
+ export declare function acceptedBy(bwsProject: string, key: string): GithubEnvironment[];
68
+ /**
69
+ * `accepts()` for an environment name that has not been narrowed yet, in the
70
+ * argument order `AuditInput.accepted` wants.
71
+ *
72
+ * Exists so that the predicate `env audit` hands the audit is a NAME rather
73
+ * than a lambda written at the call site: `runEnvAudit` cannot be unit-tested
74
+ * without mocking Bitwarden, GitHub and Cloudflare at once, so anything spelled
75
+ * out there is untested by construction.
76
+ *
77
+ * ⚠️ An unrecognised environment is REFUSED, not accepted. The string comes
78
+ * from whatever `gh` listed, and the audit uses this to decide whether a found
79
+ * copy is a stray. Failing open here would silence the finding for a copy
80
+ * sitting in an environment this repository has never heard of, which is
81
+ * exactly the case worth hearing about.
82
+ */
83
+ export declare function acceptsKey(key: string, environment: string): boolean;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The GitHub environments, and which BWS project (if any) backs each.
3
+ *
4
+ * GitHub environments and BWS projects are NOT the same set, and conflating
5
+ * them is the mistake this file exists to prevent. There are FOUR GitHub
6
+ * environments and THREE BWS projects:
7
+ *
8
+ * | GitHub environment | BWS project | Receives |
9
+ * |--------------------|--------------|------------------------------|
10
+ * | `preflight` | `preflight` | everything in the project |
11
+ * | `staging` | `staging` | everything in the project |
12
+ * | `production` | `production` | everything EXCEPT apply-only |
13
+ * | `production-apply` | `production` | everything, apply-only too |
14
+ *
15
+ * The last two split one project between two GitHub environments, and that
16
+ * split IS the reviewer gate. `production` deploys on a push with nothing in
17
+ * front of it; `production-apply` has required reviewers. A write-capable
18
+ * credential reaching the first makes the second decorative, so the routing is
19
+ * enforced here rather than left to whoever last edited a file.
20
+ *
21
+ * ⚠️ THE GATE IS ONE-DIRECTIONAL, and reading it as symmetric is the mistake
22
+ * this file made until 2026-08-17. What the gate asserts, through
23
+ * `production.excludeKeys`, is that apply-tier credentials never reach the
24
+ * UNREVIEWED environment, and nothing more. What the reviewed environment also
25
+ * holds buys no privilege: it is the same Bitwarden project, and
26
+ * `production-apply` is the strictly MORE trusted of the two. Withholding
27
+ * ordinary keys from it broke every job that runs there: the config push wanted
28
+ * deploy-tier OAuth secrets, and the orphan prune wanted a deploy-tier API
29
+ * token. So `production-apply` now receives a SUPERSET of `production`.
30
+ */
31
+ import { assertRegistryLoaded } from "../env/discovery.js";
32
+ import { getEnvSync } from "../repo/peers.js";
33
+ export const GITHUB_ENVIRONMENTS = [
34
+ "preflight",
35
+ "staging",
36
+ "production",
37
+ "production-apply",
38
+ ];
39
+ export function isGithubEnvironment(v) {
40
+ return GITHUB_ENVIRONMENTS.includes(v);
41
+ }
42
+ /**
43
+ * The apply-only set, derived from the env manifests (`tier: "apply"` on the
44
+ * declarations in `packages/devtools/env.ts`) rather than listed here.
45
+ *
46
+ * Read at ACCESS time, not module load: this module is imported by the CLI
47
+ * before any manifest is, so a snapshot taken now would be empty, and an empty
48
+ * apply set routes the write-capable credentials to the unreviewed
49
+ * `production` environment, which is the exact failure the split exists to
50
+ * prevent. The guard turns "forgot to loadRegistry()" into a crash instead.
51
+ */
52
+ function applyOnly() {
53
+ assertRegistryLoaded();
54
+ return getEnvSync().applyOnlyKeys();
55
+ }
56
+ /**
57
+ * The plan-tier set, same shape and same load-time caveat as `applyOnly()`.
58
+ * Read by `main-plan` (preflight) and `production-plan` (production); a copy
59
+ * anywhere else is a credential nothing reads, so `staging` excludes it.
60
+ */
61
+ function planOnly() {
62
+ assertRegistryLoaded();
63
+ return getEnvSync().planOnlyKeys();
64
+ }
65
+ export const GITHUB_ENVIRONMENT_SPECS = {
66
+ preflight: {
67
+ bwsProject: "preflight",
68
+ branch: "main",
69
+ onlyKeys: null,
70
+ excludeKeys: [],
71
+ guarded: false,
72
+ },
73
+ staging: {
74
+ bwsProject: "staging",
75
+ branch: "main",
76
+ onlyKeys: null,
77
+ // Both narrow tiers: no staging job plans, and none may apply.
78
+ get excludeKeys() {
79
+ return [...applyOnly(), ...planOnly()];
80
+ },
81
+ guarded: false,
82
+ },
83
+ // ⚠️ THE REVIEWER GATE, in one property. `excludeKeys` here is the only
84
+ // thing keeping the apply-tier credentials out of an environment that
85
+ // deploys on a push to `production` with nobody in front of it. Widening it
86
+ // to `[]` hands a write-capable token to an unreviewed deploy, and so does
87
+ // letting `applyOnly()` answer empty, which is what `assertRegistryLoaded()`
88
+ // exists to prevent. `environments.test.ts` asserts both apply keys by name
89
+ // against exactly this.
90
+ production: {
91
+ bwsProject: "production",
92
+ branch: "production",
93
+ onlyKeys: null,
94
+ get excludeKeys() {
95
+ return applyOnly();
96
+ },
97
+ guarded: true,
98
+ },
99
+ // A SUPERSET of `production`, deliberately: everything that environment
100
+ // receives, plus the apply-tier credential it may not have.
101
+ //
102
+ // Not a relaxation of the gate. This is the same Bitwarden project behind
103
+ // required reviewers, the more trusted half of the split, so withholding a
104
+ // key from it protects nothing and only starves the jobs that run here
105
+ // (`production-config`, `prune-orphans`, both of which needed a deploy-tier
106
+ // secret and got none). The gate is what `production` may NOT have, one row
107
+ // above.
108
+ "production-apply": {
109
+ bwsProject: "production",
110
+ branch: "production",
111
+ onlyKeys: null,
112
+ excludeKeys: [],
113
+ guarded: true,
114
+ },
115
+ };
116
+ // ── Routing ──────────────────────────────────────────────────────────────────
117
+ //
118
+ // One Bitwarden project can feed more than one GitHub environment; production
119
+ // feeds two. So a push has to know which key goes where. Derived from the table
120
+ // above rather than written out a second time: the split IS the reviewer gate,
121
+ // and a hardcoded copy of it is a copy that can disagree.
122
+ /** The GitHub environments fed by one Bitwarden project, in precedence order. */
123
+ export function githubTargets(bwsProject) {
124
+ return GITHUB_ENVIRONMENTS.filter((e) => GITHUB_ENVIRONMENT_SPECS[e].bwsProject === bwsProject);
125
+ }
126
+ /** Whether one environment takes a given key. */
127
+ export function accepts(environment, key) {
128
+ const spec = GITHUB_ENVIRONMENT_SPECS[environment];
129
+ return spec.onlyKeys
130
+ ? spec.onlyKeys.includes(key)
131
+ : !spec.excludeKeys.includes(key);
132
+ }
133
+ /**
134
+ * The PRIMARY environment a key belongs in, or `null` for "nowhere here".
135
+ *
136
+ * `null` is not an error. Pushing `staging` with an apply-only credential in
137
+ * the file is the ordinary case: that key belongs to production and has no home
138
+ * in the staging environment.
139
+ *
140
+ * ⚠️ "Primary", not "only". Since `production-apply` became a superset, an
141
+ * ordinary production key legitimately lives in TWO environments and this
142
+ * returns the first, the unreviewed one, where the deploy reads it. A caller
143
+ * asking "is this copy misplaced?" wants `acceptedBy()` instead; answering that
144
+ * question with this function reports every correctly-pushed copy in
145
+ * `production-apply` as a stray that somebody should delete.
146
+ */
147
+ export function routeTo(bwsProject, key) {
148
+ return acceptedBy(bwsProject, key)[0] ?? null;
149
+ }
150
+ /**
151
+ * EVERY environment of a project that takes a key, in precedence order.
152
+ *
153
+ * The set-shaped question, for callers that compare against what a push
154
+ * actually wrote rather than against one destination. `routeTo()` is this with
155
+ * `[0]` taken, and the difference is only ever visible for `production`, the
156
+ * one project that fans out to two environments.
157
+ */
158
+ export function acceptedBy(bwsProject, key) {
159
+ return githubTargets(bwsProject).filter((e) => accepts(e, key));
160
+ }
161
+ /**
162
+ * `accepts()` for an environment name that has not been narrowed yet, in the
163
+ * argument order `AuditInput.accepted` wants.
164
+ *
165
+ * Exists so that the predicate `env audit` hands the audit is a NAME rather
166
+ * than a lambda written at the call site: `runEnvAudit` cannot be unit-tested
167
+ * without mocking Bitwarden, GitHub and Cloudflare at once, so anything spelled
168
+ * out there is untested by construction.
169
+ *
170
+ * ⚠️ An unrecognised environment is REFUSED, not accepted. The string comes
171
+ * from whatever `gh` listed, and the audit uses this to decide whether a found
172
+ * copy is a stray. Failing open here would silence the finding for a copy
173
+ * sitting in an environment this repository has never heard of, which is
174
+ * exactly the case worth hearing about.
175
+ */
176
+ export function acceptsKey(key, environment) {
177
+ return isGithubEnvironment(environment) && accepts(environment, key);
178
+ }
@@ -0,0 +1,14 @@
1
+ /** `GET /orgs/{org}/teams/{slug}` -> the team's numeric id. */
2
+ export declare function resolveTeamId(org: string, slug: string): Promise<number>;
3
+ /**
4
+ * `GET /orgs/{org}/installations` -> the id of the App installation whose
5
+ * `app_slug` matches, e.g. `devdogs-platform` (production) or
6
+ * `devdogs-platform-staging`.
7
+ *
8
+ * `orgs/{org}/installations` rather than the App's own `/app` endpoint: the
9
+ * latter needs App-JWT auth this CLI has no path to, while the installations
10
+ * list is a plain org-admin GET — the exact command DevDogsUGA's own
11
+ * `docs/platform/guides/identity/github-app.md` already tells an operator to
12
+ * run to verify the App's permission grant.
13
+ */
14
+ export declare function resolveAppId(org: string, appSlug: string): Promise<number>;