@devdogsuga/devtools 0.1.25 → 0.1.27

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 (271) hide show
  1. package/README.md +72 -11
  2. package/bin/devtools.mjs +4 -4
  3. package/dist/cli-CZKkNHK5.js +6619 -0
  4. package/dist/failure-log-BBPGejQv.js +256 -0
  5. package/dist/launch.js +267 -281
  6. package/dist/local-env-D8HC2JK_.js +351 -0
  7. package/dist/local-env-DrBCXqD8.js +2 -0
  8. package/dist/menu-CQBsQRgH.js +1684 -0
  9. package/dist/peer-redirect-hooks.js +15 -0
  10. package/dist/peers-RBplwovA.js +229 -0
  11. package/dist/root-C2OmZDwD.js +94 -0
  12. package/dist/stack-Bx-55oi_.js +2 -0
  13. package/dist/stack-BynCgRVS.js +181 -0
  14. package/dist/supabase-project-0knM0mqd.js +426 -0
  15. package/dist/telemetry-C4foD8MD.js +249 -0
  16. package/dist/telemetry.js +2 -148
  17. package/package.json +10 -29
  18. package/bin/devtools-ci-bare.mjs +0 -48
  19. package/bin/devtools-ci.mjs +0 -31
  20. package/dist/args.d.ts +0 -26
  21. package/dist/args.js +0 -78
  22. package/dist/bws/bw.d.ts +0 -18
  23. package/dist/bws/bw.js +0 -70
  24. package/dist/bws/client.d.ts +0 -43
  25. package/dist/bws/client.js +0 -363
  26. package/dist/bws/environments.d.ts +0 -96
  27. package/dist/bws/environments.js +0 -111
  28. package/dist/bws/pace.d.ts +0 -25
  29. package/dist/bws/pace.js +0 -43
  30. package/dist/bws/retry.d.ts +0 -12
  31. package/dist/bws/retry.js +0 -39
  32. package/dist/bws/store.d.ts +0 -1
  33. package/dist/bws/store.js +0 -32
  34. package/dist/bws/token.d.ts +0 -31
  35. package/dist/bws/token.js +0 -88
  36. package/dist/bws/vault.d.ts +0 -29
  37. package/dist/bws/vault.js +0 -200
  38. package/dist/catalog.d.ts +0 -57
  39. package/dist/catalog.js +0 -57
  40. package/dist/cf/build.d.ts +0 -13
  41. package/dist/cf/build.js +0 -65
  42. package/dist/cf/commands.d.ts +0 -1
  43. package/dist/cf/commands.js +0 -172
  44. package/dist/cf/local-env.d.ts +0 -58
  45. package/dist/cf/local-env.js +0 -177
  46. package/dist/ci.d.ts +0 -1
  47. package/dist/ci.js +0 -328
  48. package/dist/cli.d.ts +0 -11
  49. package/dist/cli.js +0 -1094
  50. package/dist/commands.d.ts +0 -261
  51. package/dist/commands.js +0 -1318
  52. package/dist/completions.d.ts +0 -4
  53. package/dist/completions.js +0 -110
  54. package/dist/cron/commands.d.ts +0 -47
  55. package/dist/cron/commands.js +0 -372
  56. package/dist/cron/discovery.d.ts +0 -55
  57. package/dist/cron/discovery.js +0 -85
  58. package/dist/cron/schema.d.ts +0 -45
  59. package/dist/cron/schema.js +0 -35
  60. package/dist/db/config-push.d.ts +0 -8
  61. package/dist/db/config-push.js +0 -11
  62. package/dist/db/connection.d.ts +0 -79
  63. package/dist/db/connection.js +0 -114
  64. package/dist/db/exec.d.ts +0 -1
  65. package/dist/db/exec.js +0 -11
  66. package/dist/db/generate-types.d.ts +0 -1
  67. package/dist/db/generate-types.js +0 -4
  68. package/dist/db/generated-env.d.ts +0 -69
  69. package/dist/db/generated-env.js +0 -113
  70. package/dist/db/introspect.d.ts +0 -1
  71. package/dist/db/introspect.js +0 -122
  72. package/dist/db/local-env.d.ts +0 -65
  73. package/dist/db/local-env.js +0 -23
  74. package/dist/db/new-migration.d.ts +0 -8
  75. package/dist/db/new-migration.js +0 -69
  76. package/dist/db/pooler.d.ts +0 -23
  77. package/dist/db/pooler.js +0 -55
  78. package/dist/db/run.d.ts +0 -68
  79. package/dist/db/run.js +0 -156
  80. package/dist/db/seed-buckets.d.ts +0 -2
  81. package/dist/db/seed-buckets.js +0 -4
  82. package/dist/db/seed-production.d.ts +0 -8
  83. package/dist/db/seed-production.js +0 -79
  84. package/dist/db/seed-roles.d.ts +0 -7
  85. package/dist/db/seed-roles.js +0 -33
  86. package/dist/db/session-refresh.d.ts +0 -43
  87. package/dist/db/session-refresh.js +0 -40
  88. package/dist/deploy/migrations.d.ts +0 -20
  89. package/dist/deploy/migrations.js +0 -68
  90. package/dist/deploy/orphans.d.ts +0 -20
  91. package/dist/deploy/orphans.js +0 -177
  92. package/dist/deploy/preflight.d.ts +0 -53
  93. package/dist/deploy/preflight.js +0 -189
  94. package/dist/deploy/report.d.ts +0 -26
  95. package/dist/deploy/report.js +0 -70
  96. package/dist/deploy/require-planner.d.ts +0 -2
  97. package/dist/deploy/require-planner.js +0 -60
  98. package/dist/deploy/require-token.d.ts +0 -15
  99. package/dist/deploy/require-token.js +0 -78
  100. package/dist/deploy/secrets-file.d.ts +0 -17
  101. package/dist/deploy/secrets-file.js +0 -166
  102. package/dist/deploy/write-env.d.ts +0 -40
  103. package/dist/deploy/write-env.js +0 -395
  104. package/dist/docs/index-pages.d.ts +0 -38
  105. package/dist/docs/index-pages.js +0 -186
  106. package/dist/emails/commands.d.ts +0 -15
  107. package/dist/emails/commands.js +0 -130
  108. package/dist/emails/email-types.d.ts +0 -13
  109. package/dist/emails/email-types.js +0 -1
  110. package/dist/emails/fixtures.d.ts +0 -20
  111. package/dist/emails/fixtures.js +0 -20
  112. package/dist/env/audit.d.ts +0 -206
  113. package/dist/env/audit.js +0 -436
  114. package/dist/env/cloudflare.d.ts +0 -21
  115. package/dist/env/cloudflare.js +0 -61
  116. package/dist/env/commands.d.ts +0 -99
  117. package/dist/env/commands.js +0 -613
  118. package/dist/env/discovery.d.ts +0 -15
  119. package/dist/env/discovery.js +0 -239
  120. package/dist/env/document.d.ts +0 -119
  121. package/dist/env/document.js +0 -323
  122. package/dist/env/example.d.ts +0 -68
  123. package/dist/env/example.js +0 -605
  124. package/dist/env/selection.d.ts +0 -105
  125. package/dist/env/selection.js +0 -228
  126. package/dist/env-entry.d.ts +0 -49
  127. package/dist/env-entry.js +0 -204
  128. package/dist/environment-doctor.d.ts +0 -45
  129. package/dist/environment-doctor.js +0 -429
  130. package/dist/environment.d.ts +0 -92
  131. package/dist/environment.js +0 -185
  132. package/dist/fingerprint.d.ts +0 -10
  133. package/dist/fingerprint.js +0 -18
  134. package/dist/gen/campus-map.d.ts +0 -1
  135. package/dist/gen/campus-map.js +0 -473
  136. package/dist/gen/commands.d.ts +0 -1
  137. package/dist/gen/commands.js +0 -25
  138. package/dist/gen/hypno.d.ts +0 -1
  139. package/dist/gen/hypno.js +0 -134
  140. package/dist/gh/client.d.ts +0 -80
  141. package/dist/gh/client.js +0 -259
  142. package/dist/gh/environments.d.ts +0 -83
  143. package/dist/gh/environments.js +0 -178
  144. package/dist/gh/rulesets/actors.d.ts +0 -14
  145. package/dist/gh/rulesets/actors.js +0 -69
  146. package/dist/gh/rulesets/api.d.ts +0 -15
  147. package/dist/gh/rulesets/api.js +0 -104
  148. package/dist/gh/rulesets/commands.d.ts +0 -3
  149. package/dist/gh/rulesets/commands.js +0 -203
  150. package/dist/gh/rulesets/desired.d.ts +0 -112
  151. package/dist/gh/rulesets/desired.js +0 -271
  152. package/dist/gh/rulesets/diff.d.ts +0 -65
  153. package/dist/gh/rulesets/diff.js +0 -170
  154. package/dist/gh/rulesets/types.d.ts +0 -76
  155. package/dist/gh/rulesets/types.js +0 -13
  156. package/dist/gh/settings/api.d.ts +0 -40
  157. package/dist/gh/settings/api.js +0 -180
  158. package/dist/gh/settings/commands.d.ts +0 -1
  159. package/dist/gh/settings/commands.js +0 -254
  160. package/dist/gh/settings/desired.d.ts +0 -53
  161. package/dist/gh/settings/desired.js +0 -43
  162. package/dist/gh/settings/diff.d.ts +0 -57
  163. package/dist/gh/settings/diff.js +0 -161
  164. package/dist/gh/settings/types.d.ts +0 -92
  165. package/dist/gh/settings/types.js +0 -11
  166. package/dist/gh/settings/workflows.d.ts +0 -41
  167. package/dist/gh/settings/workflows.js +0 -128
  168. package/dist/grantRoot.d.ts +0 -42
  169. package/dist/grantRoot.js +0 -89
  170. package/dist/help.d.ts +0 -18
  171. package/dist/help.js +0 -169
  172. package/dist/images/commands.d.ts +0 -32
  173. package/dist/images/commands.js +0 -231
  174. package/dist/images/events.d.ts +0 -47
  175. package/dist/images/events.js +0 -116
  176. package/dist/images/graphics.d.ts +0 -70
  177. package/dist/images/graphics.js +0 -234
  178. package/dist/images/open-graph-types.d.ts +0 -75
  179. package/dist/images/open-graph-types.js +0 -1
  180. package/dist/images/prompts.d.ts +0 -34
  181. package/dist/images/prompts.js +0 -100
  182. package/dist/images/render.d.ts +0 -21
  183. package/dist/images/render.js +0 -61
  184. package/dist/images/select.d.ts +0 -64
  185. package/dist/images/select.js +0 -96
  186. package/dist/instance.d.ts +0 -69
  187. package/dist/instance.js +0 -102
  188. package/dist/invocation.d.ts +0 -49
  189. package/dist/invocation.js +0 -78
  190. package/dist/launch-ci.d.ts +0 -1
  191. package/dist/launch-ci.js +0 -90
  192. package/dist/launch.d.ts +0 -18
  193. package/dist/menu.d.ts +0 -43
  194. package/dist/menu.js +0 -300
  195. package/dist/moderation.d.ts +0 -70
  196. package/dist/moderation.js +0 -128
  197. package/dist/oauth/browser.d.ts +0 -1
  198. package/dist/oauth/browser.js +0 -13
  199. package/dist/oauth/config.d.ts +0 -24
  200. package/dist/oauth/config.js +0 -24
  201. package/dist/oauth/connect-url.d.ts +0 -17
  202. package/dist/oauth/connect-url.js +0 -23
  203. package/dist/oauth/db.d.ts +0 -37
  204. package/dist/oauth/db.js +0 -108
  205. package/dist/oauth/device.d.ts +0 -93
  206. package/dist/oauth/device.js +0 -198
  207. package/dist/oauth/discovery.d.ts +0 -28
  208. package/dist/oauth/discovery.js +0 -54
  209. package/dist/oauth/env-file.d.ts +0 -6
  210. package/dist/oauth/env-file.js +0 -35
  211. package/dist/oauth/exchange.d.ts +0 -43
  212. package/dist/oauth/exchange.js +0 -93
  213. package/dist/oauth/label.d.ts +0 -1
  214. package/dist/oauth/label.js +0 -25
  215. package/dist/oauth/loopback.d.ts +0 -52
  216. package/dist/oauth/loopback.js +0 -168
  217. package/dist/oauth/pkce.d.ts +0 -6
  218. package/dist/oauth/pkce.js +0 -34
  219. package/dist/oauth/target.d.ts +0 -54
  220. package/dist/oauth/target.js +0 -102
  221. package/dist/oauth/transport.d.ts +0 -41
  222. package/dist/oauth/transport.js +0 -32
  223. package/dist/oauth/wizard.d.ts +0 -40
  224. package/dist/oauth/wizard.js +0 -578
  225. package/dist/persona.d.ts +0 -18
  226. package/dist/persona.js +0 -221
  227. package/dist/pick.d.ts +0 -6
  228. package/dist/pick.js +0 -64
  229. package/dist/pipes.d.ts +0 -1
  230. package/dist/pipes.js +0 -25
  231. package/dist/planner/checks.d.ts +0 -26
  232. package/dist/planner/checks.js +0 -104
  233. package/dist/planner/commands.d.ts +0 -10
  234. package/dist/planner/commands.js +0 -367
  235. package/dist/planner/db.d.ts +0 -15
  236. package/dist/planner/db.js +0 -33
  237. package/dist/planner/role.d.ts +0 -33
  238. package/dist/planner/role.js +0 -68
  239. package/dist/repo/peer-redirect-hooks.d.ts +0 -16
  240. package/dist/repo/peer-redirect-hooks.js +0 -10
  241. package/dist/repo/peer-redirect.d.ts +0 -4
  242. package/dist/repo/peer-redirect.js +0 -26
  243. package/dist/repo/peers.d.ts +0 -39
  244. package/dist/repo/peers.js +0 -139
  245. package/dist/repo/resolve.d.ts +0 -41
  246. package/dist/repo/resolve.js +0 -152
  247. package/dist/repo/root.d.ts +0 -23
  248. package/dist/repo/root.js +0 -91
  249. package/dist/repo/source.d.ts +0 -14
  250. package/dist/repo/source.js +0 -54
  251. package/dist/repo/supabase-project.d.ts +0 -60
  252. package/dist/repo/supabase-project.js +0 -124
  253. package/dist/repo/tsx-loader.d.ts +0 -6
  254. package/dist/repo/tsx-loader.js +0 -41
  255. package/dist/run/pick.d.ts +0 -90
  256. package/dist/run/pick.js +0 -664
  257. package/dist/setup.d.ts +0 -1
  258. package/dist/setup.js +0 -280
  259. package/dist/stack.d.ts +0 -48
  260. package/dist/stack.js +0 -403
  261. package/dist/telemetry.d.ts +0 -59
  262. package/dist/tier.d.ts +0 -40
  263. package/dist/tier.js +0 -96
  264. package/dist/ui.d.ts +0 -40
  265. package/dist/ui.js +0 -71
  266. package/dist/version.d.ts +0 -7
  267. package/dist/version.js +0 -17
  268. package/dist/workers.d.ts +0 -5
  269. package/dist/workers.js +0 -33
  270. package/dist/workflows/commands.d.ts +0 -54
  271. package/dist/workflows/commands.js +0 -763
