@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,469 @@
|
|
|
1
|
+
// report-registry.mjs — the report scripts' half of reporthub-as-notion Sprint 1: uploads a report
|
|
2
|
+
// payload to the GCS registry provisioned by the project's bucket-provisioning script (Story 1.1) and
|
|
3
|
+
// returns a short `/r/<slug>` link served by the project's doc-viewer resolver (Story 1.2). On
|
|
4
|
+
// ANY failure — no credentials, no `gcloud`, a rejected upload, an unreachable bucket — this degrades to
|
|
5
|
+
// the caller's already-computed URL-hash link (LEARNINGS soft-mode pattern: standup.mjs/weekly-recap.mjs
|
|
6
|
+
// already do this for a missing/wiped delta-log baseline; this is the same discipline applied to a new
|
|
7
|
+
// failure mode). Callers keep building the URL-hash link first (scripts/lib/pmo-templates.mjs's
|
|
8
|
+
// buildDocViewerUrl) — this module only ever *upgrades* that link, never replaces the guaranteed fallback.
|
|
9
|
+
//
|
|
10
|
+
// The resolver URL and bucket are the PROJECT's (reporting.config.json → artifacts.registry). Unset, the
|
|
11
|
+
// registry is simply off: buildReportLink returns the fallback with reason `registry-not-configured` and
|
|
12
|
+
// uploads nothing. There is deliberately no default bucket — a default is one project's storage.
|
|
13
|
+
//
|
|
14
|
+
// Slug -> object path convention (matches the project's bucket-provisioning script's lifecycle rule and
|
|
15
|
+
// the fork's /r/<slug> resolver EXACTLY — a change here needs the same-wave change called out in
|
|
16
|
+
// the project's registry-invariants test):
|
|
17
|
+
// daily-story-YYYY-MM-DD-<hash6> -> daily/daily-story-YYYY-MM-DD-<hash6>.md (90d TTL)
|
|
18
|
+
// pmo-weekly-YYYY-MM-DD -> packets/pmo-weekly-YYYY-MM-DD.md (kept forever)
|
|
19
|
+
// pmo-monthly-YYYY-MM-DD -> packets/pmo-monthly-YYYY-MM-DD.md (kept forever)
|
|
20
|
+
// pmo-sheet-YYYY-MM-DD -> packets/pmo-sheet-YYYY-MM-DD.md (kept forever)
|
|
21
|
+
// Any other slug that doesn't start with "daily-" also lands under packets/ — the daily/ prefix is the
|
|
22
|
+
// only thing the lifecycle rule keys off, so anything not explicitly daily must default to "kept forever".
|
|
23
|
+
//
|
|
24
|
+
// Zero new deps: the credentialed upload path signs a service-account JSON key's JWT with node:crypto,
|
|
25
|
+
// exchanges it for an OAuth token, and PUTs straight to the storage.googleapis.com JSON API — no
|
|
26
|
+
// google-cloud/storage package. A `gcloud storage cp` fallback covers local/interactive runs that already
|
|
27
|
+
// have Application Default Credentials but no service-account key file at hand.
|
|
28
|
+
|
|
29
|
+
import { createSign } from 'node:crypto';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
|
|
33
|
+
export const DAILY_PREFIX = 'daily/';
|
|
34
|
+
export const PACKETS_PREFIX = 'packets/';
|
|
35
|
+
// reporthub-as-notion S2.1: a THIRD prefix for artifacts that need to be repeatedly overwritten in
|
|
36
|
+
// place at a stable, well-known key (a live JSON/markdown snapshot the hub reads on every page load) —
|
|
37
|
+
// unlike daily/ and packets/ objects, which are one-shot immutable artifacts protected by
|
|
38
|
+
// `if-generation-match: 0` (see uploadViaRest/uploadViaGcloud below), live/ objects are written with
|
|
39
|
+
// `allowOverwrite: true` and are expected to change on every publish run. Not covered by the bucket's
|
|
40
|
+
// 90d lifecycle rule (the project's bucket-provisioning script only expires daily/ — a live/ object is a
|
|
41
|
+
// single small rolling snapshot, not an accumulating log, so "kept forever" is fine and matches
|
|
42
|
+
// packets/'s default).
|
|
43
|
+
export const LIVE_PREFIX = 'live/';
|
|
44
|
+
|
|
45
|
+
const GCS_UPLOAD_SCOPE = 'https://www.googleapis.com/auth/devstorage.read_write';
|
|
46
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------------------------------
|
|
49
|
+
// Pure — slug generation, path mapping, URL building, the fallback decision. No I/O; fully unit-testable
|
|
50
|
+
// without a live bucket/credentials (LEARNINGS: keep the auth/upload plumbing separate from the logic a
|
|
51
|
+
// regression test should pin).
|
|
52
|
+
// ---------------------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function pad2(n) {
|
|
55
|
+
return String(n).padStart(2, '0');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// UTC date, not local — routine runs execute in an unknown timezone; a UTC-stable slug means the same
|
|
59
|
+
// calendar instant always produces the same daily/packet key regardless of where the script executes.
|
|
60
|
+
export function dateStamp(date = new Date()) {
|
|
61
|
+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A short, deterministic, collision-safe suffix derived from the payload's own content (FNV-1a, 32-bit,
|
|
65
|
+
// hex — no crypto module needed for a non-adversarial collision-avoidance hash). Two different reports
|
|
66
|
+
// generated on the same UTC day get different suffixes; the SAME report content re-uploaded on the same
|
|
67
|
+
// day reproduces the SAME slug, which makes an accidental double-run idempotent (overwrite, not a stray
|
|
68
|
+
// duplicate object) instead of minting a fresh throwaway link each retry.
|
|
69
|
+
export function contentSuffix(content) {
|
|
70
|
+
let hash = 0x811c9dc5;
|
|
71
|
+
const s = String(content ?? '');
|
|
72
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
73
|
+
hash ^= s.charCodeAt(i);
|
|
74
|
+
hash = Math.imul(hash, 0x01000193);
|
|
75
|
+
}
|
|
76
|
+
return (hash >>> 0).toString(16).padStart(8, '0').slice(0, 6);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Slug builders — one per artifact kind the report scripts emit today. `daily-story` is the ONLY kind
|
|
80
|
+
// that carries a collision suffix + lands under daily/; every packet kind is a stable one-per-day slug
|
|
81
|
+
// under packets/ (a same-day re-run intentionally overwrites the same object — packets are the
|
|
82
|
+
// "kept forever" canonical link for that day, not a throwaway).
|
|
83
|
+
export function dailyStorySlug({ date = new Date(), markdown = '' } = {}) {
|
|
84
|
+
return `daily-story-${dateStamp(date)}-${contentSuffix(markdown)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function pmoWeeklySlug({ date = new Date() } = {}) {
|
|
88
|
+
return `pmo-weekly-${dateStamp(date)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function pmoMonthlySlug({ date = new Date() } = {}) {
|
|
92
|
+
return `pmo-monthly-${dateStamp(date)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function pmoSheetSlug({ date = new Date() } = {}) {
|
|
96
|
+
return `pmo-sheet-${dateStamp(date)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SLUG_BUILDERS = {
|
|
100
|
+
standup: dailyStorySlug,
|
|
101
|
+
'daily-story': dailyStorySlug,
|
|
102
|
+
weekly: pmoWeeklySlug,
|
|
103
|
+
monthly: pmoMonthlySlug,
|
|
104
|
+
sheet: pmoSheetSlug,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Pure — lowercases and collapses anything outside [a-z0-9-] to a single '-' (leading/trailing dashes
|
|
108
|
+
// trimmed). Only used for the UNRECOGNIZED-name fallback slug below: a caller-supplied artifact `name`
|
|
109
|
+
// is developer-controlled today, but a future artifact kind (or a typo) could carry spaces, punctuation,
|
|
110
|
+
// or mixed case, and a slug is a URL path segment / GCS object key — never build one from unsanitized
|
|
111
|
+
// interpolation. Falls back to 'report' if sanitizing empties the string entirely.
|
|
112
|
+
export function sanitizeSlugPart(value) {
|
|
113
|
+
const cleaned = String(value ?? '')
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
116
|
+
.replace(/-+/g, '-')
|
|
117
|
+
.replace(/^-|-$/g, '');
|
|
118
|
+
return cleaned || 'report';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Dispatches by the report scripts' own artifact `name` (standup.mjs's 'standup', pmo-report.mjs's
|
|
122
|
+
// 'weekly'/'monthly'/'sheet') to the matching slug builder. An unrecognized name still gets a safe,
|
|
123
|
+
// unique-enough slug (packet-shaped, content-suffixed, sanitized) rather than throwing — a new artifact
|
|
124
|
+
// kind added later degrades to "works, not optimally named" instead of crashing the report script.
|
|
125
|
+
export function slugForArtifact({ name, date = new Date(), markdown = '' }) {
|
|
126
|
+
const builder = SLUG_BUILDERS[name];
|
|
127
|
+
if (builder) return builder({ date, markdown });
|
|
128
|
+
return `${sanitizeSlugPart(name)}-${dateStamp(date)}-${contentSuffix(markdown)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The one place the daily/-vs-packets/ split is decided — matches
|
|
132
|
+
// the project's bucket-provisioning script's `DAILY_PREFIX="daily/"` lifecycle rule exactly.
|
|
133
|
+
export function objectPathForSlug(slug) {
|
|
134
|
+
return slug.startsWith('daily-') ? `${DAILY_PREFIX}${slug}.md` : `${PACKETS_PREFIX}${slug}.md`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// live/<key>.<ext> — the object path for an overwrite-allowed live artifact (Story 2.1/2.2). `key` is
|
|
138
|
+
// sanitized the same way an unrecognized artifact name is (see sanitizeSlugPart below) because it's
|
|
139
|
+
// still a GCS object-key/URL path segment; a caller-supplied key should never be interpolated raw.
|
|
140
|
+
export function liveObjectPath(key, ext = 'json') {
|
|
141
|
+
return `${LIVE_PREFIX}${sanitizeSlugPart(key)}.${ext}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function registryUrl({ slug, baseUrl }) {
|
|
145
|
+
return `${baseUrl}/r/${slug}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function resolveBucket(env = process.env, configured = null) {
|
|
149
|
+
return env.REPORT_REGISTRY_BUCKET || configured || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Pure — the fallback decision itself, isolated so it's independently testable from the I/O that
|
|
153
|
+
// produces `ok`/`reason`. "Any failure" means exactly that: no special-casing of WHY the upload failed.
|
|
154
|
+
export function shouldFallbackToUrlHash(uploadResult) {
|
|
155
|
+
return !uploadResult?.ok;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------------------------------
|
|
159
|
+
// Impure — credential resolution + the actual upload. Two paths, tried in order:
|
|
160
|
+
// 1. Service-account JSON key (routine pattern — an env-var-provided key, per LEARNINGS' env-var
|
|
161
|
+
// fallback discipline for anything a routine needs unattended): signs + exchanges its own JWT,
|
|
162
|
+
// zero new deps, then PUTs the payload straight to the JSON API.
|
|
163
|
+
// 2. `gcloud storage cp` (local/interactive pattern — Application Default Credentials already on the
|
|
164
|
+
// machine, no key file needed).
|
|
165
|
+
// Neither available/working -> soft failure, never throws; the caller always gets a `{ ok, reason }`.
|
|
166
|
+
// ---------------------------------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function base64url(input) {
|
|
169
|
+
return Buffer.from(input).toString('base64url');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadServiceAccountKey(env) {
|
|
173
|
+
if (env.GOOGLE_APPLICATION_CREDENTIALS_JSON) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(env.GOOGLE_APPLICATION_CREDENTIALS_JSON);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(readFileSync(env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8'));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Signs a self-issued JWT for a service-account key (RS256, node:crypto only) and exchanges it for an
|
|
191
|
+
// OAuth access token via the standard JWT-bearer grant. Returns null on any failure (malformed key,
|
|
192
|
+
// network error, non-2xx response) — never throws, so a routine with no key configured just falls
|
|
193
|
+
// through to the gcloud path or the URL-hash fallback.
|
|
194
|
+
export async function getAccessTokenFromServiceAccountKey(
|
|
195
|
+
key,
|
|
196
|
+
{ fetchImpl = fetch, now = () => Date.now() } = {}
|
|
197
|
+
) {
|
|
198
|
+
if (!key?.client_email || !key?.private_key) return null;
|
|
199
|
+
const iat = Math.floor(now() / 1000);
|
|
200
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
201
|
+
const claim = {
|
|
202
|
+
iss: key.client_email,
|
|
203
|
+
scope: GCS_UPLOAD_SCOPE,
|
|
204
|
+
aud: TOKEN_URL,
|
|
205
|
+
iat,
|
|
206
|
+
exp: iat + 3600,
|
|
207
|
+
};
|
|
208
|
+
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`;
|
|
209
|
+
let signature;
|
|
210
|
+
try {
|
|
211
|
+
signature = createSign('RSA-SHA256').update(signingInput).sign(key.private_key);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const assertion = `${signingInput}.${Buffer.from(signature).toString('base64url')}`;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const res = await fetchImpl(TOKEN_URL, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
221
|
+
body: new URLSearchParams({
|
|
222
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
223
|
+
assertion,
|
|
224
|
+
}),
|
|
225
|
+
signal: AbortSignal.timeout(10000),
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) return null;
|
|
228
|
+
const body = await res.json();
|
|
229
|
+
return body.access_token || null;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Precondition-failure detector shared by both upload paths — a 412 (REST) or gcloud's equivalent
|
|
236
|
+
// stderr means "an object already exists at this generation-0 precondition", i.e. this exact slug is
|
|
237
|
+
// already taken. That's treated as SUCCESS (not overwritten, but the /r/<slug> link already resolves to
|
|
238
|
+
// *something* — idempotent-safe for a same-day re-run with unchanged content, and for a genuine
|
|
239
|
+
// same-day content drift on a packet slug it's a deliberate "never silently replace a public link"
|
|
240
|
+
// trade-off, not a bug).
|
|
241
|
+
function isPreconditionFailure(text) {
|
|
242
|
+
return /\b412\b|precondition/i.test(String(text ?? ''));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function uploadViaRest({
|
|
246
|
+
bucket,
|
|
247
|
+
objectPath,
|
|
248
|
+
markdown,
|
|
249
|
+
token,
|
|
250
|
+
fetchImpl = fetch,
|
|
251
|
+
contentType = 'text/markdown; charset=utf-8',
|
|
252
|
+
// Story 2.1: live/ objects are meant to be overwritten on every publish run — set true to skip the
|
|
253
|
+
// generation-0 precondition entirely. Every existing caller (daily/packets uploads) leaves this false
|
|
254
|
+
// and keeps the original never-overwrite behavior unchanged.
|
|
255
|
+
allowOverwrite = false,
|
|
256
|
+
}) {
|
|
257
|
+
try {
|
|
258
|
+
const headers = {
|
|
259
|
+
'Content-Type': contentType,
|
|
260
|
+
Authorization: `Bearer ${token}`,
|
|
261
|
+
};
|
|
262
|
+
// Overwrite protection: generation 0 means "only succeed if no object exists at this name yet" — an
|
|
263
|
+
// existing /r/<slug> object can never be silently replaced by a later run. Skipped for live/ objects,
|
|
264
|
+
// which are expected to change on every publish.
|
|
265
|
+
if (!allowOverwrite) headers['x-goog-if-generation-match'] = '0';
|
|
266
|
+
const res = await fetchImpl(
|
|
267
|
+
`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o` +
|
|
268
|
+
`?uploadType=media&name=${encodeURIComponent(objectPath)}`,
|
|
269
|
+
{
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers,
|
|
272
|
+
body: markdown,
|
|
273
|
+
signal: AbortSignal.timeout(15000),
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
if (res.ok) return { ok: true };
|
|
277
|
+
if (res.status === 412) return { ok: true, reason: 'already-exists' };
|
|
278
|
+
return { ok: false, reason: `rest-upload-http-${res.status}` };
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { ok: false, reason: `rest-upload-error: ${err.message || err}` };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function gcloudAvailable(spawnSyncImpl) {
|
|
285
|
+
const r = spawnSyncImpl('gcloud', ['--version'], { encoding: 'utf8' });
|
|
286
|
+
return r.status === 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function uploadViaGcloud({
|
|
290
|
+
bucket,
|
|
291
|
+
objectPath,
|
|
292
|
+
markdown,
|
|
293
|
+
spawnSyncImpl = spawnSync,
|
|
294
|
+
contentType = 'text/markdown; charset=utf-8',
|
|
295
|
+
allowOverwrite = false,
|
|
296
|
+
}) {
|
|
297
|
+
if (!gcloudAvailable(spawnSyncImpl)) return { ok: false, reason: 'gcloud-not-available' };
|
|
298
|
+
// `cp -` (stdin) has no filename to sniff a content-type from — pin it explicitly so the object serves
|
|
299
|
+
// as `text/markdown` (matching the REST path's Content-Type) instead of defaulting to
|
|
300
|
+
// application/octet-stream, which some renderers refuse to treat as text.
|
|
301
|
+
// --if-generation-match=0: same overwrite protection as the REST path's x-goog-if-generation-match
|
|
302
|
+
// header — `gcloud storage cp` supports this flag directly (confirmed: `gcloud storage cp --help`).
|
|
303
|
+
// Skipped when allowOverwrite is true (live/ objects — see uploadViaRest's comment).
|
|
304
|
+
const args = ['storage', 'cp', '-', `gs://${bucket}/${objectPath}`, `--content-type=${contentType}`];
|
|
305
|
+
if (!allowOverwrite) args.push('--if-generation-match=0');
|
|
306
|
+
const r = spawnSyncImpl('gcloud', args, { input: markdown, encoding: 'utf8' });
|
|
307
|
+
if (r.status === 0) return { ok: true };
|
|
308
|
+
const stderr = (r.stderr || '').trim();
|
|
309
|
+
if (isPreconditionFailure(stderr)) return { ok: true, reason: 'already-exists' };
|
|
310
|
+
return { ok: false, reason: `gcloud-cp-failed: ${stderr.slice(0, 300)}` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Orchestrates the two upload paths. Tries the credentialed REST path first (the routine/unattended
|
|
314
|
+
// pattern — an env-var key is the only thing that works with no interactive gcloud session at all); falls
|
|
315
|
+
// back to `gcloud storage cp` (the local/interactive pattern — ADC already on the machine). Returns
|
|
316
|
+
// `{ ok: false, reason: 'no-credentials' }` when neither is usable, never throws.
|
|
317
|
+
export async function uploadReportPayload({
|
|
318
|
+
bucket,
|
|
319
|
+
slug,
|
|
320
|
+
markdown,
|
|
321
|
+
env = process.env,
|
|
322
|
+
fetchImpl = fetch,
|
|
323
|
+
spawnSyncImpl = spawnSync,
|
|
324
|
+
// Story 2.1: override the slug->path derivation (used by publishLiveArtifact below to target live/
|
|
325
|
+
// instead of daily//packets/) and the content-type (JSON payloads, not just markdown). Both default to
|
|
326
|
+
// the original packet/daily behavior, so every existing caller is unaffected.
|
|
327
|
+
objectPath = objectPathForSlug(slug),
|
|
328
|
+
contentType = 'text/markdown; charset=utf-8',
|
|
329
|
+
allowOverwrite = false,
|
|
330
|
+
}) {
|
|
331
|
+
const key = loadServiceAccountKey(env);
|
|
332
|
+
if (key) {
|
|
333
|
+
const token = await getAccessTokenFromServiceAccountKey(key, { fetchImpl });
|
|
334
|
+
if (token) {
|
|
335
|
+
const result = await uploadViaRest({
|
|
336
|
+
bucket,
|
|
337
|
+
objectPath,
|
|
338
|
+
markdown,
|
|
339
|
+
token,
|
|
340
|
+
fetchImpl,
|
|
341
|
+
contentType,
|
|
342
|
+
allowOverwrite,
|
|
343
|
+
});
|
|
344
|
+
if (result.ok) return result;
|
|
345
|
+
// A key was configured but the upload itself failed (bad IAM binding, wrong bucket, etc.) — still
|
|
346
|
+
// worth trying the gcloud path in case ADC on this machine covers it, before giving up.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (gcloudAvailable(spawnSyncImpl)) {
|
|
351
|
+
return uploadViaGcloud({ bucket, objectPath, markdown, spawnSyncImpl, contentType, allowOverwrite });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { ok: false, reason: 'no-credentials' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------------------------------
|
|
358
|
+
// Story 2.1/2.2 — publish a LIVE artifact (a well-known, repeatedly-overwritten key under live/), for
|
|
359
|
+
// the hub's auto-refreshed roadmap/sprint views and PMO chart views. This is deliberately a SEPARATE
|
|
360
|
+
// call site from buildReportLink/upgradeArtifactLinks above: those upgrade a Telegram artifact's
|
|
361
|
+
// fallback URL to a one-shot, overwrite-PROTECTED packet link; this publishes a rolling snapshot at a
|
|
362
|
+
// STABLE address the hub always fetches from — no fallback-URL concept, just "did the publish
|
|
363
|
+
// succeed?". Never throws (same soft-mode contract as uploadReportPayload); a routine calling this
|
|
364
|
+
// degrades gracefully — the hub's client-side fetch (public/reports.js) falls back to its last-known
|
|
365
|
+
// bundled snapshot on any failure, so a failed publish is a staleness issue, not an outage.
|
|
366
|
+
// ---------------------------------------------------------------------------------------------------
|
|
367
|
+
export async function publishLiveArtifact({
|
|
368
|
+
bucket = resolveBucket(),
|
|
369
|
+
key,
|
|
370
|
+
content,
|
|
371
|
+
ext = 'json',
|
|
372
|
+
contentType = ext === 'json' ? 'application/json; charset=utf-8' : 'text/markdown; charset=utf-8',
|
|
373
|
+
env = process.env,
|
|
374
|
+
uploader = uploadReportPayload,
|
|
375
|
+
}) {
|
|
376
|
+
const objectPath = liveObjectPath(key, ext);
|
|
377
|
+
if (!bucket) return { ok: false, objectPath, reason: 'registry-not-configured' };
|
|
378
|
+
try {
|
|
379
|
+
const result = await uploader({
|
|
380
|
+
bucket,
|
|
381
|
+
objectPath,
|
|
382
|
+
markdown: content,
|
|
383
|
+
env,
|
|
384
|
+
contentType,
|
|
385
|
+
allowOverwrite: true,
|
|
386
|
+
});
|
|
387
|
+
if (result?.ok) return { ok: true, objectPath };
|
|
388
|
+
return { ok: false, objectPath, reason: result?.reason || 'unknown' };
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return { ok: false, objectPath, reason: `uploader-threw: ${(err && err.message) || err}` };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------------------------------
|
|
395
|
+
// The one call site the report scripts use: try to upload + upgrade to a short /r/<slug> link; on ANY
|
|
396
|
+
// failure — including an uploader that REJECTS rather than resolving `{ ok: false }` — keep the
|
|
397
|
+
// caller's already-built URL-hash link and note the fallback on stderr (never throws, never blocks the
|
|
398
|
+
// script from still printing/sending its report).
|
|
399
|
+
// ---------------------------------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
export async function buildReportLink({
|
|
402
|
+
name,
|
|
403
|
+
markdown,
|
|
404
|
+
fallbackUrl,
|
|
405
|
+
date = new Date(),
|
|
406
|
+
bucket = resolveBucket(),
|
|
407
|
+
baseUrl = null,
|
|
408
|
+
env = process.env,
|
|
409
|
+
uploader = uploadReportPayload,
|
|
410
|
+
logError = (msg) => console.error(msg),
|
|
411
|
+
logInfo = (msg) => console.error(msg),
|
|
412
|
+
// Dry-run: never write to the registry. Still computes + logs the slug/link this run WOULD have used
|
|
413
|
+
// (so `--dry-run` output stays informative) but skips calling `uploader` entirely — dry-run must be a
|
|
414
|
+
// real no-op against the live bucket, not just skip Telegram/log persistence.
|
|
415
|
+
dryRun = false,
|
|
416
|
+
} = {}) {
|
|
417
|
+
const slug = slugForArtifact({ name, date, markdown });
|
|
418
|
+
if (!bucket || !baseUrl) {
|
|
419
|
+
return { url: fallbackUrl, slug, usedRegistry: false, reason: 'registry-not-configured' };
|
|
420
|
+
}
|
|
421
|
+
if (dryRun) {
|
|
422
|
+
logInfo(
|
|
423
|
+
`report-registry: dry run — would upload "${name}" as slug ${slug} ` +
|
|
424
|
+
`(${registryUrl({ slug, baseUrl })}); no write performed.`
|
|
425
|
+
);
|
|
426
|
+
return { url: fallbackUrl, slug, usedRegistry: false, reason: 'dry-run', dryRun: true };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let uploadResult;
|
|
430
|
+
try {
|
|
431
|
+
uploadResult = await uploader({ bucket, slug, markdown, env });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
// An uploader that REJECTS (vs. resolving `{ ok: false, reason }`) must degrade exactly the same way
|
|
434
|
+
// — the real uploadReportPayload never rejects (every internal branch is try/caught), but this
|
|
435
|
+
// boundary's contract ("never throws") has to hold for ANY uploader implementation passed in, not
|
|
436
|
+
// just the well-behaved default.
|
|
437
|
+
uploadResult = { ok: false, reason: `uploader-threw: ${(err && err.message) || err}` };
|
|
438
|
+
}
|
|
439
|
+
if (!shouldFallbackToUrlHash(uploadResult)) {
|
|
440
|
+
return { url: registryUrl({ slug, baseUrl }), slug, usedRegistry: true };
|
|
441
|
+
}
|
|
442
|
+
logError(
|
|
443
|
+
`report-registry: upload of "${name}" (slug ${slug}) to gs://${bucket} failed ` +
|
|
444
|
+
`(${uploadResult.reason || 'unknown reason'}) — falling back to the URL-hash link.`
|
|
445
|
+
);
|
|
446
|
+
return { url: fallbackUrl, slug, usedRegistry: false, reason: uploadResult.reason || 'unknown' };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Convenience for the report scripts: mutates each `{ name, markdown, url }` artifact's `url` in place
|
|
450
|
+
// (keeping `url` as the fallback if the upgrade fails) and returns the same array, so a caller can do
|
|
451
|
+
// `artifacts = await upgradeArtifactLinks(artifacts, { dryRun })` right after building them with the
|
|
452
|
+
// existing URL-hash builder (scripts/lib/pmo-templates.mjs's buildDocViewerUrl /
|
|
453
|
+
// scripts/lib/standup-deck.mjs's buildStandupArtifacts) with no other call-site changes. `options` is
|
|
454
|
+
// forwarded to buildReportLink as-is — pass `dryRun: true` to skip every write for this run (see
|
|
455
|
+
// buildReportLink's `dryRun` param above).
|
|
456
|
+
export async function upgradeArtifactLinks(artifacts, options = {}) {
|
|
457
|
+
for (const artifact of artifacts) {
|
|
458
|
+
const result = await buildReportLink({
|
|
459
|
+
name: artifact.name,
|
|
460
|
+
markdown: artifact.markdown,
|
|
461
|
+
fallbackUrl: artifact.url,
|
|
462
|
+
...options,
|
|
463
|
+
});
|
|
464
|
+
artifact.url = result.url;
|
|
465
|
+
artifact.slug = result.slug;
|
|
466
|
+
artifact.usedRegistry = result.usedRegistry;
|
|
467
|
+
}
|
|
468
|
+
return artifacts;
|
|
469
|
+
}
|