@fabrika/builder 0.2.0 → 0.3.2

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/BUILD-CONTRACT.md +8 -0
  2. package/README.md +9 -0
  3. package/cli/src/bin.ts +35 -2
  4. package/cli/src/build-status.ts +7 -15
  5. package/cli/src/commands/advance.ts +3 -2
  6. package/cli/src/commands/ensure-daemon.ts +3 -3
  7. package/cli/src/commands/gate.ts +4 -10
  8. package/cli/src/commands/init.ts +44 -7
  9. package/cli/src/commands/next.ts +3 -3
  10. package/cli/src/commands/platform.ts +81 -0
  11. package/cli/src/config.ts +4 -11
  12. package/cli/src/context.ts +4 -1
  13. package/cli/src/daemon-client.ts +36 -185
  14. package/cli/src/daemon-lifecycle.ts +63 -142
  15. package/cli/src/derive.ts +4 -8
  16. package/cli/src/errors.ts +1 -0
  17. package/cli/src/index.ts +2 -3
  18. package/cli/src/inject-skills.ts +8 -31
  19. package/cli/src/layout.ts +8 -18
  20. package/cli/src/packet.ts +18 -17
  21. package/cli/src/platform-client.ts +40 -0
  22. package/daemon/src/admission.ts +21 -0
  23. package/daemon/src/bin.ts +12 -14
  24. package/daemon/src/handlers.ts +84 -81
  25. package/daemon/src/index.ts +16 -6
  26. package/daemon/src/paths.ts +5 -6
  27. package/daemon/src/phases.ts +27 -126
  28. package/daemon/src/platform-compiler.ts +21 -0
  29. package/daemon/src/platform-remote.ts +146 -0
  30. package/daemon/src/platform-routes.ts +46 -0
  31. package/daemon/src/platform-source.ts +147 -0
  32. package/daemon/src/platform-storage.ts +222 -0
  33. package/daemon/src/platform.ts +481 -0
  34. package/decisions/README.md +4 -0
  35. package/decisions/patterns/zerops-control-deploy.md +49 -0
  36. package/decisions/patterns/zerops-local-app.md +57 -0
  37. package/decisions/patterns/zerops-postgres-seeds.md +37 -0
  38. package/decisions/technologies/zerops-fabrika-auth.md +53 -0
  39. package/decisions/technologies/zerops-platform.md +64 -0
  40. package/package.json +13 -5
  41. package/shared/index.ts +365 -0
  42. package/{cli/src → shared}/phases.ts +42 -50
  43. package/shared/urls.ts +48 -0
  44. package/skill/SKILL.md +34 -0
  45. package/stacks/README.md +2 -0
  46. package/stacks/zerops-app.md +66 -0
  47. package/ui/dist/assets/client.css +1 -1
  48. package/ui/dist/assets/client.krrpk10y.js +214 -0
  49. package/ui/dist/assets/components.js +57 -57
  50. package/ui/dist/index.html +1 -1
  51. package/ui/dist/assets/client.fhjppkvr.js +0 -214
@@ -1,138 +1,25 @@
1
- import { resolve } from 'path'
2
- import { BASE_URL, SESSIONS_DIR, TIMEOUT_MS, WS_URL } from './config'
1
+ import { waitForEvent } from '@fabrika/daemon-kit'
2
+ import {
3
+ consumedFeedbackSchema,
4
+ feedbackMetaSchema,
5
+ feedbackTextSchema,
6
+ phaseStateSchema,
7
+ planResultSchema,
8
+ submitFeedbackSchema,
9
+ } from '../../shared'
10
+ import { BASE_URL, TIMEOUT_MS, WS_URL } from './config'
3
11
  import type { PhaseState } from './derive'
4
- import { PHASES } from './phases'
5
12
 
6
13
  const HEALTH_TIMEOUT_MS = 1000
7
14
 