@@ -1,613 +0,0 @@
1
- /**
2
- * `pnpm devtools env <pull|push|audit> --target <target>`
3
- *
4
- * One local env file per target, three remote stores, and a source of truth:
5
- *
6
- * pull Bitwarden into your env file
7
- * push your env file to Bitwarden, then to GitHub secrets AND variables
8
- * audit compare the file, Bitwarden, GitHub and Cloudflare
9
- *
10
- * ⚠️ THE FILE IS DERIVED FROM THE TARGET, and that one line was this
11
- * module's bug. `pathFor()` used to default to the root `.env` regardless of
12
- * target and honour only an explicit `--file`, while `init` mapped target to
13
- * file the way the table always said. So `push --env staging` uploaded the
14
- * DEVELOPMENT file to the staging project and reported success. Neither
15
- * subcommand was internally wrong; they read two different enums behind one
16
- * flag name. `--file` remains, as an override somebody types on purpose.
17
- *
18
- * Four rules shape all of it:
19
- *
20
- * * **Nothing is overwritten or removed without being asked.** Every
21
- * destructive change is listed in fingerprints and confirmed. `push` sends
22
- * to Bitwarden AND GitHub because a value in one and not the other is the
23
- * failure this design has.
24
- * * **Bitwarden holds an entire target, not its secret half.** The public
25
- * per-environment values (`PROJECT_REF`, `BASE_URL`, `PUBLISHABLE_KEY`,
26
- * and so on) are stored there too and pushed on to GitHub as *variables*
27
- * rather than secrets. Storing them as secrets would mask them by
28
- * substring in every log line they appear in and make their values
29
- * unreadable to `audit`; leaving them out of Bitwarden would mean `pull`
30
- * reconstructs a file that cannot boot an app.
31
- * * **Nothing is deleted from the file.** A key that should go away is
32
- * commented out, so the previous value stays recoverable from the file.
33
- * * **Values are never printed.** Fingerprints tell a rotation from a paste
34
- * error and cannot be used to reconstruct anything.
35
- */
36
- import { chmod, readFile, writeFile } from "node:fs/promises";
37
- import { resolve } from "node:path";
38
- import { confirm, log, note } from "@clack/prompts";
39
- import { getEnvSync } from "../repo/peers.js";
40
- import { createSecret, listSecrets as listBwsSecrets, byKey, projectIdFor, updateSecret, } from "../bws/client.js";
41
- import { environmentSpecs, assertVaultTarget, } from "../bws/environments.js";
42
- import { fingerprint } from "../fingerprint.js";
43
- import { listSecrets as listGhSecrets, listVariables as listGhVariables, listRepositoryVariables, setSecret, setVariable, } from "../gh/client.js";
44
- import { GITHUB_ENVIRONMENT_SPECS, accepts, acceptsKey, githubTargets, routeTo, } from "../gh/environments.js";
45
- import { audit, hasErrors, renderFindings, } from "./audit.js";
46
- import { listWorkerSecrets } from "./cloudflare.js";
47
- import { EnvDocument } from "./document.js";
48
- import { ignoredFor, minted, neverStore, pushableVariables, selectForPush, } from "./selection.js";
49
- import { findRepoRoot } from "../repo/root.js";
50
- import { bail, errorMessage, explain, unwrap } from "../ui.js";
51
- async function readDocument(path) {
52
- try {
53
- return EnvDocument.parse(await readFile(path, "utf8"));
54
- }
55
- catch {
56
- return EnvDocument.empty();
57
- }
58
- }
59
- /**
60
- * The file a command works on: the target's, unless `--file` overrides it.
61
- *
62
- * The default is the whole fix. `--target staging` now reads and writes
63
- * `.env.staging` for pull, push AND audit, where it used to reach the root
64
- * `.env`. That meant pushing "staging" uploaded the development values, and
65
- * pulling "staging" overwrote the development file. Every caller passes the
66
- * target explicitly so no future one can inherit `.env` by omission.
67
- */
68
- function pathFor(target, file) {
69
- return resolve(findRepoRoot(), file ?? getEnvSync().fileFor(target));
70
- }
71
- /** Today, as an ISO date. Separated so the document layer stays testable. */
72
- function stampFor(target, action) {
73
- return {
74
- environment: target,
75
- action,
76
- date: new Date().toISOString().slice(0, 10),
77
- };
78
- }
79
- /**
80
- * Writes the document, tidying first.
81
- *
82
- * `group()` runs on every write rather than as its own command: files drift a
83
- * line at a time, and a tidy pass nobody remembers to run is a tidy pass that
84
- * never happens.
85
- *
86
- * Env files contain credentials and must not be world-readable. The mode
87
- * parameter only applies on creation, so existing world-readable files are
88
- * tightened with chmod after each write.
89
- */
90
- export async function save(path, doc) {
91
- const moved = doc.group();
92
- await writeFile(path, doc.toString(), { mode: 0o600 });
93
- await chmod(path, 0o600);
94
- return moved;
95
- }
96
- /**
97
- * Names every key the file holds that no manifest declares.
98
- *
99
- * Loud on purpose, and per key: an undeclared key used to be pushed as a secret
100
- * BY OMISSION, so a typo'd name uploaded garbage under the wrong key and a
101
- * stray local variable uploaded something private. Now it is skipped, and the
102
- * person who typed the line has to hear that. Silence would read as "stored"
103
- * right up until a deploy goes looking for it.
104
- */
105
- function warnUnknown(unknown) {
106
- for (const key of unknown) {
107
- log.warn(`${key} is declared in no env manifest, so it was NOT pushed. If it is ` +
108
- `real, declare it with define() in the owning package's env.ts so it ` +
109
- `can be classified and routed; if it is a typo or a stray local ` +
110
- `variable, fix or remove the line.`);
111
- }
112
- }
113
- /**
114
- * Says out loud that a refused credential was left behind.
115
- *
116
- * A warning rather than a hard stop, because a never-store key can be
117
- * legitimately in `.env` for an operator's own use, and blocking the whole
118
- * push then would be wrong. But never silence: somebody who put a token in
119
- * the file expecting it to sync has to learn that it did not.
120
- */
121
- function warnRefused(refused) {
122
- for (const key of refused) {
123
- if (key === "BWS_ACCESS_TOKEN") {
124
- log.warn(`${key} was NOT uploaded, and must not be — it unlocks all three ` +
125
- `Bitwarden projects, so storing it in one is a key locked inside the ` +
126
- `box it opens. ⚠️ Remove it from your .env; export it per shell from ` +
127
- `the Password Manager vault instead.`);
128
- }
129
- else {
130
- log.warn(`${key} was NOT uploaded, and must not be. See its define() doc in ` +
131
- `the owning package's env.ts for why.`);
132
- }
133
- }
134
- }
135
- /**
136
- * Says that some lines were left to the registry rather than stored.
137
- *
138
- * `info`, not `warn`: this is the correct outcome, and the eight lines it
139
- * covers are ones `env init --target` WROTE. Silence would be the problem.
140
- * Somebody who filled in a file and pushed it has to be able to tell "your
141
- * derivations were left alone, on purpose" from "your derivations were
142
- * dropped", and before this the tool said neither.
143
- */
144
- function noteDerived(derived) {
145
- if (derived.length === 0)
146
- return;
147
- log.info(`${derived.length} value(s) were left to the registry: ${derived.join(", ")}. ` +
148
- "Each is still exactly the derivation its declaration gives — a shape " +
149
- "rather than a value — so storing it would freeze today's formula into " +
150
- "Bitwarden and GitHub and stop the registry from ever changing it. The " +
151
- "deploy expands them at compose time instead. Replace one with a real " +
152
- "value if this target genuinely differs, and it will push.");
153
- }
154
- // ── pull ─────────────────────────────────────────────────────────────────────
155
- /**
156
- * Brings Bitwarden's values into the target's env file, in place.
157
- *
158
- * ⚠️ With `--file .env` (or `--target development`, which is refused) this is
159
- * the file `pnpm dev` reads. Pulling `production` into it points local
160
- * development at production, which is why that target warns and defaults its
161
- * confirmation to no.
162
- */
163
- export async function runEnvPull(options) {
164
- assertVaultTarget(options.target);
165
- const target = options.target;
166
- const spec = environmentSpecs()[target];
167
- const path = pathFor(target, options.file);
168
- const projectId = await projectIdFor(spec.project);
169
- const refused = neverStore();
170
- const all = await listBwsSecrets(projectId);
171
- // If one of these is in the project it should not be, and writing it into the
172
- // file people run `pnpm dev` against would spread it further. The audit says
173
- // so loudly; here it is simply not written.
174
- const remote = new Map(all.filter((s) => !refused.has(s.key)).map((s) => [s.key, s.value]));
175
- for (const s of all) {
176
- if (refused.has(s.key)) {
177
- log.warn(`${s.key} is in ${spec.project} and must not be. Not written to ${path}. ` +
178
- `Delete it there — run \`pnpm devtools env audit --target ${target}\`.`);
179
- }
180
- }
181
- if (remote.size === 0) {
182
- log.warn(`${spec.project} contains no secrets. Writing nothing, because an empty ` +
183
- `result and a successful pull look identical afterwards.`);
184
- return;
185
- }
186
- const doc = await readDocument(path);
187
- const changes = [];
188
- for (const [key, value] of remote) {
189
- const current = doc.get(key);
190
- if (current === value)
191
- continue;
192
- changes.push(current === undefined
193
- ? `+ ${key} (${fingerprint(value)})${doc.isCommented(key) ? " — uncommenting" : ""}`
194
- : `~ ${key} ${fingerprint(current)} → ${fingerprint(value)}`);
195
- }
196
- if (changes.length === 0) {
197
- log.success(`${path} already matches ${spec.project}.`);
198
- return;
199
- }
200
- note(changes.join("\n"), `${path} would change`);
201
- if (spec.guarded) {
202
- log.warn(`This writes PRODUCTION values into ${path}. Anything that loads that ` +
203
- `file — or a stray \`--file .env\` — is then pointed at production.`);
204
- }
205
- if (!options.yes) {
206
- const ok = unwrap(await confirm({
207
- message: `Apply ${changes.length} change(s) to ${path}?`,
208
- initialValue: !spec.guarded,
209
- }));
210
- if (!ok)
211
- bail("Nothing written.");
212
- }
213
- const stamp = stampFor(target, "pulled");
214
- for (const [key, value] of remote)
215
- doc.set(key, value, stamp);
216
- const moved = await save(path, doc);
217
- log.success(`Updated ${changes.length} value(s) in ${path}` +
218
- (moved ? ", and grouped same-named lines together." : "."));
219
- }
220
- // ── push ─────────────────────────────────────────────────────────────────────
221
- /**
222
- * Sends the target's env file to Bitwarden and then on to GitHub.
223
- *
224
- * Both, always. Bitwarden alone is the failure this design has: the source of
225
- * truth moves, the deploy does not, and everything looks healthy until the old
226
- * credential is revoked.
227
- *
228
- * ⚠️ The file comes from the TARGET. `--target staging` reads `.env.staging`,
229
- * not the root `.env`. That defaulting mistake meant this command uploaded a
230
- * developer's own values to a shared project and said "3 created, 12 updated"
231
- * about it.
232
- *
233
- * ⚠️ Two GitHub stores on the far side, and only one Bitwarden project. The
234
- * public per-environment values become GitHub *variables* rather than secrets
235
- * (see `gh/client.ts` for why the store matters), but they are stored in
236
- * Bitwarden alongside the secrets, because "Bitwarden is the source of truth"
237
- * has to be true of a WHOLE target for `pull` to rebuild a working env file.
238
- * Splitting them would make GitHub the only home for 27 keys and the second
239
- * source of truth for their values, which is the arrangement this tool exists
240
- * to remove.
241
- */
242
- export async function runEnvPush(options) {
243
- assertVaultTarget(options.target);
244
- const target = options.target;
245
- const spec = environmentSpecs()[target];
246
- const path = pathFor(target, options.file);
247
- const doc = await readDocument(path);
248
- const skip = ignoredFor(target);
249
- const { push: secrets, variables: publicValues, refused, unknown, derived, } = selectForPush(doc.entries(), target);
250
- warnRefused(refused);
251
- warnUnknown(unknown);
252
- noteDerived(derived);
253
- // What Bitwarden holds: both halves, keyed together. The two are disjoint by
254
- // construction (see `selection.ts`), so this loses nothing.
255
- const stored = new Map([...secrets, ...publicValues]);
256
- if (stored.size === 0) {
257
- explain(`No pushable values in ${path}.`, "", [
258
- "Committed defaults, per-developer values and empty values are skipped.",
259
- derived.length > 0
260
- ? `The ${derived.length} line(s) named above are still their declared ` +
261
- "derivations, which the registry expands at deploy time."
262
- : "Nothing else was found.",
263
- ]);
264
- process.exitCode = 1;
265
- return;
266
- }
267
- const projectId = await projectIdFor(spec.project);
268
- const existing = await listBwsSecrets(projectId);
269
- const index = byKey(existing);
270
- const created = [];
271
- const updated = [];
272
- for (const [key, value] of stored) {
273
- const current = index.get(key);
274
- if (!current)
275
- created.push(key);
276
- else if (current.value !== value)
277
- updated.push(key);
278
- }
279
- // Present in the project and absent from the file. NEVER removed, only
280
- // reported: a key missing from a file is far more often an incomplete edit
281
- // than an intentional deletion.
282
- const orphaned = existing
283
- .map((s) => s.key)
284
- .filter((k) => !stored.has(k) && !skip.has(k));
285
- if (created.length === 0 && updated.length === 0) {
286
- log.success(`${spec.project} already matches ${path}.`);
287
- if (orphaned.length > 0) {
288
- log.warn(`${orphaned.length} secret(s) exist in the project and not in ${path}: ` +
289
- `${orphaned.join(", ")}. They were left alone.`);
290
- }
291
- }
292
- else {
293
- // Fingerprints for the public values too, not their plaintext. They are
294
- // safe to print, but "values are never printed" is a rule worth stating
295
- // without an exception, and a fingerprint answers the question anybody is
296
- // asking here: rotation, or paste error?
297
- note([
298
- ...created.map((k) => `+ ${k} (${fingerprint(stored.get(k))})`),
299
- ...updated.map((k) => `~ ${k} ${fingerprint(index.get(k).value)} → ${fingerprint(stored.get(k))}`),
300
- ...orphaned.map((k) => `? ${k} only in the project — left alone`),
301
- ].join("\n"), `${spec.project} will change`);
302
- // Overwrites are the destructive half and get their own question, so that
303
- // answering yes to "create three new secrets" is not also answering yes to
304
- // "replace a live credential".
305
- if (updated.length > 0 && !options.yes) {
306
- const ok = unwrap(await confirm({
307
- message: `Overwrite ${updated.length} existing value(s) in ${spec.project}?`,
308
- initialValue: false,
309
- }));
310
- if (!ok)
311
- bail("Nothing written.");
312
- }
313
- if (spec.guarded) {
314
- if (options.yes) {
315
- explain("Refusing to push production non-interactively.", "", [
316
- "Drop --yes and confirm at the prompt.",
317
- ]);
318
- process.exitCode = 1;
319
- return;
320
- }
321
- const sure = unwrap(await confirm({
322
- message: `This writes to PRODUCTION (${spec.project}). Continue?`,
323
- initialValue: false,
324
- }));
325
- if (!sure)
326
- bail("Nothing written.");
327
- }
328
- // Writes are paced ~1.1s apart, under Bitwarden's published 60-POSTs-a-
329
- // minute limit (see bws/pace.ts). Said out loud for a big push, because a
330
- // minute of silence reads as a hang and gets Ctrl-C'd.
331
- const writes = created.length + updated.length;
332
- if (writes > 5) {
333
- log.info(`Writing ${writes} secrets, paced to stay under Bitwarden's rate ` +
334
- `limit — about ${Math.ceil((writes * 1.1) / 10) * 10} seconds.`);
335
- }
336
- for (const key of created) {
337
- await createSecret(projectId, key, stored.get(key), MANAGED);
338
- }
339
- for (const key of updated) {
340
- await updateSecret(index.get(key), stored.get(key), MANAGED);
341
- }
342
- log.success(`${spec.project}: ${created.length} created, ${updated.length} updated.`);
343
- }
344
- await pushToGithub(target, secrets, publicValues, options.yes);
345
- // Record what went where, in the file itself. Values are untouched, since
346
- // this rewrites the trailing comment only, so it needs no confirmation. It is
347
- // what makes a stale file say so rather than look freshly synced.
348
- const stamp = stampFor(target, "pushed");
349
- for (const [key, value] of stored)
350
- doc.set(key, value, stamp);
351
- await save(path, doc);
352
- }
353
- const MANAGED = "Managed by `pnpm devtools env push`.";
354
- /**
355
- * The second half of every push.
356
- *
357
- * One Bitwarden project can feed more than one GitHub environment: `production`
358
- * feeds both `production` and `production-apply`, and which key goes where IS
359
- * the reviewer gate. So this loops over the routed targets rather than assuming
360
- * one, and confirms each separately. Agreeing to update production's ordinary
361
- * secrets is not agreeing to touch the two write-capable credentials sitting
362
- * behind the reviewers.
363
- *
364
- * The two maps stay two maps all the way down to the two `gh` calls. Merging
365
- * them and branching at the bottom would put "which store does this go to?" one
366
- * boolean away from being wrong, and getting it wrong in the variable direction
367
- * publishes a credential's plaintext to everyone who can read the repository's
368
- * Actions config.
369
- *
370
- * Exported ONLY so dispatch can be tested against a mocked `gh` client. The two
371
- * `setSecret`/`setVariable` lines below are the one place where a swap is
372
- * silent, irreversible, and invisible to `selection.ts`'s tests.
373
- */
374
- export async function pushToGithub(target, secrets, publicValues, yes) {
375
- const project = environmentSpecs()[target].project;
376
- // `ghEnvironment`, not `target`: a GitHub environment is a THIRD vocabulary
377
- // (there are four of them, and `production-apply` is not an env target at
378
- // all), so it keeps a name of its own. Reusing `target` here is how the two
379
- // vocabularies got confused in the first place.
380
- for (const ghEnvironment of githubTargets(project)) {
381
- // Routing applies to both stores, and the two loops below are the same
382
- // filter twice rather than one filter and a branch. See the header.
383
- //
384
- // `production-apply` accepts everything `production` does plus the
385
- // apply-tier credential, so a production push writes MOST keys twice: once
386
- // to the unreviewed environment the deploy reads, once to the reviewed one
387
- // whose jobs (`production-config`, `prune-orphans`) were previously
388
- // starved of them. That is not the gate leaking. The gate is
389
- // `production.excludeKeys`, which keeps the apply-tier credential out of
390
- // the FIRST environment; it has never had anything to say about the
391
- // second.
392
- const chosenSecrets = new Map([...secrets].filter(([key]) => accepts(ghEnvironment, key)));
393
- const chosenVariables = new Map([...publicValues].filter(([key]) => accepts(ghEnvironment, key)));
394
- const total = chosenSecrets.size + chosenVariables.size;
395
- if (total === 0)
396
- continue;
397
- const knownSecrets = new Set((await listGhSecrets(ghEnvironment)).map((s) => s.name));
398
- const knownVariables = new Set((await listGhVariables(ghEnvironment)).map((v) => v.name));
399
- const fresh = [...chosenSecrets.keys()].filter((k) => !knownSecrets.has(k)).length +
400
- [...chosenVariables.keys()].filter((k) => !knownVariables.has(k)).length;
401
- if (!yes) {
402
- const ok = unwrap(await confirm({
403
- message: `Sync ${chosenSecrets.size} secret(s) and ` +
404
- `${chosenVariables.size} variable(s) to the \`${ghEnvironment}\` ` +
405
- `GitHub environment (${fresh} new)?`,
406
- // The gated environments hold what a reviewer is meant to see before
407
- // it can be used, so the default answer there is no.
408
- initialValue: !GITHUB_ENVIRONMENT_SPECS[ghEnvironment].guarded,
409
- }));
410
- if (!ok) {
411
- log.warn(`Skipped \`${ghEnvironment}\`. ⚠️ Bitwarden is now ahead of GitHub ` +
412
- `— the deploy still uses the previous values. Run \`pnpm devtools ` +
413
- `env audit --target ${target}\` when you fix it.`);
414
- continue;
415
- }
416
- }
417
- // Sequential. `gh` is one process per secret, and a burst of them against
418
- // the same environment is a good way to meet a secondary rate limit. That
419
- // leaves the set half-applied, the one outcome worse than not having
420
- // started.
421
- for (const [key, value] of chosenSecrets)
422
- await setSecret(ghEnvironment, key, value);
423
- for (const [key, value] of chosenVariables) {
424
- await setVariable(ghEnvironment, key, value);
425
- }
426
- log.success(`Synced ${chosenSecrets.size} secret(s) and ${chosenVariables.size} ` +
427
- `variable(s) to \`${ghEnvironment}\`.`);
428
- }
429
- }
430
- // ── audit ────────────────────────────────────────────────────────────────────
431
- /** Read-only, and safe to run against anything. */
432
- export async function runEnvAudit(options) {
433
- assertVaultTarget(options.target);
434
- const target = options.target;
435
- const spec = environmentSpecs()[target];
436
- // The target's own file, like pull and push. Auditing `--target staging`
437
- // against the development `.env` reported drift on every key that legitimately
438
- // differs between the two, which is most of them.
439
- const path = pathFor(target, options.file);
440
- const doc = await readDocument(path);
441
- const projectId = await projectIdFor(spec.project);
442
- const bwsSecrets = await listBwsSecrets(projectId);
443
- // Every GitHub environment this project feeds, so that a key sitting in the
444
- // WRONG one is visible rather than merely absent from the right one. Both
445
- // stores are read for each: a key in the wrong STORE is as invisible to a
446
- // presence check as one in the wrong environment, and more dangerous.
447
- const github = [];
448
- const githubVariables = [];
449
- const unreachable = [];
450
- for (const ghEnvironment of githubTargets(spec.project)) {
451
- try {
452
- // Both reads complete before either is recorded. Half an environment is
453
- // worse than none of it: the secrets alone, with the environment then
454
- // marked unreachable, would leave every variable it holds looking like a
455
- // GitHub orphan that somebody should delete.
456
- const secrets = await listGhSecrets(ghEnvironment);
457
- const variables = await listGhVariables(ghEnvironment);
458
- for (const secret of secrets) {
459
- github.push({
460
- environment: ghEnvironment,
461
- name: secret.name,
462
- updatedAt: secret.updatedAt,
463
- });
464
- }
465
- for (const variable of variables) {
466
- githubVariables.push({
467
- environment: ghEnvironment,
468
- name: variable.name,
469
- value: variable.value,
470
- updatedAt: variable.updatedAt,
471
- });
472
- }
473
- }
474
- catch {
475
- // Usually an environment nobody has created yet. Reported, then routed
476
- // around: inventing "missing from GitHub" for every key in an
477
- // environment that could not be read would bury everything else.
478
- unreachable.push(ghEnvironment);
479
- }
480
- }
481
- // The repository's own variables, which no environment read can see and push
482
- // never writes. Failure is CARRIED rather than thrown or flattened to `[]`:
483
- // this list can be unreadable where the environment ones are readable, and an
484
- // audit that quietly downgraded "could not look" to "nothing there" would
485
- // report the exact hazard it was added to catch as health.
486
- let repositoryVariables;
487
- try {
488
- repositoryVariables = {
489
- readable: true,
490
- names: (await listRepositoryVariables()).map((v) => v.name),
491
- };
492
- }
493
- catch (err) {
494
- const firstLine = errorMessage(err).split("\n")[0]?.trim() ?? "";
495
- repositoryVariables = {
496
- readable: false,
497
- // The FIRST line only. `describe()` in the gh client returns a paragraph
498
- // of guidance, and a finding is one line. The rest is reproducible by
499
- // running the command the finding names.
500
- reason: firstLine || "`gh` failed",
501
- };
502
- }
503
- const { secrets: cloudflare, unreadable } = await listWorkerSecrets(target);
504
- const commented = new Set(bwsSecrets.map((s) => s.key).filter((k) => doc.isCommented(k)));
505
- const findings = audit({
506
- local: new Map(doc.entries()),
507
- localCommented: commented,
508
- bws: new Map(bwsSecrets.map((s) => [
509
- s.key,
510
- { value: s.value, revisionDate: s.revisionDate },
511
- ])),
512
- github,
513
- githubVariables,
514
- // Which store each key belongs in. Read from the registry rather than
515
- // inferred from where a copy turned up: otherwise a misplaced key would
516
- // define its own correctness and never be reported.
517
- variables: pushableVariables(),
518
- route: (key) => {
519
- const routed = routeTo(spec.project, key);
520
- return routed && unreachable.includes(routed) ? null : routed;
521
- },
522
- // Whether a SECOND copy is legitimate, which stopped being "the one place
523
- // `route` names" when `production-apply` became a superset of `production`.
524
- // A push now writes most production keys to both, and comparing against
525
- // `route` alone would report every one as a stray to delete, burying the one
526
- // stray that matters: an apply-tier key in the unreviewed environment.
527
- // `acceptsKey()` still says no to that one, and is a name rather than a
528
- // lambda so it has tests of its own.
529
- accepted: acceptsKey,
530
- cloudflare,
531
- ignore: ignoredFor(target),
532
- neverStore: neverStore(),
533
- // Keeps the Worker's minted credential from being reported as a Cloudflare
534
- // orphan, and so from being pruned, while still flagging a stored copy.
535
- minted: minted(),
536
- // Lets the audit tell "undeclared" apart from drift: the fix for one is a
537
- // define() in a manifest, for the other a push or a pull.
538
- declared: new Set(getEnvSync().variables().keys()),
539
- // The scope nothing else here addresses. Passed as the scan rather than as
540
- // a list so that `audit` can tell "checked, clean" from "could not check".
541
- repositoryVariables,
542
- });
543
- note(renderFindings(findings), `${target} (${path}) — drift`);
544
- if (unreachable.length > 0) {
545
- log.warn(`Could not read the ${unreachable.join(", ")} GitHub environment(s), so ` +
546
- `nothing was checked there. Usually means it does not exist yet.`);
547
- }
548
- if (unreadable.length > 0) {
549
- log.warn(`Could not read Worker secrets for: ${unreadable.join(", ")}. ` +
550
- `Usually means the Worker has not been deployed yet.`);
551
- }
552
- // Said explicitly, because "no drift" reads as a stronger claim than it is,
553
- // and is now a stronger claim for some keys than for others. That is the sort
554
- // of difference a summary line loses.
555
- log.info("GitHub *secrets* and Cloudflare secrets are write-only: those were " +
556
- "checked for presence, for routing, and for whether GitHub's copy " +
557
- "predates the Bitwarden revision — a changed value is undetectable. " +
558
- "GitHub *variables* are readable, so the public per-environment keys " +
559
- "were compared by VALUE, as your env file and Bitwarden were. " +
560
- // Stated only when it is true. The claim is the coverage itself, so
561
- // printing it unconditionally would turn the one run that could not look
562
- // into the one run that says it did.
563
- (repositoryVariables.readable
564
- ? "The repository's own variables were listed too, and checked for " +
565
- "names that an environment copy would shadow."
566
- : "The repository's own variables could NOT be listed, so nothing " +
567
- "above rules out a shadowed copy at that scope."));
568
- if (hasErrors(findings))
569
- process.exitCode = 1;
570
- }
571
- // ── reset ────────────────────────────────────────────────────────────────────
572
- /**
573
- * Empties every value in a local env file, losing none of them.
574
- *
575
- * The use is handing a filled-in file back to its blank state: after pulling
576
- * production onto a laptop, before passing a machine on, or when a set of keys
577
- * has to be re-entered from scratch. Deleting the values would do that too, and
578
- * would also delete the only copy of anything that was never pushed. So each
579
- * becomes a commented line holding what it was, plus an empty active line
580
- * beneath. The file still declares every key it needs, which makes it a
581
- * checklist rather than a blank page.
582
- *
583
- * Purely local. It touches no remote store, so it takes no `--target`: asking
584
- * which target to clear a file against would imply it reaches one. It works on
585
- * `.env` unless `--file` says otherwise, and that default is written out here
586
- * rather than inherited, so the shared `pathFor` has no "no target" case for a
587
- * remote command to fall into.
588
- */
589
- export async function runEnvReset(options) {
590
- const path = pathFor("development", options.file);
591
- const doc = await readDocument(path);
592
- const active = doc.entries().filter(([, value]) => value !== "");
593
- if (active.length === 0) {
594
- log.success(`Nothing to clear — every value in ${path} is already empty.`);
595
- return;
596
- }
597
- note(active
598
- .map(([key, value]) => `~ ${key} ${fingerprint(value)} → empty`)
599
- .join("\n"), `${path} will be cleared`);
600
- if (!options.yes) {
601
- const ok = unwrap(await confirm({
602
- message: `Clear ${active.length} value(s)? Each is kept, commented out, on the line above.`,
603
- initialValue: false,
604
- }));
605
- if (!ok)
606
- bail("Nothing written.");
607
- }
608
- const cleared = doc.reset();
609
- await save(path, doc);
610
- log.success(`Cleared ${cleared.length} value(s) in ${path}.`);
611
- log.info("Every previous value is still in the file, commented out. `env pull` " +
612
- "will fill them back in from Bitwarden.");
613
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * Imports every manifest so the registry populates. Idempotent; concurrent
3
- * callers share one pass. Await this before touching any derived selector.
4
- */
5
- export declare function loadRegistry(): Promise<void>;
6
- /**
7
- * Refuses to proceed on an empty registry.
8
- *
9
- * For SYNC consumers of the derived selectors (`selectForPush`, `routeTo`),
10
- * which cannot await the load themselves. An empty registry makes every
11
- * selector return `[]`, and `[]` fails open in the worst direction: nothing is
12
- * never-store, so `BWS_ACCESS_TOKEN` would upload. Better a crash naming the
13
- * missing call than a push that quietly stores the unstorable.
14
- */
15
- export declare function assertRegistryLoaded(): void;