@fabrika/builder 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/BUILD-CONTRACT.md +41 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/cli/src/bin.ts +66 -0
- package/cli/src/build-status.ts +109 -0
- package/cli/src/catalog.ts +42 -0
- package/cli/src/commands/advance.ts +73 -0
- package/cli/src/commands/build.ts +215 -0
- package/cli/src/commands/ensure-daemon.ts +25 -0
- package/cli/src/commands/gate.ts +165 -0
- package/cli/src/commands/induce.ts +61 -0
- package/cli/src/commands/init.ts +91 -0
- package/cli/src/commands/next.ts +31 -0
- package/cli/src/config.ts +47 -0
- package/cli/src/context.ts +28 -0
- package/cli/src/daemon-client.ts +280 -0
- package/cli/src/daemon-lifecycle.ts +171 -0
- package/cli/src/derive.ts +109 -0
- package/cli/src/index.ts +38 -0
- package/cli/src/induce.ts +151 -0
- package/cli/src/inject-skills.ts +313 -0
- package/cli/src/issues.ts +75 -0
- package/cli/src/layout.ts +124 -0
- package/cli/src/packet.ts +108 -0
- package/cli/src/phase-state.ts +19 -0
- package/cli/src/phases.ts +131 -0
- package/cli/src/skeletons.ts +104 -0
- package/daemon/src/bin.ts +70 -0
- package/daemon/src/build-stream.ts +38 -0
- package/daemon/src/handlers.ts +472 -0
- package/daemon/src/index.ts +123 -0
- package/daemon/src/paths.ts +30 -0
- package/daemon/src/phases.ts +221 -0
- package/daemon/src/static.ts +72 -0
- package/decisions/README.md +133 -0
- package/decisions/patterns/app-deploy.md +55 -0
- package/decisions/patterns/auth-password-kv.md +45 -0
- package/decisions/patterns/count-store.md +44 -0
- package/decisions/patterns/di-container.md +47 -0
- package/decisions/patterns/feature-architecture.md +48 -0
- package/decisions/patterns/lib-publish.md +46 -0
- package/decisions/patterns/monorepo.md +47 -0
- package/decisions/patterns/named-seeds.md +45 -0
- package/decisions/patterns/observability.md +45 -0
- package/decisions/patterns/single-worker-spa.md +74 -0
- package/decisions/patterns/structured-logging.md +49 -0
- package/decisions/patterns/typed-worker-rpc.md +87 -0
- package/decisions/patterns/vozka-app-deploy.md +74 -0
- package/decisions/patterns/write-split.md +47 -0
- package/decisions/technologies/analytics-engine.md +44 -0
- package/decisions/technologies/bindx.md +55 -0
- package/decisions/technologies/bun.md +51 -0
- package/decisions/technologies/buzola.md +80 -0
- package/decisions/technologies/cloudflare-access.md +50 -0
- package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
- package/decisions/technologies/cloudflare.md +50 -0
- package/decisions/technologies/contember.md +65 -0
- package/decisions/technologies/cron-triggers.md +46 -0
- package/decisions/technologies/d1.md +51 -0
- package/decisions/technologies/durable-objects.md +44 -0
- package/decisions/technologies/kv.md +42 -0
- package/decisions/technologies/lopata.md +55 -0
- package/decisions/technologies/nuasite.md +50 -0
- package/decisions/technologies/oblaka.md +48 -0
- package/decisions/technologies/opice.md +40 -0
- package/decisions/technologies/pletivo.md +53 -0
- package/decisions/technologies/postgres.md +47 -0
- package/decisions/technologies/preact.md +44 -0
- package/decisions/technologies/propustka.md +137 -0
- package/decisions/technologies/queue.md +55 -0
- package/decisions/technologies/r2.md +41 -0
- package/decisions/technologies/react-router.md +47 -0
- package/decisions/technologies/react.md +37 -0
- package/decisions/technologies/roj.md +51 -0
- package/decisions/technologies/sentry.md +49 -0
- package/decisions/technologies/tailwind.md +45 -0
- package/decisions/technologies/vite.md +47 -0
- package/decisions/technologies/vozka.md +122 -0
- package/decisions/technologies/workers-ai.md +59 -0
- package/decisions/technologies/wrangler.md +41 -0
- package/decisions/tooling/deptective.md +45 -0
- package/decisions/tooling/edvabe.md +45 -0
- package/index.ts +2 -0
- package/package.json +51 -0
- package/skill/SKILL.md +76 -0
- package/skill/agents/build-orchestrator.md +170 -0
- package/skill/agents/flows-author.md +65 -0
- package/skill/agents/implementer.md +37 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/agents/opice-skeleton-author.md +68 -0
- package/skill/gates/brief.jsx +20 -0
- package/skill/gates/build.jsx +20 -0
- package/skill/gates/design.jsx +22 -0
- package/skill/gates/flows.jsx +14 -0
- package/skill/gates/review-grid.jsx +32 -0
- package/skill/gates/tech.jsx +17 -0
- package/skill/gates/vision.jsx +27 -0
- package/stacks/README.md +44 -0
- package/stacks/cf-fat.md +64 -0
- package/stacks/cf-internal-app.md +125 -0
- package/stacks/cf-lightweight.md +54 -0
- package/stacks/contember-app.md +53 -0
- package/stacks/content-site.md +45 -0
- package/ui/dist/assets/annotations-runtime.js +1 -0
- package/ui/dist/assets/client.css +2 -0
- package/ui/dist/assets/client.fhjppkvr.js +214 -0
- package/ui/dist/assets/components.js +63 -0
- package/ui/dist/assets/preact-compat.js +1 -0
- package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
- package/ui/dist/assets/react-jsx-runtime.js +3 -0
- package/ui/dist/assets/runtime.js +1 -0
- package/ui/dist/index.html +36 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { resolve } from 'path'
|
|
2
|
+
import { BASE_URL, SESSIONS_DIR, TIMEOUT_MS, WS_URL } from './config'
|
|
3
|
+
import type { PhaseState } from './derive'
|
|
4
|
+
import { PHASES } from './phases'
|
|
5
|
+
|
|
6
|
+
const HEALTH_TIMEOUT_MS = 1000
|
|
7
|
+
|
|
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
|
+
export async function fetchPhase(fabrikaDir: string, sessionId?: string): Promise<PhaseState | null> {
|
|
129
|
+
try {
|
|
130
|
+
let url = `${BASE_URL}/api/phase?root=${encodeURIComponent(fabrikaDir)}`
|
|
131
|
+
if (sessionId) url += `&session=${encodeURIComponent(sessionId)}`
|
|
132
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
|
|
133
|
+
if (!res.ok) return null
|
|
134
|
+
const data: unknown = await res.json()
|
|
135
|
+
return parsePhaseState(data)
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PlanResult {
|
|
142
|
+
ok: boolean
|
|
143
|
+
browserUrl?: string
|
|
144
|
+
revision?: number
|
|
145
|
+
isNew?: boolean
|
|
146
|
+
canvasFiles?: string[]
|
|
147
|
+
gate?: string
|
|
148
|
+
error?: string
|
|
149
|
+
errors?: Record<string, string>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type PlanSource =
|
|
153
|
+
| { view: string; directory?: never }
|
|
154
|
+
| { directory: string; view?: never }
|
|
155
|
+
|
|
156
|
+
export async function pushPlan(
|
|
157
|
+
sessionId: string,
|
|
158
|
+
body: PlanSource & { projectRoot: string; label?: string; gate?: string },
|
|
159
|
+
): Promise<PlanResult & { reachable: boolean }> {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify(body),
|
|
165
|
+
})
|
|
166
|
+
const data = parsePlanResult(await res.json())
|
|
167
|
+
return { ...data, reachable: true }
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { ok: false, reachable: false, error: String(e) }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function peekFeedback(sessionId: string): Promise<string | null> {
|
|
174
|
+
try {
|
|
175
|
+
const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`, {
|
|
176
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
177
|
+
})
|
|
178
|
+
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']
|
|
182
|
+
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`, {
|
|
187
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
188
|
+
})
|
|
189
|
+
if (!fbRes.ok) return null
|
|
190
|
+
const data: unknown = await fbRes.json()
|
|
191
|
+
return isRecord(data) && typeof data['feedback'] === 'string' ? data['feedback'] : ''
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
} catch {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function consumeFeedback(sessionId: string): Promise<string | null> {
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(`${BASE_URL}/api/session/${sessionId}/feedback/consume`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
205
|
+
})
|
|
206
|
+
const data: unknown = await res.json()
|
|
207
|
+
if (!isRecord(data) || data['found'] !== true) return null
|
|
208
|
+
return typeof data['feedback'] === 'string' ? data['feedback'] : ''
|
|
209
|
+
} catch {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface BuildEvent {
|
|
215
|
+
issue?: string
|
|
216
|
+
status: string
|
|
217
|
+
message?: string
|
|
218
|
+
ts?: number
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function postBuildEvent(event: BuildEvent): Promise<boolean> {
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(`${BASE_URL}/api/build/event`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify(event),
|
|
227
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
228
|
+
})
|
|
229
|
+
return res.ok
|
|
230
|
+
} catch {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
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
|
+
}
|
|
279
|
+
})
|
|
280
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DaemonUnavailableError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'DaemonUnavailableError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
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) }
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
}
|
|
105
|
+
|
|
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
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!quiet) console.error(`[fabrika] ${mismatch}; restarting managed daemon ${pid}.`)
|
|
113
|
+
try {
|
|
114
|
+
process.kill(pid, 'SIGTERM')
|
|
115
|
+
} catch {
|
|
116
|
+
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
|
|
126
|
+
}
|
|
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
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
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
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function openBrowser(url: string): void {
|
|
160
|
+
const candidates = process.platform === 'darwin'
|
|
161
|
+
? ['open']
|
|
162
|
+
: ['xdg-open', 'wslview', 'sensible-browser']
|
|
163
|
+
for (const cmd of candidates) {
|
|
164
|
+
const which = Bun.spawnSync(['which', cmd])
|
|
165
|
+
if (which.exitCode === 0) {
|
|
166
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
console.error(`Open this URL in your browser: ${url}`)
|
|
171
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
2
|
+
import { basename, dirname, join, resolve } from 'path'
|
|
3
|
+
import { type Phase, type PhaseId, PHASES } from './phases'
|
|
4
|
+
|
|
5
|
+
export interface PhaseState {
|
|
6
|
+
current: PhaseId
|
|
7
|
+
index: number
|
|
8
|
+
completed: PhaseId[]
|
|
9
|
+
reads: string[]
|
|
10
|
+
produces: string[]
|
|
11
|
+
gate: boolean
|
|
12
|
+
satisfied: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type GateProbe = (phase: Phase) => boolean
|
|
16
|
+
|
|
17
|
+
export const offlineGateProbe: GateProbe = () => true
|
|
18
|
+
|
|
19
|
+
export function matchGlob(repoRoot: string, glob: string): string[] {
|
|
20
|
+
if (!glob.includes('*')) {
|
|
21
|
+
const abs = resolve(repoRoot, glob)
|
|
22
|
+
return existsSync(abs) ? [abs] : []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dirPart = dirname(glob)
|
|
26
|
+
const filePart = basename(glob)
|
|
27
|
+
const absDir = resolve(repoRoot, dirPart)
|
|
28
|
+
if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return []
|
|
29
|
+
|
|
30
|
+
const re = globSegmentToRegExp(filePart)
|
|
31
|
+
const out: string[] = []
|
|
32
|
+
for (const entry of readdirSync(absDir)) {
|
|
33
|
+
if (re.test(entry)) out.push(join(absDir, entry))
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function globSegmentToRegExp(seg: string): RegExp {
|
|
39
|
+
const escaped = seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
|
|
40
|
+
return new RegExp(`^${escaped}$`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isNonEmptyFile(p: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const st = statSync(p)
|
|
46
|
+
return st.isFile() && st.size > 0
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function producesSatisfied(repoRoot: string, phase: Phase): boolean {
|
|
53
|
+
for (const glob of phase.produces) {
|
|
54
|
+
if (isPlaceholder(glob)) return false
|
|
55
|
+
const matches = matchGlob(repoRoot, glob).filter(isNonEmptyFile)
|
|
56
|
+
if (matches.length === 0) return false
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveReads(repoRoot: string, phase: Phase): string[] {
|
|
62
|
+
const out: string[] = []
|
|
63
|
+
for (const glob of phase.reads) {
|
|
64
|
+
if (isPlaceholder(glob)) {
|
|
65
|
+
out.push(glob) // keep the human hint, e.g. "<init brief>"
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const matches = matchGlob(repoRoot, glob)
|
|
69
|
+
if (matches.length > 0) {
|
|
70
|
+
out.push(glob)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPlaceholder(glob: string): boolean {
|
|
77
|
+
return glob.startsWith('<')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function derivePhase(repoRoot: string, gateProbe: GateProbe = offlineGateProbe): PhaseState {
|
|
81
|
+
const completed: PhaseId[] = []
|
|
82
|
+
|
|
83
|
+
for (const phase of PHASES) {
|
|
84
|
+
const produced = producesSatisfied(repoRoot, phase)
|
|
85
|
+
const gateOk = !produced ? false : phase.gate ? gateProbe(phase) : true
|
|
86
|
+
|
|
87
|
+
if (produced && gateOk) {
|
|
88
|
+
completed.push(phase.id)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
return toState(repoRoot, phase, completed, produced)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const build = PHASES.at(-1)
|
|
95
|
+
if (build === undefined) throw new Error('The builder has no phases')
|
|
96
|
+
return toState(repoRoot, build, completed.filter((id) => id !== build.id), true)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toState(repoRoot: string, phase: Phase, completed: PhaseId[], satisfied: boolean): PhaseState {
|
|
100
|
+
return {
|
|
101
|
+
current: phase.id,
|
|
102
|
+
index: PHASES.findIndex((p) => p.id === phase.id),
|
|
103
|
+
completed,
|
|
104
|
+
reads: resolveReads(repoRoot, phase),
|
|
105
|
+
produces: phase.produces,
|
|
106
|
+
gate: phase.gate,
|
|
107
|
+
satisfied,
|
|
108
|
+
}
|
|
109
|
+
}
|
package/cli/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export { resolveCatalogRoot, resolveStackPointer, type StackPointer } from './catalog'
|
|
2
|
+
export {
|
|
3
|
+
BASE_URL,
|
|
4
|
+
DAEMON_PORT,
|
|
5
|
+
DATA_DIR,
|
|
6
|
+
FABRIKA_DIRNAME,
|
|
7
|
+
getSessionId,
|
|
8
|
+
hashRepoPath,
|
|
9
|
+
LEGACY_DATA_DIR,
|
|
10
|
+
NEUTRAL_DATA_DIR,
|
|
11
|
+
resolveDataDir,
|
|
12
|
+
TEMP_DIR,
|
|
13
|
+
WS_URL,
|
|
14
|
+
} from './config'
|
|
15
|
+
export { derivePhase, type GateProbe, isNonEmptyFile, matchGlob, offlineGateProbe, type PhaseState, producesSatisfied, resolveReads } from './derive'
|
|
16
|
+
export {
|
|
17
|
+
type AgentHost,
|
|
18
|
+
injectAgentSupport,
|
|
19
|
+
type InjectOptions,
|
|
20
|
+
type InjectResult,
|
|
21
|
+
injectSkills,
|
|
22
|
+
parseAgentHosts,
|
|
23
|
+
resolveSkillSource,
|
|
24
|
+
} from './inject-skills'
|
|
25
|
+
export {
|
|
26
|
+
configPath,
|
|
27
|
+
EXTERNAL_DIRS,
|
|
28
|
+
type FabrikaConfig,
|
|
29
|
+
fabrikaDir,
|
|
30
|
+
findRepoRoot,
|
|
31
|
+
type InitResult,
|
|
32
|
+
isInitialized,
|
|
33
|
+
LAYER_DIRS,
|
|
34
|
+
readConfig,
|
|
35
|
+
scaffold,
|
|
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'
|