8
- export interface HealthInfo {
9
- ok: boolean
10
- version?: string
11
- pid?: number
12
- dataDir?: string
13
- sessionsDir?: string
14
- sessions?: number | string[]
15
- }
16
-
17
- function isRecord(value: unknown): value is Record<string, unknown> {
18
- return typeof value === 'object' && value !== null && !Array.isArray(value)
19
- }
20
-
21
- function optionalString(value: unknown): string | undefined {
22
- return typeof value === 'string' ? value : undefined
23
- }
24
-
25
- function optionalNumber(value: unknown): number | undefined {
26
- return typeof value === 'number' ? value : undefined
27
- }
28
-
29
- function optionalBoolean(value: unknown): boolean | undefined {
30
- return typeof value === 'boolean' ? value : undefined
31
- }
32
-
33
- function stringArray(value: unknown): string[] | null {
34
- if (!Array.isArray(value) || !value.every((item: unknown) => typeof item === 'string')) return null
35
- return value
36
- }
37
-
38
- function parseHealthInfo(value: unknown): HealthInfo | null {
39
- if (!isRecord(value) || value['ok'] !== true) return null
40
- const sessions = typeof value['sessions'] === 'number' ? value['sessions'] : stringArray(value['sessions']) ?? undefined
41
- return {
42
- ok: true,
43
- ...(optionalString(value['version']) === undefined ? {} : { version: optionalString(value['version']) }),
44
- ...(optionalNumber(value['pid']) === undefined ? {} : { pid: optionalNumber(value['pid']) }),
45
- ...(optionalString(value['dataDir']) === undefined ? {} : { dataDir: optionalString(value['dataDir']) }),
46
- ...(optionalString(value['sessionsDir']) === undefined ? {} : { sessionsDir: optionalString(value['sessionsDir']) }),
47
- ...(sessions === undefined ? {} : { sessions }),
48
- }
49
- }
50
-
51
- function isPhaseId(value: unknown): value is PhaseState['current'] {
52
- return typeof value === 'string' && PHASES.some((phase) => phase.id === value)
53
- }
54
-
55
- function parsePhaseState(value: unknown): PhaseState | null {
56
- if (!isRecord(value) || !isPhaseId(value['current']) || typeof value['index'] !== 'number') return null
57
- const rawCompleted = value['completed']
58
- const completed = Array.isArray(rawCompleted) ? rawCompleted.filter(isPhaseId) : null
59
- const reads = stringArray(value['reads'])
60
- const produces = stringArray(value['produces'])
61
- if (completed === null || !Array.isArray(rawCompleted) || completed.length !== rawCompleted.length || reads === null || produces === null) {
62
- return null
63
- }
64
- if (typeof value['gate'] !== 'boolean' || typeof value['satisfied'] !== 'boolean') return null
65
- return {
66
- current: value['current'],
67
- index: value['index'],
68
- completed,
69
- reads,
70
- produces,
71
- gate: value['gate'],
72
- satisfied: value['satisfied'],
73
- }
74
- }
75
-
76
- function stringRecord(value: unknown): Record<string, string> | undefined {
77
- if (!isRecord(value)) return undefined
78
- const result: Record<string, string> = {}
79
- for (const [key, entry] of Object.entries(value)) {
80
- if (typeof entry !== 'string') return undefined
81
- result[key] = entry
82
- }
83
- return result
84
- }
85
-
86
- function parsePlanResult(value: unknown): PlanResult {
87
- if (!isRecord(value) || typeof value['ok'] !== 'boolean') return { ok: false, error: 'Invalid daemon response' }
88
- return {
89
- ok: value['ok'],
90
- ...(optionalString(value['browserUrl']) === undefined ? {} : { browserUrl: optionalString(value['browserUrl']) }),
91
- ...(optionalNumber(value['revision']) === undefined ? {} : { revision: optionalNumber(value['revision']) }),
92
- ...(optionalBoolean(value['isNew']) === undefined ? {} : { isNew: optionalBoolean(value['isNew']) }),
93
- ...(stringArray(value['canvasFiles']) === null ? {} : { canvasFiles: stringArray(value['canvasFiles']) ?? [] }),
94
- ...(optionalString(value['gate']) === undefined ? {} : { gate: optionalString(value['gate']) }),
95
- ...(optionalString(value['error']) === undefined ? {} : { error: optionalString(value['error']) }),
96
- ...(stringRecord(value['errors']) === undefined ? {} : { errors: stringRecord(value['errors']) }),
97
- }
98
- }
99
-
100
- export async function health(): Promise<HealthInfo | null> {
101
- try {
102
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
103
- const data: unknown = await res.json()
104
- return parseHealthInfo(data)
105
- } catch {
106
- return null
107
- }
108
- }
109
-
110
- export async function isDaemonRunning(): Promise<boolean> {
111
- const info = await health()
112
- return info !== null && healthMatchesDataRoot(info)
113
- }
114
-
115
- export function healthMatchesDataRoot(
116
- info: HealthInfo,
117
- expectedSessionsDir: string = SESSIONS_DIR,
118
- ): boolean {
119
- if (!info.sessionsDir) return false
120
- return comparablePath(info.sessionsDir) === comparablePath(expectedSessionsDir)
121
- }
122
-
123
- function comparablePath(path: string): string {
124
- const absolute = resolve(path)
125
- return process.platform === 'win32' ? absolute.toLowerCase() : absolute
126
- }
127
-
128
15
  export async function fetchPhase(fabrikaDir: string, sessionId?: string): Promise<PhaseState | null> {
129
16
  try {
130
17
  let url = `${BASE_URL}/api/phase?root=${encodeURIComponent(fabrikaDir)}`
131
18
  if (sessionId) url += `&session=${encodeURIComponent(sessionId)}`
132
19
  const res = await fetch(url, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
133
20
  if (!res.ok) return null
134
- const data: unknown = await res.json()
135
- return parsePhaseState(data)
21
+ const result = phaseStateSchema.safeParse(await res.json())
22
+ return result.success ? result.data : null
136
23
  } catch {
137
24
  return null
138
25
  }
@@ -163,8 +50,8 @@ export async function pushPlan(
163
50
  headers: { 'Content-Type': 'application/json' },
164
51
  body: JSON.stringify(body),
165
52
  })
166
- const data = parsePlanResult(await res.json())
167
- return { ...data, reachable: true }
53
+ const result = planResultSchema.safeParse(await res.json())
54
+ return { ...(result.success ? result.data : { ok: false, error: 'Invalid daemon response' }), reachable: true }
168
55
  } catch (e) {
169
56
  return { ok: false, reachable: false, error: String(e) }
170
57
  }
