@golden-frijoles/kit 0.0.0 → 0.2.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 (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. package/package.json +33 -1
@@ -0,0 +1,229 @@
1
+ // gh-rest.mjs — REST-only GitHub API helpers, for environments where `gh`'s GraphQL proxying is
2
+ // unavailable.
3
+ //
4
+ // Confirmed live (2026-07-02, ops-nightly's Claude Routine sandbox): `gh pr list --json`, `gh pr view
5
+ // --json`, and `gh pr comment` all internally hit `https://api.github.com/graphql` — traced via
6
+ // `GH_DEBUG=api`. `gh run list/rerun` and any `gh api <rest-path>` call always use REST v3 regardless,
7
+ // and were unaffected. This module gives standup.mjs/weekly-recap.mjs/babysit-pr.mjs/build-order-sync.mjs
8
+ // REST-only equivalents for the operations they need, so they no longer depend on GraphQL being reachable.
9
+ //
10
+ // Normalizes every REST response into the SAME shape + casing the codebase's existing pure decision
11
+ // functions already expect (matching GraphQL's statusCheckRollup: UPPERCASE conclusion/state enums,
12
+ // camelCase detailsUrl, mergeable as a MergeableState-style string) — so decideBabysitActions() and the
13
+ // standup's failingOpen/conflictingOpen filters need NO changes, only the I/O layer feeding them does.
14
+ //
15
+ // Zero npm deps — Node >=20 (spawnSync). `gh api` takes GET query params and POST bodies as `-f key=value`
16
+ // pairs (safe: passed as argv, never shell-interpolated) for short payloads, or JSON piped via
17
+ // `--input -` for a comment/PR body that may be long or contain characters `-f` can't safely encode.
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+
21
+ function ghApi(args, { input } = {}) {
22
+ const r = spawnSync('gh', ['api', ...args], {
23
+ encoding: 'utf8',
24
+ maxBuffer: 16 * 1024 * 1024,
25
+ input,
26
+ });
27
+ if (r.status !== 0) return null;
28
+ try {
29
+ return JSON.parse(r.stdout || 'null');
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // ---- pure normalizers (exported for unit testing without gh/network I/O) ----
36
+
37
+ // GitHub's REST `mergeable_state` (dirty|clean|unstable|has_hooks|unknown|…) → the same coarse
38
+ // MERGEABLE/CONFLICTING/UNKNOWN vocabulary the codebase's `mergeable === 'CONFLICTING'` checks expect
39
+ // (mirroring GraphQL's MergeableState enum, which only ever surfaced as one of those three in practice
40
+ // here). `unknown` means GitHub hasn't finished computing it yet — the caller retries, this just reports
41
+ // what it was handed.
42
+ export function mapMergeableState(mergeableState, mergeableBool) {
43
+ if (mergeableState === 'dirty') return 'CONFLICTING';
44
+ if (mergeableState === 'clean' || mergeableState === 'unstable' || mergeableState === 'has_hooks') {
45
+ return 'MERGEABLE';
46
+ }
47
+ if (mergeableBool === false) return 'CONFLICTING';
48
+ if (mergeableBool === true) return 'MERGEABLE';
49
+ return 'UNKNOWN';
50
+ }
51
+
52
+ // Merges the legacy combined-status API + the modern check-runs API into ONE rollup list, normalized to
53
+ // look like a GraphQL statusCheckRollup entry: a check-run item carries {name, status, conclusion,
54
+ // detailsUrl} (status/conclusion uppercase, matching CheckStatusState/CheckConclusionState), a legacy
55
+ // status item carries {context, state, detailsUrl} (state uppercase, matching StatusState) — exactly the
56
+ // two shapes decideBabysitActions()/standup.mjs's failingOpen filter already branch on.
57
+ export function buildStatusRollup({ combinedStatus, checkRuns }) {
58
+ const rollup = [];
59
+ for (const s of combinedStatus?.statuses || []) {
60
+ rollup.push({
61
+ context: s.context,
62
+ state: (s.state || '').toUpperCase(),
63
+ detailsUrl: s.target_url || null,
64
+ });
65
+ }
66
+ for (const c of checkRuns?.check_runs || []) {
67
+ rollup.push({
68
+ name: c.name,
69
+ status: (c.status || '').toUpperCase(),
70
+ conclusion: c.conclusion ? c.conclusion.toUpperCase() : null,
71
+ detailsUrl: c.details_url || null,
72
+ });
73
+ }
74
+ return rollup;
75
+ }
76
+
77
+ // REST pull-list item → the shape standup.mjs's gatherRepoPrs already builds `byNumber`/open/merged sets
78
+ // from (state OPEN/MERGED/CLOSED matching GraphQL's PullRequestState enum casing).
79
+ //
80
+ // `headRefName` (the PR's source branch) was added 2026-07-26 for session-resume.mjs's stray-branch check
81
+ // (README.md D3: "a non-main branch with no open PR") — it needs to correlate a repo's current local
82
+ // branch against open PRs by branch NAME, which the list endpoint already returns as `head.ref` but no
83
+ // existing consumer had asked for yet. Purely additive: every field standup.mjs/babysit-pr.mjs already
84
+ // read is unchanged.
85
+ export function normalizePullListItem(p) {
86
+ return {
87
+ number: p.number,
88
+ title: p.title,
89
+ state: p.state === 'open' ? 'OPEN' : p.merged_at ? 'MERGED' : 'CLOSED',
90
+ isDraft: !!p.draft,
91
+ mergedAt: p.merged_at,
92
+ createdAt: p.created_at,
93
+ updatedAt: p.updated_at,
94
+ url: p.html_url,
95
+ headSha: p.head?.sha || null,
96
+ headRefName: p.head?.ref || null,
97
+ };
98
+ }
99
+
100
+ // A GitHub Search API (`/search/issues`) item, scoped to `is:pr` → the {number, title, mergedAt, url}
101
+ // shape weekly-recap.mjs already expects.
102
+ export function normalizeSearchPrItem(it) {
103
+ return {
104
+ number: it.number,
105
+ title: it.title,
106
+ createdAt: it.created_at || null,
107
+ mergedAt: it.pull_request?.merged_at || null,
108
+ url: it.html_url,
109
+ };
110
+ }
111
+
112
+ // ---- I/O (thin wrappers over `gh api`) ----
113
+
114
+ // GET /repos/{repo}/pulls?state=… — list PRs (title/state/draft/dates/url only; mergeable and check
115
+ // status are NOT included by the list endpoint — see getPullMergeability/getStatusRollup for those).
116
+ export function listPulls({ repo, state = 'all', perPage = 50 }) {
117
+ const data = ghApi([
118
+ `repos/${repo}/pulls`,
119
+ '--method',
120
+ 'GET',
121
+ '-f',
122
+ `state=${state}`,
123
+ '-f',
124
+ `per_page=${perPage}`,
125
+ '-f',
126
+ 'sort=updated',
127
+ '-f',
128
+ 'direction=desc',
129
+ ]);
130
+ return data === null ? null : data.map(normalizePullListItem);
131
+ }
132
+
133
+ // search/issues — merged PRs with `merged_at >= sinceDate` (day-granular, same as the old GraphQL
134
+ // `merged:>=` search qualifier), optionally scoped to a base branch. Paginates up to maxItems.
135
+ export function searchMergedPrs({ repo, sinceDate, base, maxItems = 500 }) {
136
+ const items = [];
137
+ const perPage = 100;
138
+ for (let page = 1; items.length < maxItems; page += 1) {
139
+ const q = `repo:${repo} is:pr is:merged merged:>=${sinceDate}${base ? ` base:${base}` : ''}`;
140
+ const data = ghApi([
141
+ 'search/issues',
142
+ '--method',
143
+ 'GET',
144
+ '-f',
145
+ `q=${q}`,
146
+ '-f',
147
+ `per_page=${perPage}`,
148
+ '-f',
149
+ `page=${page}`,
150
+ ]);
151
+ if (data === null) return null;
152
+ const pageItems = data.items || [];
153
+ items.push(...pageItems);
154
+ if (pageItems.length < perPage) break;
155
+ }
156
+ return items.slice(0, maxItems).map(normalizeSearchPrItem);
157
+ }
158
+
159
+ // GET /repos/{repo}/pulls/{number} — raw response, with GitHub's own documented retry-on-"unknown"
160
+ // pattern: `mergeable`/`mergeable_state` are computed ASYNCHRONOUSLY, so a just-fetched or just-updated
161
+ // PR can report `mergeable_state: "unknown"` on the first try. One retry (2s default) resolves it in
162
+ // practice (confirmed live); if it's still unknown after that, the caller gets 'unknown' back rather
163
+ // than this function guessing.
164
+ function fetchPullWithRetry({ repo, number, retries = 1, delayMs = 2000 }) {
165
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
166
+ const data = ghApi([`repos/${repo}/pulls/${number}`, '--method', 'GET']);
167
+ if (data === null) return null;
168
+ if (data.mergeable_state !== 'unknown' || attempt === retries) return data;
169
+ spawnSync('sleep', [String(delayMs / 1000)]);
170
+ }
171
+ return null; // unreachable — the loop always returns on its last iteration
172
+ }
173
+
174
+ // GET /repos/{repo}/pulls/{number} — just the mapped mergeable state (standup.mjs's use case).
175
+ export function getPullMergeability({ repo, number, retries = 1, delayMs = 2000 }) {
176
+ const data = fetchPullWithRetry({ repo, number, retries, delayMs });
177
+ return data === null ? null : mapMergeableState(data.mergeable_state, data.mergeable);
178
+ }
179
+
180
+ // GET /repos/{repo}/pulls/{number} — the fuller shape babysit-pr.mjs needs in one call: state
181
+ // (OPEN/CLOSED/MERGED, matching GraphQL's PullRequestState casing), headSha (for getStatusRollup), and
182
+ // the mapped mergeable state.
183
+ export function getPull({ repo, number, retries = 1, delayMs = 2000 }) {
184
+ const data = fetchPullWithRetry({ repo, number, retries, delayMs });
185
+ if (data === null) return null;
186
+ return {
187
+ number: data.number,
188
+ state: data.state === 'open' ? 'OPEN' : data.merged_at ? 'MERGED' : 'CLOSED',
189
+ url: data.html_url,
190
+ headSha: data.head?.sha || null,
191
+ mergeable: mapMergeableState(data.mergeable_state, data.mergeable),
192
+ };
193
+ }
194
+
195
+ // GET commits/{sha}/status + commits/{sha}/check-runs, merged via buildStatusRollup(). `per_page=100`
196
+ // (GitHub's max) on check-runs — its default page size is 30, so an unpaginated call could silently miss
197
+ // failures past page 1 on a commit with many parallel jobs.
198
+ export function getStatusRollup({ repo, sha }) {
199
+ const combinedStatus = ghApi([`repos/${repo}/commits/${sha}/status`, '--method', 'GET']);
200
+ const checkRuns = ghApi([
201
+ `repos/${repo}/commits/${sha}/check-runs`,
202
+ '--method',
203
+ 'GET',
204
+ '-f',
205
+ 'per_page=100',
206
+ ]);
207
+ // Fail closed on EITHER source failing, not just both — a PARTIAL rollup (one source real, the other
208
+ // silently empty) is worse than no rollup at all, since it can misreport a genuine failure as "clean."
209
+ // Callers treat a null rollup as "unavailable" (degrading to no-signal), never as "confirmed clean."
210
+ if (combinedStatus === null || checkRuns === null) return null;
211
+ return buildStatusRollup({ combinedStatus, checkRuns });
212
+ }
213
+
214
+ // POST /repos/{repo}/issues/{number}/comments — works for both issues and PRs (a PR IS an issue for
215
+ // this endpoint, same as `gh pr comment` used under the hood).
216
+ export function postIssueComment({ repo, number, body }) {
217
+ const data = ghApi([`repos/${repo}/issues/${number}/comments`, '--method', 'POST', '--input', '-'], {
218
+ input: JSON.stringify({ body }),
219
+ });
220
+ return data ? { url: data.html_url } : null;
221
+ }
222
+
223
+ // POST /repos/{repo}/pulls
224
+ export function createPullRequest({ repo, head, base, title, body }) {
225
+ const data = ghApi([`repos/${repo}/pulls`, '--method', 'POST', '--input', '-'], {
226
+ input: JSON.stringify({ head, base, title, body }),
227
+ });
228
+ return data ? { url: data.html_url, number: data.number } : null;
229
+ }
@@ -0,0 +1,179 @@
1
+ // golden-onboarding.mjs — the Golden Frijoles onboarding surface, named ONCE.
2
+ //
3
+ // ── Why this is a module and not a sentence in three READMEs ───────────────────────────────────
4
+ // golden-flags-by-default S1.4's acceptance is that the text the agent prints, the install page's
5
+ // CLI block and `gf init`'s own next-step line are **one surface**: "the three must say the same
6
+ // thing". Two hand-written copies of `npx @golden-frijoles/cli init` agree right up until one of
7
+ // them is edited, and the one that drifts is always the one nobody runs.
8
+ //
9
+ // So every command a consuming project is ever told to type comes from here, and
10
+ // `scripts/check-onboarding-parity.mjs` (in dobby-foundation, not shipped) asserts the shipped
11
+ // READMEs and AGENTS.md still contain these exact strings.
12
+ //
13
+ // ── The cross-repo half, stated honestly ──────────────────────────────────────────────────────
14
+ // The other two surfaces live in the Golden Frijoles product repo — its `/install` page reads
15
+ // `apps/web/lib/cli-install.ts`, and `gf init` prints from `packages/cli/src/commands/init.ts`.
16
+ // This file cannot import either: they are a different repo, and a template that npm-installs a
17
+ // product's web app to read a string would be absurd. The weld there is the same discipline in the
18
+ // other direction (`cli-install.test.ts` reads `packages/cli/package.json` off disk). Here the
19
+ // values are TRANSCRIBED, each with the file it was transcribed from, and re-checked by hand at
20
+ // every CLI release — `ENV_KEYS` below is the pair that matters, and `preflight.mjs` FAILS LOUDLY
21
+ // if the CLI writes different names, so a drift is caught by running the tool rather than by
22
+ // reading it.
23
+ //
24
+ // ── The env var names are D6 of the CLI epic, and D3 of this one ──────────────────────────────
25
+ // `gf init` writes exactly three names, and deliberately does NOT write `flag_sync` or an ingest
26
+ // key: a verb whose job is "let this app READ its flags" must not put wider credentials on disk as
27
+ // a side effect. `flag_sync` is an operator/deploy credential and belongs in CI secrets — minted on
28
+ // purpose with `gf keys create --type flag_sync`. Never both in one place.
29
+ //
30
+ // Zero deps — Node 18+.
31
+
32
+ /** The published packages. Transcribed from golden-beans `apps/web/lib/cli-install.ts`. */
33
+ export const CLI_PACKAGE = '@golden-frijoles/cli';
34
+ export const SDK_PACKAGE = '@golden-frijoles/sdk';
35
+
36
+ /** The binary `npm i -g` puts on PATH. */
37
+ export const CLI_BIN = 'gf';
38
+
39
+ /**
40
+ * The minimum CLI this template's contract is written against.
41
+ *
42
+ * It is the version that shipped the write path — `flags create --kill-switch --all-envs`, the verb
43
+ * every kill-switch story in this operating system now names. A project on an older `gf` can read
44
+ * flags and cannot complete a kill-switch story, which is a failure worth naming at preflight
45
+ * rather than discovering halfway through one.
46
+ */
47
+ export const MIN_CLI_VERSION = '0.1.0';
48
+
49
+ /**
50
+ * The env var names `gf init` writes and the app reads. ONE definition, on purpose (CLI epic D6):
51
+ * the file and its reader are generated together in `gf init`, and this is that pair's address on
52
+ * the template side. `preflight.mjs` and `apps/example-app/flags.mjs` both read it from here.
53
+ */
54
+ export const ENV_KEYS = {
55
+ url: 'GOLDEN_FRIJOLES_URL',
56
+ flagRead: 'GOLDEN_FRIJOLES_FLAG_READ_KEY',
57
+ environment: 'GOLDEN_FRIJOLES_ENVIRONMENT',
58
+ };
59
+
60
+ /** The file `gf init` writes, and refuses to write if it cannot get it into `.gitignore`. */
61
+ export const ENV_FILE = '.env.local';
62
+
63
+ /** The route a `flag_read` key is exercised against — the one that actually serves it. */
64
+ export const SNAPSHOT_PATH = 'api/v1/flags/snapshot';
65
+
66
+ /** The default deployment, matching the CLI's own `DEFAULT_API_URL`. */
67
+ export const DEFAULT_API_URL = 'https://goldenfrijoles.com';
68
+
69
+ /**
70
+ * The ONE command. `npx` first because the promise is "a machine that has never seen it" — a reader
71
+ * who wants the global install finds it one line below, and a reader who only wants to try it will
72
+ * not go looking for the shorter form.
73
+ */
74
+ export const CLI_NPX_INIT = `npx ${CLI_PACKAGE} init`;
75
+ export const CLI_NPX_LOGIN = `npx ${CLI_PACKAGE} login`;
76
+ export const CLI_GLOBAL_INSTALL = `npm i -g ${CLI_PACKAGE}`;
77
+
78
+ /**
79
+ * The kill-switch story as the commands that complete it — **including the activation step**.
80
+ *
81
+ * D4 of this epic: definitions are catalog-as-code, activations are not. `flags create` does both
82
+ * in one verb (it creates the definition AND activates it in every environment named), which is
83
+ * exactly why the distinction has to be written down somewhere a planner reads: `flags sync`, the
84
+ * catalog-as-code verb, does NOT activate, and a project that syncs its definitions from source
85
+ * control and never activates them serves compile defaults through the fallback chain while its
86
+ * console reads "Never turned on here".
87
+ */
88
+ export const KILL_SWITCH_STORY = [
89
+ `${CLI_BIN} flags create <domain>.<feature>_enabled --kill-switch --all-envs`,
90
+ `${CLI_BIN} flags get <domain>.<feature>_enabled # ← the ACTIVATION check: PRODUCTION must not read "—"`,
91
+ `${CLI_BIN} flags kill <domain>.<feature>_enabled --env production`,
92
+ ];
93
+
94
+ /**
95
+ * How to read the activation check's answer, in the CLI's OWN vocabulary.
96
+ *
97
+ * ⚠️ **This line shipped wrong once, and the way it was wrong is worth keeping.** It said
98
+ * `gf flags ls --env production` and told the reader to look for the words *"never turned on"*. Two
99
+ * separate errors: `gf flags ls` accepts only `--project` and prints all three environments as
100
+ * columns, so `--env` is a hard usage error that exits 1 before it ever reaches auth; and *"never
101
+ * turned on here"* is the **web console's** wording, while the CLI's `describeServing` prints `—`.
102
+ * A reader following it literally would have run a command that cannot run, looking for a string
103
+ * the tool never prints.
104
+ *
105
+ * It survived because it was verified the way prose gets verified — the string was present in every
106
+ * surface, and `check-onboarding-parity.mjs` was about to weld it into five files. **Presence is not
107
+ * execution.** A command a doc tells someone to run is checked by running it.
108
+ */
109
+ export const SERVING_LEGEND = [
110
+ '— never activated here. The consumer is serving its call-site default.',
111
+ 'off (nothing served) activated, then deactivated. Also the call-site default.',
112
+ '<value> v<n> activated, and this is what a context with no attributes actually gets.',
113
+ ];
114
+
115
+ /**
116
+ * The block an agent prints when a project has no linked Golden Frijoles project.
117
+ *
118
+ * Returned as an array of lines rather than one string so a caller can indent it, prefix it or push
119
+ * it into an existing report without re-wrapping. `preflight.mjs` prints it verbatim on a hard
120
+ * failure; the READMEs carry it as a fenced block; `check-onboarding-parity.mjs` asserts they match.
121
+ */
122
+ export function onboardingLines() {
123
+ return [
124
+ 'This project has no Golden Frijoles project linked, so it cannot create a kill-switch.',
125
+ 'Two commands fix that — the second writes .env.local and prints the snippet that reads it:',
126
+ '',
127
+ ` ${CLI_NPX_LOGIN}`,
128
+ ` ${CLI_NPX_INIT}`,
129
+ '',
130
+ `(${CLI_GLOBAL_INSTALL} puts \`${CLI_BIN}\` on your PATH. Every command takes --json.)`,
131
+ ];
132
+ }
133
+
134
+ /**
135
+ * Compare two dotted versions. Returns <0, 0 or >0 — `null` when either is unparseable, because
136
+ * "we could not tell" and "it is older" lead to different actions and must not collapse.
137
+ *
138
+ * Deliberately not semver-complete: prerelease tags are compared as "has one = older than the same
139
+ * numbers without one", which is the only prerelease rule this check needs and the only one it can
140
+ * get right without a dependency in a repo that has none.
141
+ */
142
+ export function compareVersions(a, b) {
143
+ const parse = (v) => {
144
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v).trim());
145
+ return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
146
+ };
147
+ const left = parse(a);
148
+ const right = parse(b);
149
+ if (!left || !right) return null;
150
+ for (let i = 0; i < 3; i += 1) {
151
+ if (left.nums[i] !== right.nums[i]) return left.nums[i] - right.nums[i];
152
+ }
153
+ if (left.pre === right.pre) return 0;
154
+ if (left.pre === null) return 1;
155
+ if (right.pre === null) return -1;
156
+ return left.pre < right.pre ? -1 : 1;
157
+ }
158
+
159
+ /**
160
+ * The value of `name` in a dotenv file, or null.
161
+ *
162
+ * ⚠️ **The LAST assignment wins.** `dotenv` assigns in file order, so a later line overrides an
163
+ * earlier one — and a reader that returns the FIRST will confidently report on a value the running
164
+ * app does not use. Two duplicate lines is not exotic: it is what a hand-edit plus a re-run of
165
+ * `gf init` produces. Same rule, same reason, as the CLI's own `readEnvValue`.
166
+ *
167
+ * No interpolation, deliberately: this reads a file to decide whether a credential is present, and
168
+ * expanding `$VAR` would make that answer depend on the environment doing the reading.
169
+ */
170
+ export function readEnvValue(contents, name) {
171
+ let found = null;
172
+ for (const line of String(contents).split('\n')) {
173
+ const match = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`).exec(line);
174
+ if (!match) continue;
175
+ const raw = match[1].trim().replace(/^(['"])(.*)\1$/, '$2');
176
+ found = raw === '' ? null : raw;
177
+ }
178
+ return found;
179
+ }