@fabrika/builder 0.2.0 → 0.3.1
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/BUILD-CONTRACT.md +8 -0
- package/README.md +9 -0
- package/cli/src/bin.ts +35 -2
- package/cli/src/build-status.ts +7 -15
- package/cli/src/commands/advance.ts +3 -2
- package/cli/src/commands/ensure-daemon.ts +3 -3
- package/cli/src/commands/gate.ts +4 -10
- package/cli/src/commands/init.ts +44 -7
- package/cli/src/commands/next.ts +3 -3
- package/cli/src/commands/platform.ts +81 -0
- package/cli/src/config.ts +1 -9
- package/cli/src/context.ts +4 -1
- package/cli/src/daemon-client.ts +36 -185
- package/cli/src/daemon-lifecycle.ts +63 -142
- package/cli/src/derive.ts +4 -8
- package/cli/src/errors.ts +1 -0
- package/cli/src/index.ts +2 -3
- package/cli/src/inject-skills.ts +8 -31
- package/cli/src/layout.ts +8 -18
- package/cli/src/packet.ts +18 -17
- package/cli/src/platform-client.ts +40 -0
- package/daemon/src/admission.ts +19 -0
- package/daemon/src/bin.ts +7 -9
- package/daemon/src/handlers.ts +82 -79
- package/daemon/src/index.ts +14 -5
- package/daemon/src/paths.ts +5 -6
- package/daemon/src/phases.ts +27 -126
- package/daemon/src/platform-compiler.ts +21 -0
- package/daemon/src/platform-remote.ts +146 -0
- package/daemon/src/platform-routes.ts +46 -0
- package/daemon/src/platform-source.ts +147 -0
- package/daemon/src/platform-storage.ts +222 -0
- package/daemon/src/platform.ts +481 -0
- package/decisions/README.md +4 -0
- package/decisions/patterns/zerops-control-deploy.md +49 -0
- package/decisions/patterns/zerops-local-app.md +57 -0
- package/decisions/patterns/zerops-postgres-seeds.md +37 -0
- package/decisions/technologies/zerops-fabrika-auth.md +53 -0
- package/decisions/technologies/zerops-platform.md +64 -0
- package/package.json +13 -5
- package/shared/index.ts +365 -0
- package/{cli/src → shared}/phases.ts +42 -50
- package/shared/urls.ts +41 -0
- package/skill/SKILL.md +34 -0
- package/stacks/README.md +2 -0
- package/stacks/zerops-app.md +66 -0
- package/ui/dist/assets/client.2fkhreps.js +214 -0
- package/ui/dist/assets/client.css +1 -1
- package/ui/dist/assets/components.js +57 -57
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/client.fhjppkvr.js +0 -214
package/cli/src/daemon-client.ts
CHANGED
|
@@ -1,138 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
135
|
-
return
|
|
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
|
|
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
|
|
180
|
-
if (!
|
|
181
|
-
const revisions = meta
|
|
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 (
|
|
185
|
-
|
|
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
|
|
191
|
-
return
|
|
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
|
|
207
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, resolve } from 'path'
|
|
4
|
-
import { fileURLToPath } from 'url'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
if (!quiet) console.error(`[fabrika] ${
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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' })
|
|
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: ${
|
|
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 '
|
|
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 (
|
|
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 (
|
|
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:
|
|
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,
|
|
38
|
-
export { type Phase, PHASE_COUNT, phaseById, type PhaseId, phaseIndex, PHASES } from './phases'
|
|
37
|
+
export { type PacketJson, packetJson, type RenderOpts, renderPacket } from './packet'
|
package/cli/src/inject-skills.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
277
|
-
if (!
|
|
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
|
|
280
|
-
if (
|
|
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,
|