@@ -172,23 +59,20 @@ export async function pushPlan(
172
59
 
173
60
  export async function peekFeedback(sessionId: string): Promise<string | null> {
174
61
  try {
175
- const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`, {
176
- signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
177
- })
62
+ const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
178
63
  if (!metaRes.ok) return null
179
- const meta: unknown = await metaRes.json()
180
- if (!isRecord(meta) || !Array.isArray(meta['revisions'])) return null
181
- const revisions = meta['revisions']
64
+ const meta = feedbackMetaSchema.safeParse(await metaRes.json())
65
+ if (!meta.success) return null
66
+ const revisions = meta.data.revisions
182
67
  for (let i = revisions.length - 1; i >= 0; i--) {
183
- const revision = revisions[i]
184
- if (!isRecord(revision) || typeof revision['revision'] !== 'number') continue
185
- if (revision['hasFeedback'] === true && revision['feedbackConsumed'] !== true) {
186
- const fbRes = await fetch(`${BASE_URL}/api/session/${sessionId}/revision/${revision['revision']}/feedback`, {
68
+ const revision = revisions[i]!
69
+ if (revision.hasFeedback && !revision.feedbackConsumed) {
70
+ const fbRes = await fetch(`${BASE_URL}/api/session/${sessionId}/revision/${revision.revision}/feedback`, {
187
71
  signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
188
72
  })
189
73
  if (!fbRes.ok) return null
190
- const data: unknown = await fbRes.json()
191
- return isRecord(data) && typeof data['feedback'] === 'string' ? data['feedback'] : ''
74
+ const result = feedbackTextSchema.safeParse(await fbRes.json())
75
+ return result.success ? result.data.feedback : ''
192
76
  }
193
77
  }
194
78
  return null
@@ -203,9 +87,8 @@ export async function consumeFeedback(sessionId: string): Promise<string | null>
203
87
  method: 'POST',
204
88
  signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
205
89
  })
206
- const data: unknown = await res.json()
207
- if (!isRecord(data) || data['found'] !== true) return null
208
- return typeof data['feedback'] === 'string' ? data['feedback'] : ''
90
+ const result = consumedFeedbackSchema.safeParse(await res.json())
91
+ return result.success ? result.data.feedback : null
209
92
  } catch {
210
93
  return null
211
94
  }
@@ -233,48 +116,16 @@ export async function postBuildEvent(event: BuildEvent): Promise<boolean> {
233
116
  }
234
117
 
235
118
  export function waitForFeedback(sessionId: string, timeoutMs: number = TIMEOUT_MS): Promise<string> {
236
- return new Promise((resolveP, rejectP) => {
237
- let settled = false
238
- const ws = new WebSocket(`${WS_URL}/ws/wait/${sessionId}`)
239
-
240
- const timer = setTimeout(() => {
241
- if (settled) return
242
- settled = true
243
- try {
244
- ws.close()
245
- } catch {}
246
- rejectP(new Error('timeout'))
247
- }, timeoutMs)
248
-
249
- ws.onmessage = (event: MessageEvent) => {
250
- try {
251
- const raw = typeof event.data === 'string' ? event.data : ''
252
- const data: unknown = JSON.parse(raw)
253
- if (isRecord(data) && data['type'] === 'submit') {
254
- if (settled) return
255
- settled = true
256
- clearTimeout(timer)
257
- try {
258
- ws.close()
259
- } catch {}
260
- resolveP(typeof data['feedback'] === 'string' ? data['feedback'] : '')
261
- }
262
- } catch {
263
- }
264
- }
265
-
266
- ws.onerror = () => {
267
- if (settled) return
268
- settled = true
269
- clearTimeout(timer)
270
- rejectP(new Error('connection-failed'))
271
- }
272
-
273
- ws.onclose = () => {
274
- if (settled) return
275
- settled = true
276
- clearTimeout(timer)
277
- rejectP(new Error('closed'))
278
- }
119
+ return waitForEvent({
120
+ url: `${WS_URL}/ws/wait/${sessionId}`,
121
+ timeoutMs,
122
+ onFrame: (frame) => {
123
+ const result = submitFeedbackSchema.safeParse(frame)
124
+ return result.success ? { type: 'settle', value: result.data.feedback } : undefined
125
+ },
126
+ probe: async () => {
127
+ const feedback = await peekFeedback(sessionId)
128
+ return feedback === null ? undefined : { value: feedback }
129
+ },
279
130
  })
280
131
  }
@@ -1,171 +1,92 @@
1
- import { spawn } from 'child_process'
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'fs'
3
- import { dirname, join, resolve } from 'path'
4
- import { fileURLToPath } from 'url'
5
- import { DATA_DIR, PID_FILE, SESSIONS_DIR, TEMP_DIR } from './config'
6
- import { health, type HealthInfo, healthMatchesDataRoot, isDaemonRunning } from './daemon-client'
7
-
8
- const HERE = dirname(fileURLToPath(import.meta.url))
9
-
10
- export function resolveDaemonServer(): string | null {
11
- const candidates = [
12
- resolve(HERE, '../../daemon/src/bin.ts'),
13
- ]
14
- for (const c of candidates) {
15
- if (existsSync(c)) return c
16
- }
17
- return null
1
+ import { createDaemonLifecycle, type DaemonLifecycleConfig, DaemonStartError } from '@fabrika/daemon-kit'
2
+ import { spawn } from 'node:child_process'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import type { z } from 'zod'
6
+ import { healthInfoSchema } from '../../shared'
7
+ import { BASE_URL, DATA_DIR, PID_FILE, SESSIONS_DIR, TEMP_DIR } from './config'
8
+
9
+ export type DaemonHealthInfo = z.output<typeof healthInfoSchema> & { pid: number }
10
+
11
+ function parseDaemonHealth(body: unknown): DaemonHealthInfo | undefined {
12
+ const result = healthInfoSchema.safeParse(body)
13
+ if (!result.success) return undefined
14
+ const { pid } = result.data
15
+ return pid !== undefined && Number.isInteger(pid) ? { ...result.data, pid } : undefined
18
16
  }
19
17
 
20
- export class DaemonUnavailableError extends Error {
21
- constructor(message: string) {
22
- super(message)
23
- this.name = 'DaemonUnavailableError'
24
- }
18
+ function comparablePath(path: string): string {
19
+ const absolute = resolve(path)
20
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
25
21
  }
26
22
 
27
- export async function startDaemon(): Promise<void> {
28
- const server = resolveDaemonServer()
29
- if (!server) {
30
- throw new DaemonUnavailableError(
31
- 'fabrika builder daemon not found. '
32
- + 'Build/start it manually, or run the CLI in offline mode.',
33
- )
34
- }
35
-
36
- mkdirSync(TEMP_DIR, { recursive: true })
37
- const startupLog = join(TEMP_DIR, 'daemon-startup.log')
38
- const fd = openSync(startupLog, 'w')
39
-
40
- const child = spawn('bun', ['run', server], {
41
- detached: true,
42
- stdio: ['ignore', 'ignore', fd],
43
- cwd: dirname(dirname(server)),
44
- env: daemonSpawnEnvironment(),
45
- })
46
- child.unref()
47
- closeSync(fd)
48
-
49
- for (let i = 0; i < 30; i++) {
50
- await new Promise((r) => setTimeout(r, 100))
51
- if (await isDaemonRunning()) return
52
- }
53
-
54
- let detail = ''
55
- if (existsSync(startupLog)) {
56
- const log = readFileSync(startupLog, 'utf-8').trim()
57
- if (log) detail = '\nDaemon stderr:\n' + log
58
- }
59
- throw new DaemonUnavailableError('Daemon failed to start within 3 seconds.' + detail)
60
- }
61
-
62
- export function daemonSpawnEnvironment(
63
- base: NodeJS.ProcessEnv = process.env,
64
- dataDir: string = DATA_DIR,
65
- ): NodeJS.ProcessEnv {
66
- return { ...base, FABRIKA_DATA_DIR: resolve(dataDir) }
23
+ export function healthMatchesDataRoot(info: { sessionsDir: string }, expectedSessionsDir: string = SESSIONS_DIR): boolean {
24
+ return comparablePath(info.sessionsDir) === comparablePath(expectedSessionsDir)
67
25
  }
68
26
 
69
- export async function ensureDaemon(opts: { quiet?: boolean } = {}): Promise<boolean> {
70
- const active = await health()
71
- if (active && healthMatchesDataRoot(active)) return true
72
-
73
- if (active) {
74
- const stopped = await stopMismatchedManagedDaemon(active, opts.quiet ?? false)
75
- if (!stopped) return false
76
- }
77
-
78
- if (existsSync(PID_FILE)) {
79
- const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
80
- if (!isAlive(pid)) {
81
- try {
82
- unlinkSync(PID_FILE)
83
- } catch {}
84
- }
85
- }
86
-
87
- try {
88
- await startDaemon()
89
- return true
90
- } catch (error: unknown) {
91
- if (!opts.quiet) {
92
- console.error(`[fabrika] ${error instanceof Error ? error.message : String(error)}`)
93
- }
94
- return false
95
- }
27
+ const DAEMON_BIN = fileURLToPath(new URL('../../daemon/src/bin.ts', import.meta.url))
28
+
29
+ export const daemonLifecycleConfig: DaemonLifecycleConfig<DaemonHealthInfo> = {
30
+ baseUrl: BASE_URL,
31
+ parseHealth: parseDaemonHealth,
32
+ pidFile: PID_FILE,
33
+ command: [process.execPath, DAEMON_BIN, '--data-dir', DATA_DIR],
34
+ // Never the caller's project: its bunfig.toml preloads must not load into the daemon.
35
+ cwd: dirname(dirname(DAEMON_BIN)),
36
+ logFile: join(TEMP_DIR, 'daemon.log'),
37
+ // A daemon serving another data root is restarted, like one of another version.
38
+ versionCheck: {
39
+ expected: comparablePath(SESSIONS_DIR),
40
+ of: (health) => comparablePath(health.sessionsDir),
41
+ },
96
42
  }
97
43
 
98
- async function stopMismatchedManagedDaemon(info: HealthInfo, quiet: boolean): Promise<boolean> {
99
- const actual = info.sessionsDir ?? '<not reported by legacy daemon>'
100
- const mismatch = `daemon data-root mismatch: active ${actual}, requested ${SESSIONS_DIR}`
101
- if (!existsSync(PID_FILE)) {
102
- if (!quiet) console.error(`[fabrika] ${mismatch}; no managed PID file, refusing to stop it.`)
103
- return false
104
- }
44
+ export const daemon = createDaemonLifecycle(daemonLifecycleConfig)
105
45
 
106
- const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
107
- if (!isAlive(pid) || info.pid === undefined || info.pid !== pid) {
108
- if (!quiet) console.error(`[fabrika] ${mismatch}; daemon PID ownership could not be verified.`)
109
- return false
46
+ export async function ensureDaemon(opts: { quiet?: boolean } = {}): Promise<boolean> {
47
+ const report = (message: string) => {
48
+ if (!opts.quiet) console.error(`[fabrika] ${message}`)
110
49
  }
111
-
112
- if (!quiet) console.error(`[fabrika] ${mismatch}; restarting managed daemon ${pid}.`)
50
+ const lifecycle = createDaemonLifecycle({
51
+ ...daemonLifecycleConfig,
52
+ onEvent: (event) => {
53
+ if (event.type === 'version-mismatch') {
54
+ report(`daemon data-root mismatch: active ${event.running}, requested ${event.expected}; restarting the daemon.`)
55
+ }
56
+ },
57
+ })
113
58
  try {
114
- process.kill(pid, 'SIGTERM')
115
- } catch {
59
+ const health = await lifecycle.ensure()
60
+ if (healthMatchesDataRoot(health)) return true
61
+ report(`daemon data-root mismatch: active ${health.sessionsDir}, requested ${SESSIONS_DIR}`)
116
62
  return false
117
- }
118
-
119
- for (let attempt = 0; attempt < 30; attempt++) {
120
- await new Promise((resolveP) => setTimeout(resolveP, 100))
121
- if ((await health()) === null) {
122
- try {
123
- unlinkSync(PID_FILE)
124
- } catch {}
125
- return true
63
+ } catch (error: unknown) {
64
+ if (error instanceof DaemonStartError) {
65
+ report(error.logTail ? `${error.message}\nDaemon log (${error.logFile}):\n${error.logTail}` : error.message)
66
+ } else {
67
+ report(error instanceof Error ? error.message : String(error))
126
68
  }
127
- }
128
- if (!quiet) console.error(`[fabrika] mismatched daemon did not stop within 3 seconds.`)
129
- return false
130
- }
131
-
132
- export function stopDaemon(): boolean {
133
- if (!existsSync(PID_FILE)) return false
134
- const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
135
- try {
136
- process.kill(pid, 'SIGTERM')
137
- try {
138
- unlinkSync(PID_FILE)
139
- } catch {}
140
- return true
141
- } catch {
142
- try {
143
- unlinkSync(PID_FILE)
144
- } catch {}
145
69
  return false
146
70
  }
147
71
  }
148
72
 
149
- function isAlive(pid: number): boolean {
150
- if (!Number.isFinite(pid) || pid <= 0) return false
151
- try {
152
- process.kill(pid, 0)
153
- return true
154
- } catch {
155
- return false
156
- }
73
+ export async function isDaemonRunning(): Promise<boolean> {
74
+ const health = await daemon.health()
75
+ return health.status === 'up' && healthMatchesDataRoot(health)
157
76
  }
158
77
 
159
- export function openBrowser(url: string): void {
78
+ export function openBrowser(url: string, fallbackUrl: string = url): void {
160
79
  const candidates = process.platform === 'darwin'
161
80
  ? ['open']
162
81
  : ['xdg-open', 'wslview', 'sensible-browser']
163
82
  for (const cmd of candidates) {
164
83
  const which = Bun.spawnSync(['which', cmd])
165
84
  if (which.exitCode === 0) {
166
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref()
85
+ const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' })
86
+ child.on('error', () => console.error(`Open this URL in your browser: ${fallbackUrl}`))
87
+ child.unref()
167
88
  return
168
89
  }
169
90
  }
170
- console.error(`Open this URL in your browser: ${url}`)
91
+ console.error(`Open this URL in your browser: ${fallbackUrl}`)
171
92
  }
package/cli/src/derive.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from 'fs'
2
2
  import { basename, dirname, join, resolve } from 'path'
3
- import { type Phase, type PhaseId, PHASES } from './phases'
3
+ import { isPhaseHint, type Phase, type PhaseId, phaseIndex, PHASES } from '../../shared/phases'
4
4
 
5
5
  export interface PhaseState {
6
6
  current: PhaseId
@@ -51,7 +51,7 @@ export function isNonEmptyFile(p: string): boolean {
51
51
 
52
52
  export function producesSatisfied(repoRoot: string, phase: Phase): boolean {
53
53
  for (const glob of phase.produces) {
54
- if (isPlaceholder(glob)) return false
54
+ if (isPhaseHint(glob)) return false
55
55
  const matches = matchGlob(repoRoot, glob).filter(isNonEmptyFile)
56
56
  if (matches.length === 0) return false
57
57
  }
@@ -61,7 +61,7 @@ export function producesSatisfied(repoRoot: string, phase: Phase): boolean {
61
61
  export function resolveReads(repoRoot: string, phase: Phase): string[] {
62
62
  const out: string[] = []
63
63
  for (const glob of phase.reads) {
64
- if (isPlaceholder(glob)) {
64
+ if (isPhaseHint(glob)) {
65
65
  out.push(glob) // keep the human hint, e.g. "<init brief>"
66
66
  continue
67
67
  }
@@ -73,10 +73,6 @@ export function resolveReads(repoRoot: string, phase: Phase): string[] {
73
73
  return out
74
74
  }
75
75
 
76
- function isPlaceholder(glob: string): boolean {
77
- return glob.startsWith('<')
78
- }
79
-
80
76
  export function derivePhase(repoRoot: string, gateProbe: GateProbe = offlineGateProbe): PhaseState {
81
77
  const completed: PhaseId[] = []
82
78
 
@@ -99,7 +95,7 @@ export function derivePhase(repoRoot: string, gateProbe: GateProbe = offlineGate
99
95
  function toState(repoRoot: string, phase: Phase, completed: PhaseId[], satisfied: boolean): PhaseState {
100
96
  return {
101
97
  current: phase.id,
102
- index: PHASES.findIndex((p) => p.id === phase.id),
98
+ index: phaseIndex(phase.id),
103
99
  completed,
104
100
  reads: resolveReads(repoRoot, phase),
105
101
  produces: phase.produces,
@@ -0,0 +1 @@
1
+ export class BuilderCliError extends Error {}
package/cli/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { type Phase, PHASE_COUNT, PHASE_IDS, phaseById, type PhaseId, phaseIndex, PHASES } from '../../shared/phases'
1
2
  export { resolveCatalogRoot, resolveStackPointer, type StackPointer } from './catalog'
2
3
  export {
3
4
  BASE_URL,
@@ -6,7 +7,6 @@ export {
6
7
  FABRIKA_DIRNAME,
7
8
  getSessionId,
8
9
  hashRepoPath,
9
- LEGACY_DATA_DIR,
10
10
  NEUTRAL_DATA_DIR,
11
11
  resolveDataDir,
12
12
  TEMP_DIR,
@@ -34,5 +34,4 @@ export {
34
34
  readConfig,
35
35
  scaffold,
36
36
  } from './layout'
37
- export { type PacketJson, packetJson, PHASE_IDS, type RenderOpts, renderPacket } from './packet'
38
- export { type Phase, PHASE_COUNT, phaseById, type PhaseId, phaseIndex, PHASES } from './phases'
37
+ export { type PacketJson, packetJson, type RenderOpts, renderPacket } from './packet'
@@ -2,6 +2,7 @@ import { createHash } from 'crypto'
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
3
3
  import { dirname, join, relative, resolve } from 'path'
4
4
  import { fileURLToPath } from 'url'
5
+ import { z } from 'zod'
5
6
 
6
7
  const HERE = dirname(fileURLToPath(import.meta.url))
7
8
  const MANIFEST_VERSION = 1
@@ -13,9 +14,8 @@ export interface InjectOptions {
13
14
  hosts?: AgentHost[]
14
15
  }
15
16
 
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return typeof value === 'object' && value !== null && !Array.isArray(value)
18
- }
17
+ const managedFileSchema = z.object({ installedHash: z.string(), host: z.enum(['claude', 'codex']) })
18
+ const managedManifestSchema = z.object({ files: z.record(managedFileSchema.nullable().catch(null)) })
19
19
 
20
20
  function isAgentHost(value: string): value is AgentHost {
21
21
  return value === 'claude' || value === 'codex'
@@ -49,21 +49,6 @@ interface AgentSource {
49
49
  raw: string
50
50
  }
51
51
 
52
- const LEGACY_MANAGED_HASHES: Record<string, Set<string>> = {
53
- [join('.claude', 'skills', 'fabrika', 'SKILL.md')]: new Set([
54
- 'd06c8508d47ab36222bcb33b964fd928e3fbcff383db8c257ef68dfd956dd428',
55
- ]),
56
- [join('.claude', 'agents', 'build-orchestrator.md')]: new Set([
57
- '31c436e08ffac217d32c71ec56d1612f565b0b2828df9c067ff1e812768339f1',
58
- ]),
59
- [join('.claude', 'agents', 'flows-author.md')]: new Set([
60
- '1e55cce06f0210b65ce6ec2d9cd1d4bb5e0e8bcff11e06eb4f2ee405914dd322',
61
- ]),
62
- [join('.claude', 'agents', 'opice-skeleton-author.md')]: new Set([
63
- '8644461c1acdc236a886b648ae97984cbd54bc0d59872f7b310ce2b5783e4378',
64
- ]),
65
- }
66
-
67
52
  export function resolveSkillSource(): string | null {
68
53
  const candidates = [resolve(HERE, '../../skill')]
69
54
  for (const candidate of candidates) {
@@ -256,10 +241,7 @@ function installManagedFile(
256
241
  result.written.push(destination)
257
242
  } else if (existingHash === desiredHash) {
258
243
  result.skipped.push(destination)
259
- } else if (
260
- existingHash === recorded?.installedHash
261
- || LEGACY_MANAGED_HASHES[desired.relativePath]?.has(existingHash ?? '')
262
- ) {
244
+ } else if (existingHash === recorded?.installedHash) {
263
245
  writeAtomic(destination, desired.content)
264
246
  result.updated.push(destination)
265
247
  } else {
@@ -273,16 +255,11 @@ function installManagedFile(
273
255
  function readManifest(path: string): ManagedManifest {
274
256
  if (!existsSync(path)) return { version: MANIFEST_VERSION, files: {} }
275
257
  try {
276
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
277
- if (!isRecord(parsed) || !isRecord(parsed['files'])) return { version: MANIFEST_VERSION, files: {} }
258
+ const parsed = managedManifestSchema.safeParse(JSON.parse(readFileSync(path, 'utf-8')))
259
+ if (!parsed.success) return { version: MANIFEST_VERSION, files: {} }
278
260
  const files: ManagedManifest['files'] = {}
279
- for (const [relativePath, candidate] of Object.entries(parsed['files'])) {
280
- if (!isRecord(candidate)) continue
281
- const installedHash = candidate['installedHash']
282
- const host = candidate['host']
283
- if (typeof installedHash === 'string' && typeof host === 'string' && isAgentHost(host)) {
284
- files[relativePath] = { installedHash, host }
285
- }
261
+ for (const [relativePath, candidate] of Object.entries(parsed.data.files)) {
262
+ if (candidate !== null) files[relativePath] = candidate
286
263
  }
287
264
  return {
288
265
  version: MANIFEST_VERSION,