@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.
- package/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +179 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- 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
|
+
}
|