@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,39 @@
1
+ /**
2
+ * Retrying exactly one failure class: HTTP 429, rate limiting.
3
+ *
4
+ * A 429 is the server saying "correct request, wrong minute", the only error
5
+ * where trying the same thing again IS the fix. Everything else (bad token,
6
+ * missing project, malformed input) re-throws untouched on the first attempt,
7
+ * because retrying those just triples the time to the real message.
8
+ *
9
+ * Written for the Secrets Manager calls, where the minute matters: a push is
10
+ * ~45 sequential writes plus a login, several targets get pushed back-to-back,
11
+ * and Bitwarden's identity endpoint rate-limits repeated access-token logins
12
+ * aggressively. The login half is also fixed at the source, since the client
13
+ * now caches its login in a state file, so the retry is the belt on that
14
+ * suspender.
15
+ */
16
+ import { log } from "@clack/prompts";
17
+ export function isRateLimited(err) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ return /\b429\b|too many requests|rate.?limit/i.test(message);
20
+ }
21
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ export async function withRateLimitRetry(op, { retries = 3, baseMs = 5_000, sleep = wait, doing }) {
23
+ for (let attempt = 0;; attempt++) {
24
+ try {
25
+ return await op();
26
+ }
27
+ catch (err) {
28
+ if (!isRateLimited(err) || attempt >= retries)
29
+ throw err;
30
+ const ms = baseMs * 2 ** attempt;
31
+ // A silent 20-second stall reads as a hang, and a command that appears
32
+ // hung gets Ctrl-C'd, turning a wait the server asked for into a
33
+ // half-finished push.
34
+ log.warn(`Bitwarden rate-limited ${doing} (HTTP 429). Waiting ${ms / 1000}s ` +
35
+ `and retrying (${attempt + 1}/${retries})…`);
36
+ await sleep(ms);
37
+ }
38
+ }
39
+ }
@@ -0,0 +1 @@
1
+ export declare function saveToDevEnv(key: string, value: string): Promise<void>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Saving a prompted value into the developer's own `.env`.
3
+ *
4
+ * The development file only, by construction: the path is fixed to
5
+ * `fileFor("development")`, so no caller can point this at a vault target's
6
+ * file. Those are what `env push` uploads, and the values saved here
7
+ * (`BWS_ACCESS_TOKEN`, `BWS_ORG_ID`) exist so they never ride a push. The
8
+ * token is refused there by name anyway; this keeps the write from ever being
9
+ * the thing that needs refusing.
10
+ *
11
+ * `EnvDocument.set` revives the commented line the rendered file already
12
+ * carries (never-store keys ship as `# KEY=""` with their documentation), so a
13
+ * saved value lands under its own doc comment rather than appended bare.
14
+ */
15
+ import { readFile, writeFile } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+ import { EnvDocument } from "../env/document.js";
18
+ import { findRepoRoot } from "../repo/root.js";
19
+ import { loadEnv } from "../repo/peers.js";
20
+ export async function saveToDevEnv(key, value) {
21
+ const { fileFor } = await loadEnv();
22
+ const path = resolve(findRepoRoot(), fileFor("development"));
23
+ let doc;
24
+ try {
25
+ doc = EnvDocument.parse(await readFile(path, "utf8"));
26
+ }
27
+ catch {
28
+ doc = EnvDocument.empty();
29
+ }
30
+ doc.set(key, value);
31
+ await writeFile(path, doc.toString());
32
+ }
@@ -0,0 +1,31 @@
1
+ export declare class NoAccessTokenError extends Error {
2
+ }
3
+ export interface TokenSources {
4
+ /** From `--access-token`. */
5
+ explicit?: string;
6
+ /** From `process.env.BWS_ACCESS_TOKEN`. */
7
+ env?: string;
8
+ /** `undefined` for every reason the vault might not have it. */
9
+ fromVault: () => Promise<string | undefined>;
10
+ /** `undefined` when there is nobody to ask. */
11
+ prompt: () => Promise<string | undefined>;
12
+ /** Asked only after a successful prompt. */
13
+ offerSave: () => Promise<boolean>;
14
+ save: (token: string) => Promise<void>;
15
+ /** Where the token came from, for the caller to report. */
16
+ onSource?: (source: TokenSource) => void;
17
+ }
18
+ export type TokenSource = "flag" | "environment" | "vault" | "prompt";
19
+ export declare function resolveToken(sources: TokenSources): Promise<string>;
20
+ /**
21
+ * A weak shape check, to turn a paste error into a sentence rather than a 401.
22
+ *
23
+ * Machine account tokens are `0.<uuid>.<secret>:<secret>`. This checks the
24
+ * leading version segment only, and **warns rather than refuses**: the format
25
+ * is Bitwarden's to change, and a tool that rejects a valid token because its
26
+ * prefix moved is worse than one that passes a bad token to `bws` and lets the
27
+ * real error surface.
28
+ */
29
+ export declare function looksLikeAccessToken(value: string): boolean;
30
+ /** The interactive half. Masked, and never echoed back. */
31
+ export declare function promptForToken(): Promise<string | undefined>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Finding the Secrets Manager access token, in four places, in order.
3
+ *
4
+ * 1. `--access-token` explicit, and explicit beats ambient
5
+ * 2. `BWS_ACCESS_TOKEN` the environment, which includes your `.env`,
6
+ * since `with-env` loads it for every command
7
+ * 3. the Bitwarden Password Manager vault, via the bundled `bw`
8
+ * 4. ask, and offer to save it to `.env` (the default) or the vault, so
9
+ * this is the last time
10
+ *
11
+ * The ordering logic is separated from the four implementations because it is
12
+ * the part that can be wrong invisibly. A chain that silently prefers a stale
13
+ * environment variable over a rotated vault entry authenticates as the wrong
14
+ * account and reports nothing, so the order is asserted rather than assumed.
15
+ */
16
+ import { log, password } from "@clack/prompts";
17
+ import { unwrap } from "../ui.js";
18
+ export class NoAccessTokenError extends Error {
19
+ }
20
+ export async function resolveToken(sources) {
21
+ const announce = sources.onSource ?? (() => { });
22
+ // Explicit beats ambient. Someone who passes the flag while an environment
23
+ // variable is set is overriding on purpose, and silently ignoring them would
24
+ // point the command at the account they were trying to avoid.
25
+ if (sources.explicit) {
26
+ announce("flag");
27
+ return sources.explicit;
28
+ }
29
+ if (sources.env) {
30
+ announce("environment");
31
+ return sources.env;
32
+ }
33
+ const stored = await sources.fromVault();
34
+ if (stored) {
35
+ announce("vault");
36
+ return stored;
37
+ }
38
+ const typed = await sources.prompt();
39
+ if (!typed) {
40
+ throw new NoAccessTokenError("No access token. Put BWS_ACCESS_TOKEN in your .env (an interactive " +
41
+ "run offers to do this for you), pass --access-token, or store one " +
42
+ "in your Bitwarden vault. See docs/toolkit/guides/env/commands.md.");
43
+ }
44
+ announce("prompt");
45
+ // Offered only for a token that was just typed. Saving one that came from a
46
+ // flag or the environment would copy a credential into a vault nobody asked
47
+ // to put it in.
48
+ if (await sources.offerSave()) {
49
+ // A failed vault write must not lose the token. Somebody just pasted a live
50
+ // credential; making them do it again because a write failed is the worst
51
+ // moment to be strict, and the command itself is unaffected.
52
+ try {
53
+ await sources.save(typed);
54
+ }
55
+ catch (err) {
56
+ log.warn(`Could not save to your vault: ${err instanceof Error ? err.message : String(err)}. ` +
57
+ "Continuing with the token you typed.");
58
+ }
59
+ }
60
+ return typed;
61
+ }
62
+ /**
63
+ * A weak shape check, to turn a paste error into a sentence rather than a 401.
64
+ *
65
+ * Machine account tokens are `0.<uuid>.<secret>:<secret>`. This checks the
66
+ * leading version segment only, and **warns rather than refuses**: the format
67
+ * is Bitwarden's to change, and a tool that rejects a valid token because its
68
+ * prefix moved is worse than one that passes a bad token to `bws` and lets the
69
+ * real error surface.
70
+ */
71
+ export function looksLikeAccessToken(value) {
72
+ return /^\d+\.[0-9a-f-]{36}\./i.test(value.trim());
73
+ }
74
+ /** The interactive half. Masked, and never echoed back. */
75
+ export async function promptForToken() {
76
+ if (!process.stdin.isTTY)
77
+ return undefined;
78
+ const typed = unwrap(await password({
79
+ message: "Paste the Secrets Manager access token for the `admin` account",
80
+ mask: "•",
81
+ validate: (v) => (v ?? "").trim() === "" ? "An access token is required." : undefined,
82
+ })).trim();
83
+ if (!looksLikeAccessToken(typed)) {
84
+ log.warn("That does not look like a machine account access token (they start " +
85
+ "`0.<uuid>.`). Continuing anyway — if it is wrong, Bitwarden will say so.");
86
+ }
87
+ return typed;
88
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The item this looks for, and creates.
3
+ *
4
+ * Named for what it unlocks rather than for this tool, because whoever finds it
5
+ * in the vault six months from now needs to know what it is, not which script
6
+ * wrote it.
7
+ */
8
+ export declare const VAULT_ITEM_NAME = "DevDogs Secrets Manager access token (admin)";
9
+ export type VaultStatus = "unavailable" | "unauthenticated" | "locked" | "unlocked";
10
+ /** `bw status`, or `unavailable` when the CLI is not installed. */
11
+ export declare function vaultStatus(): Promise<VaultStatus>;
12
+ /**
13
+ * The stored token, or `undefined` for every reason it might not be there.
14
+ *
15
+ * `bw get password` takes a search term and fails when it matches more than one
16
+ * item, which is the behaviour worth having: two items called something like
17
+ * this means somebody should look, not that this should guess.
18
+ */
19
+ export declare function readTokenFromVault(): Promise<string | undefined>;
20
+ /**
21
+ * Creates the item, with the token arriving on **stdin** as base64 JSON.
22
+ *
23
+ * `bw create item` documents an encoded-JSON positional AND stdin. Using stdin
24
+ * is the difference between a live credential that is invisible and one that
25
+ * sits in `ps` output for the length of the call.
26
+ */
27
+ export declare function saveTokenToVault(token: string): Promise<boolean>;
28
+ /** Why the vault could not be used, phrased as something to do about it. */
29
+ export declare function explainVault(status: VaultStatus): string | undefined;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The Bitwarden **Password Manager** vault, via the `bw` CLI.
3
+ *
4
+ * A different product from Secrets Manager and a different CLI, which is the
5
+ * point: `bws` has no user authentication at all, so the access token it needs
6
+ * has to be held somewhere a person can log in to. That somewhere is the
7
+ * Password Manager vault, and this module reads it back rather than making
8
+ * somebody paste a token every session.
9
+ *
10
+ * Everything here degrades to `undefined`. A missing `bw`, a locked vault, a
11
+ * declined unlock, a missing item: none of them are errors, because each has a
12
+ * good fallback, which is to ask. Throwing would turn a convenience into a
13
+ * prerequisite.
14
+ *
15
+ * ⚠️ The token never passes through argv in either direction. Reads use
16
+ * `bw get password <search>`, which puts only the item NAME on the command
17
+ * line, and writes pipe base64 JSON through **stdin**, which `bw create item`
18
+ * documents as an accepted input.
19
+ */
20
+ import { execFile, spawn } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { confirm, log, spinner } from "@clack/prompts";
23
+ import { unwrap } from "../ui.js";
24
+ const run = promisify(execFile);
25
+ /**
26
+ * The item this looks for, and creates.
27
+ *
28
+ * Named for what it unlocks rather than for this tool, because whoever finds it
29
+ * in the vault six months from now needs to know what it is, not which script
30
+ * wrote it.
31
+ */
32
+ export const VAULT_ITEM_NAME = "DevDogs Secrets Manager access token (admin)";
33
+ /** `bw status`, or `unavailable` when the CLI is not installed. */
34
+ export async function vaultStatus() {
35
+ try {
36
+ const { stdout } = await run("bw", bwArgs(["status", "--response"]), {
37
+ shell: false,
38
+ });
39
+ // `--response` wraps the payload; the bare form is also accepted, so read
40
+ // whichever shape came back rather than depending on one.
41
+ const parsed = JSON.parse(stdout);
42
+ const status = ("status" in parsed && parsed.status) ||
43
+ ("data" in parsed && parsed.data?.template?.status) ||
44
+ undefined;
45
+ if (status === "unlocked")
46
+ return "unlocked";
47
+ if (status === "locked")
48
+ return "locked";
49
+ return "unauthenticated";
50
+ }
51
+ catch (err) {
52
+ if (err.code === "ENOENT")
53
+ return "unavailable";
54
+ return "unauthenticated";
55
+ }
56
+ }
57
+ /**
58
+ * A usable session key, unlocking if the person agrees.
59
+ *
60
+ * `BW_SESSION` is preferred and silent. Otherwise this ASKS before unlocking:
61
+ * a tool that pops a master-password prompt unannounced is shaped exactly like
62
+ * the thing people are told never to type their master password into.
63
+ *
64
+ * The password is typed straight into `bw`. stdin is inherited, so it never
65
+ * passes through this process.
66
+ */
67
+ async function session(status) {
68
+ if (process.env.BW_SESSION)
69
+ return process.env.BW_SESSION;
70
+ if (status !== "locked")
71
+ return undefined;
72
+ if (!process.stdin.isTTY)
73
+ return undefined;
74
+ const ok = unwrap(await confirm({
75
+ message: "Your Bitwarden vault is locked. Unlock it to look for the access token?",
76
+ initialValue: true,
77
+ }));
78
+ if (!ok)
79
+ return undefined;
80
+ return new Promise((resolve) => {
81
+ // stdin inherited so the master password goes to `bw` and not through here;
82
+ // stdout piped so the session key can be captured rather than printed.
83
+ const child = spawn("bw", ["unlock", "--raw"], {
84
+ stdio: ["inherit", "pipe", "inherit"],
85
+ shell: false,
86
+ });
87
+ let out = "";
88
+ child.stdout.on("data", (c) => (out += c.toString()));
89
+ child.on("error", () => resolve(undefined));
90
+ child.on("close", (code) => resolve(code === 0 && out.trim() !== "" ? out.trim() : undefined));
91
+ });
92
+ }
93
+ /**
94
+ * Adds the session key, and `--nointeraction` always.
95
+ *
96
+ * The second one is not belt-and-braces. `bw` prompts for a master password on
97
+ * stdin whenever the vault is locked, as `bw get template item` does against a
98
+ * locked vault, and this module runs `bw` with piped stdio, where that prompt
99
+ * has nobody to answer it and hangs until something gives up. `--nointeraction`
100
+ * turns that into an immediate non-zero exit, which every caller here already
101
+ * treats as "not available, ask instead".
102
+ */
103
+ function bwArgs(args, key) {
104
+ const withNoInteraction = [...args, "--nointeraction"];
105
+ return key ? [...withNoInteraction, "--session", key] : withNoInteraction;
106
+ }
107
+ /**
108
+ * The stored token, or `undefined` for every reason it might not be there.
109
+ *
110
+ * `bw get password` takes a search term and fails when it matches more than one
111
+ * item, which is the behaviour worth having: two items called something like
112
+ * this means somebody should look, not that this should guess.
113
+ */
114
+ export async function readTokenFromVault() {
115
+ const status = await vaultStatus();
116
+ if (status === "unavailable" || status === "unauthenticated") {
117
+ // Said out loud, and only here. By the time this runs the chain has already
118
+ // found nothing in the flag or the environment, so the person is about to
119
+ // be asked to paste a token. That is when knowing the vault could have
120
+ // answered instead is worth something.
121
+ log.info(explainVault(status));
122
+ return undefined;
123
+ }
124
+ const key = await session(status);
125
+ if (status === "locked" && !key)
126
+ return undefined;
127
+ const s = spinner();
128
+ s.start("Looking in your Bitwarden vault");
129
+ try {
130
+ const { stdout } = await run("bw", bwArgs(["get", "password", VAULT_ITEM_NAME, "--raw"], key), { shell: false });
131
+ const token = stdout.trim();
132
+ if (token === "") {
133
+ s.stop("Nothing stored in the vault yet");
134
+ return undefined;
135
+ }
136
+ s.stop(`Read the access token from your vault ("${VAULT_ITEM_NAME}")`);
137
+ return token;
138
+ }
139
+ catch {
140
+ // "not found" and "more than one match" both land here, and both mean the
141
+ // same thing to the caller: ask instead.
142
+ s.stop("No single matching item in your vault");
143
+ return undefined;
144
+ }
145
+ }
146
+ /**
147
+ * Creates the item, with the token arriving on **stdin** as base64 JSON.
148
+ *
149
+ * `bw create item` documents an encoded-JSON positional AND stdin. Using stdin
150
+ * is the difference between a live credential that is invisible and one that
151
+ * sits in `ps` output for the length of the call.
152
+ */
153
+ export async function saveTokenToVault(token) {
154
+ const status = await vaultStatus();
155
+ if (status === "unavailable" || status === "unauthenticated")
156
+ return false;
157
+ const key = await session(status);
158
+ if (status === "locked" && !key)
159
+ return false;
160
+ const item = {
161
+ organizationId: null,
162
+ collectionIds: null,
163
+ folderId: null,
164
+ type: 1, // login, so `bw get password` can retrieve it in one call
165
+ name: VAULT_ITEM_NAME,
166
+ notes: "Bitwarden Secrets Manager access token for the `admin` machine account, " +
167
+ "read/write on preflight, staging and production.\n\n" +
168
+ "Read automatically by `pnpm devtools env`. Never put this in a .env " +
169
+ "file: it unlocks all three projects, and the tool refuses to upload it.",
170
+ favorite: false,
171
+ reprompt: 0,
172
+ login: { username: null, password: token, totp: null },
173
+ };
174
+ return new Promise((resolve) => {
175
+ const child = spawn("bw", bwArgs(["create", "item"], key), {
176
+ stdio: ["pipe", "ignore", "pipe"],
177
+ shell: false,
178
+ });
179
+ let stderr = "";
180
+ child.stderr.on("data", (c) => (stderr += c.toString()));
181
+ child.on("error", () => resolve(false));
182
+ child.on("close", (code) => {
183
+ if (code !== 0 && stderr.trim() !== "")
184
+ log.warn(stderr.trim());
185
+ resolve(code === 0);
186
+ });
187
+ child.stdin.end(Buffer.from(JSON.stringify(item)).toString("base64"));
188
+ });
189
+ }
190
+ /** Why the vault could not be used, phrased as something to do about it. */
191
+ export function explainVault(status) {
192
+ if (status === "unavailable") {
193
+ return ("The `bw` CLI is not installed, so the vault could not be checked. " +
194
+ "`pnpm devtools bw login` — the CLI is a devtools dependency, nothing to install.");
195
+ }
196
+ if (status === "unauthenticated") {
197
+ return "The `bw` CLI is not signed in. Run `bw login`.";
198
+ }
199
+ return undefined;
200
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What can be reported on this instance, and for what reasons?
3
+ *
4
+ * Neither half of that question had an answer in the CLI before. `doctor` comes
5
+ * closest, but it runs `conformance_check()` for a single app and reports
6
+ * pass/fail per content type, a diagnostic rather than a listing, and it says
7
+ * nothing about reasons at all.
8
+ *
9
+ * It matters more now than it used to, because both halves stopped being
10
+ * editable at runtime. Content types are derived from each app's own schema and
11
+ * report reasons are a platform-owned enum, so there is no configuration page
12
+ * to open and look at. The database is the only source of truth, and this is
13
+ * how a contributor reads it. `docs/platform/reporting-and-feedback.md` carries
14
+ * a copy of the reason list for people who are not at a terminal, but that copy
15
+ * is maintained by hand and says so. This is what settles a disagreement.
16
+ *
17
+ * Both calls run as the seeded moderator rather than through an admin client.
18
+ * `list_content_types` is `security definer` behind a `canModerate` check, so
19
+ * an anonymous or ordinary client sees nothing, and `list_report_reasons` is
20
+ * `security invoker` and subject to RLS. Using a persona means what prints here
21
+ * is what a moderator would actually see, rather than what a superuser can.
22
+ */
23
+ import { type Instance } from "./instance.js";
24
+ export interface CatalogReason {
25
+ reason: string;
26
+ title: string;
27
+ description: string | null;
28
+ }
29
+ export interface CatalogContentType {
30
+ appSlug: string;
31
+ contentType: string;
32
+ label: string;
33
+ tableName: string;
34
+ schemaName: string;
35
+ visibility: string;
36
+ /** Null when the table carries no foreign key to reportResolutions. */
37
+ quarantineColumn: string | null;
38
+ /** Null when no single-column primary key could be derived. */
39
+ refColumn: string | null;
40
+ authorColumn: string | null;
41
+ }
42
+ export interface Catalog {
43
+ reasons: CatalogReason[];
44
+ contentTypes: CatalogContentType[];
45
+ }
46
+ export declare function readCatalog(instance: Instance): Promise<Catalog>;
47
+ /**
48
+ * Formats the catalog for `note()`.
49
+ *
50
+ * Content types are grouped by app, because a contributor asks "what does *my*
51
+ * app expose?". A type that cannot be quarantined or cannot be addressed is
52
+ * called out inline rather than left to be inferred from a blank column. Those
53
+ * are the two states that look like working configuration and are not.
54
+ */
55
+ export declare function renderCatalog(catalog: Catalog): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What can be reported on this instance, and for what reasons?
3
+ *
4
+ * Neither half of that question had an answer in the CLI before. `doctor` comes
5
+ * closest, but it runs `conformance_check()` for a single app and reports
6
+ * pass/fail per content type, a diagnostic rather than a listing, and it says
7
+ * nothing about reasons at all.
8
+ *
9
+ * It matters more now than it used to, because both halves stopped being
10
+ * editable at runtime. Content types are derived from each app's own schema and
11
+ * report reasons are a platform-owned enum, so there is no configuration page
12
+ * to open and look at. The database is the only source of truth, and this is
13
+ * how a contributor reads it. `docs/platform/reporting-and-feedback.md` carries
14
+ * a copy of the reason list for people who are not at a terminal, but that copy
15
+ * is maintained by hand and says so. This is what settles a disagreement.
16
+ *
17
+ * Both calls run as the seeded moderator rather than through an admin client.
18
+ * `list_content_types` is `security definer` behind a `canModerate` check, so
19
+ * an anonymous or ordinary client sees nothing, and `list_report_reasons` is
20
+ * `security invoker` and subject to RLS. Using a persona means what prints here
21
+ * is what a moderator would actually see, rather than what a superuser can.
22
+ */
23
+ import { personaClient, PERSONAS } from "./instance.js";
24
+ export async function readCatalog(instance) {
25
+ const client = await personaClient(instance, PERSONAS.moderator);
26
+ const [reasons, contentTypes] = await Promise.all([
27
+ client.rpc("list_report_reasons"),
28
+ client.rpc("list_content_types"),
29
+ ]);
30
+ if (reasons.error)
31
+ throw new Error(reasons.error.message);
32
+ if (contentTypes.error)
33
+ throw new Error(contentTypes.error.message);
34
+ return {
35
+ reasons: (reasons.data ?? []),
36
+ contentTypes: (contentTypes.data ?? []),
37
+ };
38
+ }
39
+ /**
40
+ * Formats the catalog for `note()`.
41
+ *
42
+ * Content types are grouped by app, because a contributor asks "what does *my*
43
+ * app expose?". A type that cannot be quarantined or cannot be addressed is
44
+ * called out inline rather than left to be inferred from a blank column. Those
45
+ * are the two states that look like working configuration and are not.
46
+ */
47
+ export function renderCatalog(catalog) {
48
+ const lines = [];
49
+ lines.push("Report reasons — one global list, every app, every content type");
50
+ for (const r of catalog.reasons) {
51
+ lines.push(` ${r.reason.padEnd(16)} ${r.title}`);
52
+ }
53
+ if (catalog.reasons.length === 0) {
54
+ lines.push(" (none — the presentation table is empty, which is a bug)");
55
+ }
56
+ lines.push("");
57
+ lines.push("Content types — derived from each app's schema, not configured");
58
+ const byApp = new Map();
59
+ for (const t of catalog.contentTypes) {
60
+ byApp.set(t.appSlug, [...(byApp.get(t.appSlug) ?? []), t]);
61
+ }
62
+ if (byApp.size === 0) {
63
+ lines.push(" (none — no registered app declares any moderatable content)");
64
+ }
65
+ for (const [app, types] of [...byApp].sort()) {
66
+ lines.push(` ${app}`);
67
+ for (const t of types) {
68
+ const notes = [];
69
+ if (!t.quarantineColumn)
70
+ notes.push("no quarantine column");
71
+ if (!t.refColumn)
72
+ notes.push("NOT ADDRESSABLE: no single-column key");
73
+ if (!t.authorColumn)
74
+ notes.push("no author column derived");
75
+ const suffix = notes.length > 0 ? ` (${notes.join("; ")})` : "";
76
+ lines.push(` ${t.contentType.padEnd(14)} ${t.schemaName}.${t.tableName}` +
77
+ ` [${t.visibility}]${suffix}`);
78
+ }
79
+ }
80
+ return lines.join("\n");
81
+ }
@@ -0,0 +1 @@
1
+ export declare function runCf(argv: readonly string[]): Promise<number>;