voidforge-build 23.11.4 → 23.12.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/dist/.claude/agents/batman-qa.md +1 -0
- package/dist/.claude/agents/galadriel-frontend.md +2 -0
- package/dist/.claude/agents/kusanagi-devops.md +4 -0
- package/dist/.claude/agents/lucius-config.md +6 -0
- package/dist/.claude/agents/silver-surfer-herald.md +11 -4
- package/dist/.claude/commands/architect.md +9 -0
- package/dist/.claude/commands/assemble.md +4 -1
- package/dist/.claude/commands/assess.md +13 -1
- package/dist/.claude/commands/audit-docs.md +106 -0
- package/dist/.claude/commands/deploy.md +28 -0
- package/dist/.claude/commands/engage.md +2 -0
- package/dist/.claude/commands/gauntlet.md +23 -4
- package/dist/.claude/commands/imagine.md +15 -0
- package/dist/.claude/commands/ux.md +32 -0
- package/dist/.claude/commands/void.md +1 -0
- package/dist/CHANGELOG.md +39 -0
- package/dist/CLAUDE.md +8 -0
- package/dist/VERSION.md +2 -1
- package/dist/docs/methods/AI_INTELLIGENCE.md +33 -0
- package/dist/docs/methods/ASSEMBLER.md +31 -2
- package/dist/docs/methods/CAMPAIGN.md +27 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +158 -0
- package/dist/docs/methods/DOC_AUDIT.md +92 -0
- package/dist/docs/methods/FORGE_KEEPER.md +16 -5
- package/dist/docs/methods/GAUNTLET.md +33 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +53 -0
- package/dist/docs/methods/QA_ENGINEER.md +19 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +27 -0
- package/dist/docs/methods/SUB_AGENTS.md +31 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +13 -0
- package/dist/docs/methods/TESTING.md +19 -0
- package/dist/docs/patterns/README.md +3 -0
- package/dist/docs/patterns/ai-eval.ts +63 -0
- package/dist/docs/patterns/daemon-process.ts +90 -0
- package/dist/docs/patterns/deploy-preflight.ts +85 -2
- package/dist/docs/patterns/design-tokens.ts +338 -0
- package/dist/docs/patterns/error-message-categorization.tsx +376 -0
- package/dist/wizard/lib/patterns/daemon-process.d.ts +2 -1
- package/dist/wizard/lib/patterns/daemon-process.js +89 -1
- package/package.json +2 -2
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Error Message Categorization at the UI Boundary
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Categorize the error BEFORE selecting user-facing copy. The copy you show
|
|
6
|
+
* is a function of the error CATEGORY, never of where in the call stack the
|
|
7
|
+
* catch happened. A quota/billing failure must never render "try a different
|
|
8
|
+
* file" messaging just because it surfaced inside the upload flow.
|
|
9
|
+
* - Classification is driven by two signals: the HTTP status code AND the error
|
|
10
|
+
* SHAPE (machine-readable `code`/`type` fields, Retry-After header, well-known
|
|
11
|
+
* message fragments). Status alone is ambiguous (429 = rate limit OR quota),
|
|
12
|
+
* so we inspect the shape to disambiguate.
|
|
13
|
+
* - Every category maps to copy that is honest and actionable: tell the user
|
|
14
|
+
* what actually happened and what they can do about it. Quota → "you've hit
|
|
15
|
+
* your plan limit, upgrade or wait for reset", not "something went wrong".
|
|
16
|
+
* - Unknown/unclassifiable errors fall back to a safe generic category. We
|
|
17
|
+
* never guess a specific category we can't justify from the signals.
|
|
18
|
+
*
|
|
19
|
+
* Agents: Bilbo (copy), Legolas (code), Samwise (a11y), Stark (error shapes)
|
|
20
|
+
*
|
|
21
|
+
* Framework adaptations:
|
|
22
|
+
* Next.js/React: This file (classify() + copy map + hook + component)
|
|
23
|
+
* Vue/Svelte: Same classify() + COPY map; render via the framework's
|
|
24
|
+
* conditional blocks. The categorization logic is framework-agnostic —
|
|
25
|
+
* only the rendering changes.
|
|
26
|
+
* Django/Rails (server-rendered): Run classify() server-side on the caught
|
|
27
|
+
* exception, pass the category + copy into the template context, render the
|
|
28
|
+
* matching block. Same union, same map.
|
|
29
|
+
*
|
|
30
|
+
* Why this pattern exists:
|
|
31
|
+
* (Field report #343 F8: an upload component caught a 402 billing/quota error
|
|
32
|
+
* from the storage backend and rendered "try a different file" because its
|
|
33
|
+
* only error branch was shaped for validation failures. The user re-uploaded
|
|
34
|
+
* the same file five times before contacting support. The copy was selected
|
|
35
|
+
* by call site, not by error category. Categorize first, then choose copy.)
|
|
36
|
+
*
|
|
37
|
+
* === The Anti-Pattern This Replaces ===
|
|
38
|
+
*
|
|
39
|
+
* // WRONG — copy chosen by call site, not by error category
|
|
40
|
+
* try {
|
|
41
|
+
* await uploadFile(file)
|
|
42
|
+
* } catch (err) {
|
|
43
|
+
* // This branch assumes every failure here is the file's fault.
|
|
44
|
+
* setMessage('That file could not be uploaded. Try a different file.')
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* A 402 (quota), 429 (rate limit), 401 (auth expired), or 503 (server down)
|
|
48
|
+
* all hit this branch and all get blamed on the file. Categorize instead.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
'use client'
|
|
52
|
+
|
|
53
|
+
import { useCallback, useState } from 'react'
|
|
54
|
+
|
|
55
|
+
// ── Error category union ─────────────────────────────
|
|
56
|
+
// The exhaustive set of categories the UI knows how to talk about. Keep this
|
|
57
|
+
// closed — adding a category forces you to add copy for it (the COPY map below
|
|
58
|
+
// is keyed by this union, so the compiler flags a missing entry).
|
|
59
|
+
export type ErrorCategory =
|
|
60
|
+
| 'quota' // plan/usage limit reached (billing dimension), e.g. 402 + quota code
|
|
61
|
+
| 'rate-limit' // too many requests in a window; retry after a delay (429)
|
|
62
|
+
| 'timeout' // request took too long / aborted (408, AbortError)
|
|
63
|
+
| 'network' // could not reach the server at all (offline, DNS, CORS, fetch throw)
|
|
64
|
+
| 'validation' // the input was rejected (400/422 with field errors)
|
|
65
|
+
| 'auth' // not authenticated / session expired (401)
|
|
66
|
+
| 'forbidden' // authenticated but not allowed (403)
|
|
67
|
+
| 'not-found' // the target does not exist (404)
|
|
68
|
+
| 'server' // backend fault, not the user's fault (500/502/503/504)
|
|
69
|
+
| 'unknown' // safe fallback — none of the signals matched
|
|
70
|
+
|
|
71
|
+
// ── Normalized error input ───────────────────────────
|
|
72
|
+
// Real catch blocks receive heterogeneous junk: fetch Responses, thrown
|
|
73
|
+
// Errors, parsed JSON error bodies, DOMExceptions. classify() accepts a
|
|
74
|
+
// normalized shape so callers can adapt their transport once at the boundary.
|
|
75
|
+
export interface NormalizedError {
|
|
76
|
+
/** HTTP status code, if the error came from an HTTP response. */
|
|
77
|
+
status?: number
|
|
78
|
+
/**
|
|
79
|
+
* Machine-readable error code from the response body. Backends commonly send
|
|
80
|
+
* `{ code: 'quota_exceeded' }` or `{ type: 'insufficient_quota' }`. We check
|
|
81
|
+
* both `code` and `type` because providers disagree on the field name.
|
|
82
|
+
*/
|
|
83
|
+
code?: string
|
|
84
|
+
/** Human/loggable message — used only as a last-resort signal, never as copy. */
|
|
85
|
+
message?: string
|
|
86
|
+
/** Retry-After value in seconds, if the server sent one (rate limit / quota). */
|
|
87
|
+
retryAfterSeconds?: number
|
|
88
|
+
/** True if this was a fetch/network throw (no response was received at all). */
|
|
89
|
+
isNetworkError?: boolean
|
|
90
|
+
/** True if the request was aborted or timed out client-side. */
|
|
91
|
+
isTimeout?: boolean
|
|
92
|
+
/** Field-level validation errors, if the backend returned them. */
|
|
93
|
+
fieldErrors?: Record<string, string[]>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Codes (from the body) that signal a billing/quota exhaustion regardless of
|
|
97
|
+
// the status code. Different providers use different strings — match on any.
|
|
98
|
+
const QUOTA_CODES = new Set([
|
|
99
|
+
'quota_exceeded',
|
|
100
|
+
'insufficient_quota',
|
|
101
|
+
'plan_limit_reached',
|
|
102
|
+
'billing_quota_exceeded',
|
|
103
|
+
'usage_limit_exceeded',
|
|
104
|
+
'storage_quota_exceeded',
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
const RATE_LIMIT_CODES = new Set([
|
|
108
|
+
'rate_limited',
|
|
109
|
+
'rate_limit_exceeded',
|
|
110
|
+
'too_many_requests',
|
|
111
|
+
'throttled',
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
const AUTH_CODES = new Set([
|
|
115
|
+
'token_expired',
|
|
116
|
+
'invalid_token',
|
|
117
|
+
'session_expired',
|
|
118
|
+
'unauthenticated',
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
// ── classify(): error shape + status -> category ─────
|
|
122
|
+
//
|
|
123
|
+
// Order matters. We check the most specific, least-ambiguous signals first
|
|
124
|
+
// (network/timeout, then body codes), and only fall back to bare status codes
|
|
125
|
+
// when the shape gave us nothing. This is what stops a 402 quota error from
|
|
126
|
+
// being miscategorized as a generic 4xx validation failure.
|
|
127
|
+
export function classify(err: NormalizedError): ErrorCategory {
|
|
128
|
+
// 1. No response at all — we never reached the server. This must come before
|
|
129
|
+
// status checks because a network throw has no status.
|
|
130
|
+
if (err.isNetworkError) return 'network'
|
|
131
|
+
|
|
132
|
+
// 2. Client-side timeout / abort.
|
|
133
|
+
if (err.isTimeout || err.status === 408) return 'timeout'
|
|
134
|
+
|
|
135
|
+
const code = (err.code ?? '').toLowerCase()
|
|
136
|
+
|
|
137
|
+
// 3. Body-code disambiguation — THE critical step (#343 F8). A 402 or even a
|
|
138
|
+
// 429 can be a quota/billing problem; the body code tells us which copy to
|
|
139
|
+
// show. Decide on the code before falling through to status-only logic.
|
|
140
|
+
if (QUOTA_CODES.has(code)) return 'quota'
|
|
141
|
+
if (RATE_LIMIT_CODES.has(code)) return 'rate-limit'
|
|
142
|
+
if (AUTH_CODES.has(code)) return 'auth'
|
|
143
|
+
|
|
144
|
+
// 4. Status-driven categories for the unambiguous cases.
|
|
145
|
+
switch (err.status) {
|
|
146
|
+
case 401:
|
|
147
|
+
return 'auth'
|
|
148
|
+
case 402:
|
|
149
|
+
// Payment Required is, by spec, a billing/quota signal. If we got here the
|
|
150
|
+
// body code didn't say "rate-limit", so treat 402 as quota — NOT as a
|
|
151
|
+
// generic validation error the file-upload branch would have shown.
|
|
152
|
+
return 'quota'
|
|
153
|
+
case 403:
|
|
154
|
+
return 'forbidden'
|
|
155
|
+
case 404:
|
|
156
|
+
return 'not-found'
|
|
157
|
+
case 422:
|
|
158
|
+
return 'validation'
|
|
159
|
+
case 429:
|
|
160
|
+
// 429 with no quota code is a plain rate limit (retry later). A 429 WITH a
|
|
161
|
+
// quota code was already caught in step 3.
|
|
162
|
+
return 'rate-limit'
|
|
163
|
+
case 500:
|
|
164
|
+
case 502:
|
|
165
|
+
case 503:
|
|
166
|
+
case 504:
|
|
167
|
+
return 'server'
|
|
168
|
+
case 400:
|
|
169
|
+
// 400 with field errors is a validation failure; a bare 400 is ambiguous,
|
|
170
|
+
// so only call it validation when the backend actually itemized fields.
|
|
171
|
+
return err.fieldErrors && Object.keys(err.fieldErrors).length > 0
|
|
172
|
+
? 'validation'
|
|
173
|
+
: 'unknown'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 5. Last-resort message sniffing for transports that drop status/code.
|
|
177
|
+
const msg = (err.message ?? '').toLowerCase()
|
|
178
|
+
if (msg.includes('quota') || msg.includes('billing')) return 'quota'
|
|
179
|
+
if (msg.includes('rate limit') || msg.includes('too many requests')) return 'rate-limit'
|
|
180
|
+
if (msg.includes('timeout') || msg.includes('timed out')) return 'timeout'
|
|
181
|
+
if (msg.includes('network') || msg.includes('failed to fetch')) return 'network'
|
|
182
|
+
|
|
183
|
+
// 6. We could not justify a specific category from any signal.
|
|
184
|
+
return 'unknown'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── normalizeError(): transport junk -> NormalizedError ─
|
|
188
|
+
// Reference adapter for the fetch/Response + thrown-Error world. Call this once
|
|
189
|
+
// at your data-access boundary so classify() always receives a clean shape.
|
|
190
|
+
export async function normalizeError(input: unknown): Promise<NormalizedError> {
|
|
191
|
+
// fetch network failures throw a TypeError before any Response exists.
|
|
192
|
+
if (input instanceof TypeError && /fetch/i.test(input.message)) {
|
|
193
|
+
return { isNetworkError: true, message: input.message }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// AbortController / client timeout surfaces as a DOMException.
|
|
197
|
+
if (input instanceof DOMException && input.name === 'AbortError') {
|
|
198
|
+
return { isTimeout: true, message: input.message }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// An HTTP Response that came back non-OK.
|
|
202
|
+
if (input instanceof Response) {
|
|
203
|
+
const retryAfterHeader = input.headers.get('Retry-After')
|
|
204
|
+
const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : undefined
|
|
205
|
+
let body: { code?: string; type?: string; message?: string; errors?: Record<string, string[]> } = {}
|
|
206
|
+
try {
|
|
207
|
+
body = await input.clone().json()
|
|
208
|
+
} catch {
|
|
209
|
+
// Non-JSON body (HTML error page, empty 502, etc.) — status alone drives it.
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
status: input.status,
|
|
213
|
+
code: body.code ?? body.type,
|
|
214
|
+
message: body.message,
|
|
215
|
+
retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
|
|
216
|
+
fieldErrors: body.errors,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A plain thrown Error.
|
|
221
|
+
if (input instanceof Error) {
|
|
222
|
+
return { message: input.message }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { message: String(input) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Copy map: category -> user-facing copy ───────────
|
|
229
|
+
// Keyed by the full ErrorCategory union, so dropping a category is a compile
|
|
230
|
+
// error. `action` is the label for the primary recovery button; `retryable`
|
|
231
|
+
// tells the UI whether to even offer a retry (retrying a 403 is pointless).
|
|
232
|
+
export interface ErrorCopy {
|
|
233
|
+
title: string
|
|
234
|
+
body: string
|
|
235
|
+
action: string
|
|
236
|
+
retryable: boolean
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const COPY: Record<ErrorCategory, ErrorCopy> = {
|
|
240
|
+
quota: {
|
|
241
|
+
title: "You've hit your plan limit",
|
|
242
|
+
body: 'This action would exceed your current plan. Upgrade your plan or wait for your usage to reset — re-trying the same request will not help.',
|
|
243
|
+
action: 'View plans',
|
|
244
|
+
retryable: false,
|
|
245
|
+
},
|
|
246
|
+
'rate-limit': {
|
|
247
|
+
title: 'Slow down a moment',
|
|
248
|
+
body: "You're sending requests faster than we allow. Wait a few seconds and try again.",
|
|
249
|
+
action: 'Try again',
|
|
250
|
+
retryable: true,
|
|
251
|
+
},
|
|
252
|
+
timeout: {
|
|
253
|
+
title: 'That took too long',
|
|
254
|
+
body: 'The request timed out before we got a response. Your connection may be slow — try again.',
|
|
255
|
+
action: 'Try again',
|
|
256
|
+
retryable: true,
|
|
257
|
+
},
|
|
258
|
+
network: {
|
|
259
|
+
title: "Can't reach the server",
|
|
260
|
+
body: "We couldn't connect. Check your internet connection and try again.",
|
|
261
|
+
action: 'Try again',
|
|
262
|
+
retryable: true,
|
|
263
|
+
},
|
|
264
|
+
validation: {
|
|
265
|
+
title: 'Check your input',
|
|
266
|
+
body: 'Some of the information you entered was rejected. Fix the highlighted fields and resubmit.',
|
|
267
|
+
action: 'Review fields',
|
|
268
|
+
retryable: false,
|
|
269
|
+
},
|
|
270
|
+
auth: {
|
|
271
|
+
title: 'Your session expired',
|
|
272
|
+
body: 'You need to sign in again to continue. Re-trying will not work until you do.',
|
|
273
|
+
action: 'Sign in',
|
|
274
|
+
retryable: false,
|
|
275
|
+
},
|
|
276
|
+
forbidden: {
|
|
277
|
+
title: "You don't have access",
|
|
278
|
+
body: "Your account isn't permitted to do this. Contact an administrator if you think that's wrong.",
|
|
279
|
+
action: 'Go back',
|
|
280
|
+
retryable: false,
|
|
281
|
+
},
|
|
282
|
+
'not-found': {
|
|
283
|
+
title: "We couldn't find that",
|
|
284
|
+
body: 'The item you were looking for no longer exists or was moved.',
|
|
285
|
+
action: 'Go back',
|
|
286
|
+
retryable: false,
|
|
287
|
+
},
|
|
288
|
+
server: {
|
|
289
|
+
title: 'Something broke on our end',
|
|
290
|
+
body: "This isn't your fault — our server hit an error. We've been notified. Try again in a moment.",
|
|
291
|
+
action: 'Try again',
|
|
292
|
+
retryable: true,
|
|
293
|
+
},
|
|
294
|
+
unknown: {
|
|
295
|
+
title: 'Something went wrong',
|
|
296
|
+
body: "An unexpected error occurred. If it keeps happening, contact support.",
|
|
297
|
+
action: 'Try again',
|
|
298
|
+
retryable: true,
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Convenience: go straight from a caught value to the copy to display. */
|
|
303
|
+
export async function copyForError(input: unknown): Promise<{ category: ErrorCategory; copy: ErrorCopy }> {
|
|
304
|
+
const category = classify(await normalizeError(input))
|
|
305
|
+
return { category, copy: COPY[category] }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── React usage example ──────────────────────────────
|
|
309
|
+
// A small hook that turns any caught value into a renderable error state, plus
|
|
310
|
+
// the component that renders it. Note the upload handler NEVER hardcodes
|
|
311
|
+
// "try a different file" — the copy is whatever the CATEGORY dictates.
|
|
312
|
+
|
|
313
|
+
interface ErrorState {
|
|
314
|
+
category: ErrorCategory
|
|
315
|
+
copy: ErrorCopy
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function useCategorizedError() {
|
|
319
|
+
const [errorState, setErrorState] = useState<ErrorState | null>(null)
|
|
320
|
+
|
|
321
|
+
const capture = useCallback(async (input: unknown) => {
|
|
322
|
+
setErrorState(await copyForError(input))
|
|
323
|
+
}, [])
|
|
324
|
+
|
|
325
|
+
const clear = useCallback(() => setErrorState(null), [])
|
|
326
|
+
|
|
327
|
+
return { errorState, capture, clear }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function UploadPanel({ uploadFile }: { uploadFile: (file: File) => Promise<void> }) {
|
|
331
|
+
const { errorState, capture, clear } = useCategorizedError()
|
|
332
|
+
const [busy, setBusy] = useState(false)
|
|
333
|
+
|
|
334
|
+
async function handleUpload(file: File) {
|
|
335
|
+
setBusy(true)
|
|
336
|
+
clear()
|
|
337
|
+
try {
|
|
338
|
+
await uploadFile(file)
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// Categorize first. A 402 quota error here renders the quota copy, never
|
|
341
|
+
// the validation "try a different file" copy. (#343 F8)
|
|
342
|
+
await capture(err)
|
|
343
|
+
} finally {
|
|
344
|
+
setBusy(false)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div>
|
|
350
|
+
<input
|
|
351
|
+
type="file"
|
|
352
|
+
disabled={busy}
|
|
353
|
+
aria-label="Choose a file to upload"
|
|
354
|
+
onChange={(e) => {
|
|
355
|
+
const file = e.target.files?.[0]
|
|
356
|
+
if (file) handleUpload(file)
|
|
357
|
+
}}
|
|
358
|
+
/>
|
|
359
|
+
|
|
360
|
+
{errorState && (
|
|
361
|
+
<div role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
|
362
|
+
<p className="font-medium text-red-800">{errorState.copy.title}</p>
|
|
363
|
+
<p className="mt-1 text-sm text-red-600">{errorState.copy.body}</p>
|
|
364
|
+
{errorState.copy.retryable && (
|
|
365
|
+
<button
|
|
366
|
+
onClick={clear}
|
|
367
|
+
className="mt-3 text-sm font-medium text-red-700 underline hover:text-red-800"
|
|
368
|
+
>
|
|
369
|
+
{errorState.copy.action}
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
@@ -84,5 +84,6 @@ declare function createLogger(logPath: string): {
|
|
|
84
84
|
log: (msg: string) => void;
|
|
85
85
|
close: () => void;
|
|
86
86
|
};
|
|
87
|
-
|
|
87
|
+
declare function parseDotenv(contents: string): Record<string, string>;
|
|
88
|
+
export { writePidFile, checkStalePid, removePidFile, generateSessionToken, validateToken, createSocketServer, startSocketServer, writeState, setupSignalHandlers, JobScheduler, createLogger, parseDotenv, PID_FILE, SOCKET_PATH, TOKEN_FILE, STATE_FILE, LOG_FILE, };
|
|
88
89
|
export type { DaemonState, HeartbeatState, ScheduledJob };
|
|
@@ -268,4 +268,92 @@ function createLogger(logPath) {
|
|
|
268
268
|
close() { stream.end(); }
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
|
-
|
|
271
|
+
// ── .env Parsing (literal, $-safe) ────────────────────
|
|
272
|
+
// field report #344 F1: never source secrets via `export $(cat .env)` /
|
|
273
|
+
// `eval "$(cat .env)"`. The shell performs variable expansion and word
|
|
274
|
+
// splitting on the RHS, so a `$`-bearing secret — bcrypt hashes
|
|
275
|
+
// ($2b$...), JWTs, Postgres URLs with `$` in the password, anything with
|
|
276
|
+
// `$VAR`/`${...}`/backticks — gets mangled or silently truncated. Parse
|
|
277
|
+
// literally instead: read each line, split on the FIRST `=` only, and keep
|
|
278
|
+
// the value byte-for-byte (no expansion, no eval). For shells, the
|
|
279
|
+
// equivalent is `while IFS='=' read -r k v; do export "$k=$v"; done < .env`
|
|
280
|
+
// — note IFS='=' and `read -r` (raw, no backslash processing), which never
|
|
281
|
+
// re-expands the value.
|
|
282
|
+
//
|
|
283
|
+
// Prefer a runtime-native loader where available — it sidesteps the shell
|
|
284
|
+
// entirely:
|
|
285
|
+
// - Node 20.6+: `node --env-file=.env daemon.js` (literal parse, no shell).
|
|
286
|
+
// - systemd: `EnvironmentFile=/etc/voidforge/heartbeat.env` (also literal;
|
|
287
|
+
// unit-file `Environment=` lines do NOT undergo shell expansion).
|
|
288
|
+
// Use this helper only when you must parse `.env` in-process.
|
|
289
|
+
function parseDotenv(contents) {
|
|
290
|
+
const out = {};
|
|
291
|
+
for (const rawLine of contents.split('\n')) {
|
|
292
|
+
const line = rawLine.replace(/\r$/, '');
|
|
293
|
+
// Skip blanks and comments. A leading `export ` prefix is tolerated.
|
|
294
|
+
const trimmed = line.trimStart();
|
|
295
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
296
|
+
continue;
|
|
297
|
+
const body = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed;
|
|
298
|
+
// Split on the FIRST `=` only — values may legitimately contain `=`.
|
|
299
|
+
const eq = body.indexOf('=');
|
|
300
|
+
if (eq < 0)
|
|
301
|
+
continue; // not a KEY=VALUE line — ignore, don't guess
|
|
302
|
+
const key = body.slice(0, eq).trim();
|
|
303
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
304
|
+
continue; // invalid env name
|
|
305
|
+
let value = body.slice(eq + 1);
|
|
306
|
+
// Strip a single layer of matching surrounding quotes. Inside quotes the
|
|
307
|
+
// value is taken LITERALLY — no `$` expansion, no eval — which is the
|
|
308
|
+
// whole point: `PASS='p@$$w0rd'` keeps its `$$` intact.
|
|
309
|
+
if (value.length >= 2 &&
|
|
310
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
311
|
+
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
312
|
+
value = value.slice(1, -1);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Unquoted: trim trailing inline whitespace only (POSIX-ish), never
|
|
316
|
+
// touch interior `$` characters.
|
|
317
|
+
value = value.trimEnd();
|
|
318
|
+
}
|
|
319
|
+
out[key] = value;
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
// ── systemd hardening stanza (Node daemons) ───────────
|
|
324
|
+
// field report #344 F3: when running this daemon under systemd, harden the
|
|
325
|
+
// unit — but DO NOT set `MemoryDenyWriteExecute=true` for a Node/V8 process.
|
|
326
|
+
// V8's JIT allocates pages that are written and then executed (it manages its
|
|
327
|
+
// own W^X internally); MDWE forbids any write+exec mapping, so the daemon
|
|
328
|
+
// takes a SIGTRAP and dies at boot, usually before it logs a single line. The
|
|
329
|
+
// safe, high-value sandbox flags below give most of MDWE's benefit without the
|
|
330
|
+
// JIT collision:
|
|
331
|
+
//
|
|
332
|
+
// [Unit]
|
|
333
|
+
// Description=VoidForge Heartbeat daemon
|
|
334
|
+
// After=network-online.target
|
|
335
|
+
// Wants=network-online.target
|
|
336
|
+
//
|
|
337
|
+
// [Service]
|
|
338
|
+
// Type=simple
|
|
339
|
+
// ExecStart=/usr/bin/node /opt/voidforge/daemon.js
|
|
340
|
+
// EnvironmentFile=/etc/voidforge/heartbeat.env # literal parse — see #344 F1
|
|
341
|
+
// Restart=on-failure
|
|
342
|
+
// RestartSec=5
|
|
343
|
+
//
|
|
344
|
+
// # Hardening — keep these:
|
|
345
|
+
// NoNewPrivileges=true # no setuid/setgid privilege escalation
|
|
346
|
+
// ProtectSystem=full # /usr, /boot, /etc mounted read-only
|
|
347
|
+
// ProtectHome=true # /home, /root, /run/user hidden
|
|
348
|
+
// PrivateTmp=true # private /tmp + /var/tmp namespace
|
|
349
|
+
// # MemoryDenyWriteExecute=true # <-- OMITTED ON PURPOSE: breaks V8 JIT
|
|
350
|
+
// # (SIGTRAP at boot). Re-enable ONLY for
|
|
351
|
+
// # Go/Rust/static daemons with no JIT.
|
|
352
|
+
//
|
|
353
|
+
// [Install]
|
|
354
|
+
// WantedBy=multi-user.target
|
|
355
|
+
//
|
|
356
|
+
// Go, Rust, and other AOT-compiled daemons emit no executable pages at
|
|
357
|
+
// runtime, so for THEM you can and should keep `MemoryDenyWriteExecute=true`.
|
|
358
|
+
// The omission above is V8-specific, not a general weakening.
|
|
359
|
+
export { writePidFile, checkStalePid, removePidFile, generateSessionToken, validateToken, createSocketServer, startSocketServer, writeState, setupSignalHandlers, JobScheduler, createLogger, parseDotenv, PID_FILE, SOCKET_PATH, TOKEN_FILE, STATE_FILE, LOG_FILE, };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voidforge-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.12.0",
|
|
4
4
|
"description": "From nothing, everything. A methodology framework for building with Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@aws-sdk/client-rds": "^3.700.0",
|
|
46
46
|
"@aws-sdk/client-s3": "^3.700.0",
|
|
47
47
|
"@aws-sdk/client-sts": "^3.700.0",
|
|
48
|
-
"voidforge-build-methodology": "^23.
|
|
48
|
+
"voidforge-build-methodology": "^23.12.0",
|
|
49
49
|
"node-pty": "^1.2.0-beta.12",
|
|
50
50
|
"ws": "^8.19.0"
|
|
51
51
|
},
|