@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.
- 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 +4 -11
- 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 +21 -0
- package/daemon/src/bin.ts +12 -14
- package/daemon/src/handlers.ts +84 -81
- package/daemon/src/index.ts +16 -6
- 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 +48 -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.css +1 -1
- package/ui/dist/assets/client.krrpk10y.js +214 -0
- 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/layout.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
|
2
|
-
import { join, resolve } from 'path'
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { type BuilderProjectConfig, parseBuilderProjectConfig } from '../../shared'
|
|
3
4
|
import { FABRIKA_DIRNAME, hashRepoPath } from './config'
|
|
5
|
+
import { BuilderCliError } from './errors'
|
|
4
6
|
|
|
5
7
|
export const LAYER_DIRS = [
|
|
6
8
|
'intent',
|
|
@@ -14,19 +16,7 @@ export const LAYER_DIRS = [
|
|
|
14
16
|
|
|
15
17
|
export const EXTERNAL_DIRS = ['tests/browser']
|
|
16
18
|
|
|
17
|
-
export
|
|
18
|
-
profile: string
|
|
19
|
-
sessionId: string
|
|
20
|
-
createdAt: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function parseConfig(value: unknown): FabrikaConfig | null {
|
|
24
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
|
25
|
-
if (!('profile' in value) || !('sessionId' in value) || !('createdAt' in value)) return null
|
|
26
|
-
const { profile, sessionId, createdAt } = value
|
|
27
|
-
if (typeof profile !== 'string' || typeof sessionId !== 'string' || typeof createdAt !== 'string') return null
|
|
28
|
-
return { profile, sessionId, createdAt }
|
|
29
|
-
}
|
|
19
|
+
export type FabrikaConfig = BuilderProjectConfig
|
|
30
20
|
|
|
31
21
|
export function fabrikaDir(repoRoot: string): string {
|
|
32
22
|
return join(resolve(repoRoot), FABRIKA_DIRNAME)
|
|
@@ -44,9 +34,9 @@ export function readConfig(repoRoot: string): FabrikaConfig | null {
|
|
|
44
34
|
const p = configPath(repoRoot)
|
|
45
35
|
if (!existsSync(p)) return null
|
|
46
36
|
try {
|
|
47
|
-
return
|
|
37
|
+
return parseBuilderProjectConfig(JSON.parse(readFileSync(p, 'utf-8')))
|
|
48
38
|
} catch {
|
|
49
|
-
|
|
39
|
+
throw new BuilderCliError('Invalid .fabrika/config.json; repair it before continuing.')
|
|
50
40
|
}
|
|
51
41
|
}
|
|
52
42
|
|
|
@@ -69,6 +59,7 @@ export function scaffold(
|
|
|
69
59
|
const createdDirs: string[] = []
|
|
70
60
|
|
|
71
61
|
const alreadyInitialized = isInitialized(root)
|
|
62
|
+
const existing = readConfig(root)
|
|
72
63
|
|
|
73
64
|
for (const dir of ['.', ...LAYER_DIRS]) {
|
|
74
65
|
const abs = dir === '.' ? fdir : join(fdir, dir)
|
|
@@ -88,7 +79,6 @@ export function scaffold(
|
|
|
88
79
|
|
|
89
80
|
let config: FabrikaConfig
|
|
90
81
|
let createdConfig = false
|
|
91
|
-
const existing = readConfig(root)
|
|
92
82
|
if (existing) {
|
|
93
83
|
config = existing
|
|
94
84
|
} else {
|
package/cli/src/packet.ts
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
import { join } from 'path'
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import type { BuilderProjectConnection } from '../../shared'
|
|
3
|
+
import { PHASE_COUNT, phaseById, type PhaseId, phaseIndex } from '../../shared/phases'
|
|
2
4
|
import { resolveStackPointer, type StackPointer } from './catalog'
|
|
3
5
|
import type { PhaseState } from './derive'
|
|
4
|
-
import { PHASE_COUNT, phaseById, type PhaseId, phaseIndex, PHASES } from './phases'
|
|
5
6
|
|
|
6
7
|
export interface RenderOpts {
|
|
7
8
|
profile?: string
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const SHORT_LABEL: Record<PhaseId, string> = {
|
|
11
|
-
intake: 'intake',
|
|
12
|
-
vision: 'vision',
|
|
13
|
-
flows: 'flows',
|
|
14
|
-
tech: 'tech',
|
|
15
|
-
design: 'design',
|
|
16
|
-
skeletons: 'skeletons',
|
|
17
|
-
issues: 'issues',
|
|
18
|
-
build: 'build',
|
|
9
|
+
connection?: BuilderProjectConnection
|
|
19
10
|
}
|
|
20
11
|
|
|
21
12
|
const CHECK = '✓' // ✓
|
|
@@ -23,8 +14,9 @@ const CHECK = '✓' // ✓
|
|
|
23
14
|
export function renderPacket(state: PhaseState, opts: RenderOpts = {}): string {
|
|
24
15
|
const phase = phaseById(state.current)
|
|
25
16
|
const lines: string[] = []
|
|
17
|
+
if (opts.connection) lines.push(`Platform: ${JSON.stringify(connectionContext(opts.connection))}`)
|
|
26
18
|
|
|
27
|
-
const done = state.completed.map((id) => `${
|
|
19
|
+
const done = state.completed.map((id) => `${id} ${CHECK}`).join(', ')
|
|
28
20
|
const completedSuffix = done ? ` (${done})` : ''
|
|
29
21
|
lines.push(`## Phase ${phase.number}/${PHASE_COUNT} - ${phase.title}${completedSuffix}`)
|
|
30
22
|
|
|
@@ -37,7 +29,7 @@ export function renderPacket(state: PhaseState, opts: RenderOpts = {}): string {
|
|
|
37
29
|
|
|
38
30
|
lines.push(`Goal: ${phase.goal}`)
|
|
39
31
|
|
|
40
|
-
lines.push(`Skills: ${phase.skills.join(', ')}`)
|
|
32
|
+
if (phase.skills) lines.push(`Skills: ${phase.skills.join(', ')}`)
|
|
41
33
|
|
|
42
34
|
if (phase.gate) {
|
|
43
35
|
const view = phase.gateView ?? `${phase.id}.jsx`
|
|
@@ -85,11 +77,13 @@ export interface PacketJson {
|
|
|
85
77
|
completed: PhaseId[]
|
|
86
78
|
satisfied: boolean
|
|
87
79
|
stack: StackPointer | null
|
|
80
|
+
platform: ReturnType<typeof connectionContext> | null
|
|
88
81
|
}
|
|
89
82
|
|
|
90
83
|
export function packetJson(state: PhaseState, opts: RenderOpts = {}): PacketJson {
|
|
91
84
|
const phase = phaseById(state.current)
|
|
92
85
|
return {
|
|
86
|
+
platform: opts.connection ? connectionContext(opts.connection) : null,
|
|
93
87
|
phase: phase.id,
|
|
94
88
|
index: phaseIndex(phase.id),
|
|
95
89
|
number: phase.number,
|
|
@@ -98,11 +92,18 @@ export function packetJson(state: PhaseState, opts: RenderOpts = {}): PacketJson
|
|
|
98
92
|
produces: state.produces,
|
|
99
93
|
gate: phase.gate,
|
|
100
94
|
gateView: phase.gateView ?? null,
|
|
101
|
-
skills: phase.skills,
|
|
95
|
+
skills: phase.skills ?? [],
|
|
102
96
|
completed: state.completed,
|
|
103
97
|
satisfied: state.satisfied,
|
|
104
98
|
stack: phase.catalog && opts.profile ? resolveStackPointer(opts.profile) : null,
|
|
105
99
|
}
|
|
106
100
|
}
|
|
107
101
|
|
|
108
|
-
|
|
102
|
+
function connectionContext(connection: BuilderProjectConnection) {
|
|
103
|
+
return {
|
|
104
|
+
controlOrigin: connection.controlOrigin,
|
|
105
|
+
provider: connection.receipt.provider,
|
|
106
|
+
app: connection.receipt.app,
|
|
107
|
+
environment: connection.receipt.environment,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type BuilderPlatformStatus, builderProjectPath, parseBuilderPlatformStatus } from '../../shared'
|
|
2
|
+
import { BASE_URL } from './config'
|
|
3
|
+
import { BuilderCliError } from './errors'
|
|
4
|
+
|
|
5
|
+
export async function platformRequest(
|
|
6
|
+
sessionId: string,
|
|
7
|
+
suffix: Parameters<typeof builderProjectPath>[1],
|
|
8
|
+
body?: object,
|
|
9
|
+
timeoutMs = 30_000,
|
|
10
|
+
): Promise<BuilderPlatformStatus> {
|
|
11
|
+
let response: Response
|
|
12
|
+
try {
|
|
13
|
+
response = await fetch(`${BASE_URL}${builderProjectPath(sessionId, suffix)}`, {
|
|
14
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
15
|
+
redirect: 'error',
|
|
16
|
+
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
|
17
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
18
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
19
|
+
})
|
|
20
|
+
} catch {
|
|
21
|
+
throw new BuilderCliError('Local builder request failed. Check the local dashboard and retry.')
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) throw new BuilderCliError(`Local builder request refused (HTTP ${response.status}). Check the local dashboard.`)
|
|
24
|
+
try {
|
|
25
|
+
return parseBuilderPlatformStatus(await response.json())
|
|
26
|
+
} catch {
|
|
27
|
+
throw new BuilderCliError('Invalid local builder response.')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function publicPlatformStatus(status: BuilderPlatformStatus) {
|
|
32
|
+
return {
|
|
33
|
+
...status,
|
|
34
|
+
pairing: status.pairing === null ? null : {
|
|
35
|
+
status: status.pairing.status,
|
|
36
|
+
...(status.pairing.status === 'pending' ? { expiresAt: status.pairing.expiresAt } : {}),
|
|
37
|
+
},
|
|
38
|
+
deployment: status.deployment === null ? null : { ...status.deployment, message: null },
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BUILDER_DAEMON_HOST } from '../../shared/urls'
|
|
2
|
+
|
|
3
|
+
export function admitRequest(req: Request, url: URL, port: number): Response | null {
|
|
4
|
+
const authority = req.headers.get('host')
|
|
5
|
+
const allowedHostname = url.hostname === BUILDER_DAEMON_HOST || url.hostname === 'localhost'
|
|
6
|
+
const allowedAuthority = authority === `${url.hostname}:${port}` || (port === 80 && authority === url.hostname)
|
|
7
|
+
const expectedPort = port === 80 ? '' : String(port)
|
|
8
|
+
const origin = req.headers.get('origin')
|
|
9
|
+
const site = req.headers.get('sec-fetch-site')
|
|
10
|
+
const safeNavigation = site === 'none' && (req.method === 'GET' || req.method === 'HEAD')
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
!allowedHostname || !allowedAuthority || url.protocol !== 'http:' || url.port !== expectedPort || url.username !== '' || url.password !== ''
|
|
14
|
+
|| (origin !== null && origin !== url.origin)
|
|
15
|
+
|| (site !== null && site !== 'same-origin' && !safeNavigation)
|
|
16
|
+
) {
|
|
17
|
+
return new Response('Forbidden', { status: 403 })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null
|
|
21
|
+
}
|
package/daemon/src/bin.ts
CHANGED
|
@@ -2,27 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
4
4
|
import { resolve } from 'path'
|
|
5
|
+
import { activeDaemonSchema, builderDaemonUrl } from '../../shared'
|
|
5
6
|
import { startDaemon } from './index'
|
|
6
7
|
import { PID_FILE, SESSIONS_DIR, TEMP_DIR } from './paths'
|
|
7
8
|
|
|
8
9
|
const port = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
|
|
9
10
|
|
|
10
11
|
interface ActiveDaemon {
|
|
11
|
-
ok
|
|
12
|
-
sessionsDir
|
|
12
|
+
ok: boolean
|
|
13
|
+
sessionsDir: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function parseActiveDaemon(value: unknown): ActiveDaemon | null {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const sessionsDir = 'sessionsDir' in value ? value.sessionsDir : undefined
|
|
19
|
-
if (ok !== true || (sessionsDir !== undefined && typeof sessionsDir !== 'string')) return null
|
|
20
|
-
return { ok, ...(sessionsDir === undefined ? {} : { sessionsDir }) }
|
|
17
|
+
const result = activeDaemonSchema.safeParse(value)
|
|
18
|
+
return result.success ? result.data : null
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
async function activeDaemon(): Promise<ActiveDaemon | null> {
|
|
24
22
|
try {
|
|
25
|
-
const res = await fetch(
|
|
23
|
+
const res = await fetch(`${builderDaemonUrl(port)}/health`, {
|
|
26
24
|
signal: AbortSignal.timeout(1000),
|
|
27
25
|
})
|
|
28
26
|
const data: unknown = await res.json()
|
|
@@ -34,20 +32,20 @@ async function activeDaemon(): Promise<ActiveDaemon | null> {
|
|
|
34
32
|
|
|
35
33
|
const active = await activeDaemon()
|
|
36
34
|
if (active) {
|
|
37
|
-
if (
|
|
38
|
-
console.log(`fabrika daemon already running on
|
|
35
|
+
if (resolve(active.sessionsDir) === resolve(SESSIONS_DIR)) {
|
|
36
|
+
console.log(`fabrika daemon already running on ${builderDaemonUrl(port)} — not starting a second.`)
|
|
39
37
|
process.exit(0)
|
|
40
38
|
}
|
|
41
39
|
console.error(
|
|
42
|
-
`fabrika daemon on :${port} uses ${active.sessionsDir
|
|
40
|
+
`fabrika daemon on :${port} uses ${active.sessionsDir}; `
|
|
43
41
|
+ `requested ${SESSIONS_DIR}. Stop it before starting this daemon.`,
|
|
44
42
|
)
|
|
45
43
|
process.exit(1)
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
let
|
|
46
|
+
let boundPort: number
|
|
49
47
|
try {
|
|
50
|
-
;({
|
|
48
|
+
;({ port: boundPort } = startDaemon({ port }))
|
|
51
49
|
} catch (error: unknown) {
|
|
52
50
|
console.error(`fabrika daemon failed to bind :${port}: ${error instanceof Error ? error.message : String(error)}`)
|
|
53
51
|
process.exit(1)
|
|
@@ -56,7 +54,7 @@ try {
|
|
|
56
54
|
mkdirSync(TEMP_DIR, { recursive: true })
|
|
57
55
|
writeFileSync(PID_FILE, String(process.pid))
|
|
58
56
|
|
|
59
|
-
console.log(`fabrika daemon listening on
|
|
57
|
+
console.log(`fabrika daemon listening on ${builderDaemonUrl(boundPort)}`)
|
|
60
58
|
|
|
61
59
|
function shutdown() {
|
|
62
60
|
try {
|
package/daemon/src/handlers.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
|
|
1
|
+
import { type AssertReadable, type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
|
|
2
2
|
import type { Route } from '@fabrika/daemon-kit'
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
4
|
-
import { basename, dirname, extname, join
|
|
4
|
+
import { basename, dirname, extname, join } from 'path'
|
|
5
|
+
import type { z } from 'zod'
|
|
6
|
+
import { builderDaemonUrl, buildEventSchema, planInputSchema } from '../../shared'
|
|
5
7
|
import type { BuildEvent, BuildStream } from './build-stream'
|
|
6
8
|
import { COMPILE_TEMP_DIR } from './paths'
|
|
7
9
|
import { derivePhaseState, type GateOracle } from './phases'
|
|
10
|
+
import { BuilderPlatform } from './platform'
|
|
11
|
+
import { PlatformError } from './platform-storage'
|
|
8
12
|
|
|
9
13
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
10
14
|
return new Response(JSON.stringify(data), {
|
|
@@ -13,10 +17,6 @@ function jsonResponse(data: unknown, status = 200): Response {
|
|
|
13
17
|
})
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
20
|
function errorMessage(error: unknown): string {
|
|
21
21
|
return error instanceof Error ? error.message : String(error)
|
|
22
22
|
}
|
|
@@ -27,33 +27,9 @@ function routeParam(match: URLPatternResult, name: string): string {
|
|
|
27
27
|
return value
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function optionalString(value: unknown): string | undefined {
|
|
31
|
-
return typeof value === 'string' ? value : undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isBuildStatus(value: unknown): value is BuildEvent['status'] {
|
|
35
|
-
return value === 'queued' || value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked'
|
|
36
|
-
|| value === 'info'
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseBuildEvent(value: unknown): BuildEvent | null {
|
|
40
|
-
if (!isRecord(value) || !isBuildStatus(value['status'])) return null
|
|
41
|
-
const issue = optionalString(value['issue'])
|
|
42
|
-
const message = optionalString(value['message'])
|
|
43
|
-
const at = optionalString(value['at'])
|
|
44
|
-
const data = isRecord(value['data']) ? value['data'] : undefined
|
|
45
|
-
return {
|
|
46
|
-
status: value['status'],
|
|
47
|
-
...(issue === undefined ? {} : { issue }),
|
|
48
|
-
...(message === undefined ? {} : { message }),
|
|
49
|
-
...(at === undefined ? {} : { at }),
|
|
50
|
-
...(data === undefined ? {} : { data }),
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
30
|
export function corsHeaders(port: number): Record<string, string> {
|
|
55
31
|
return {
|
|
56
|
-
'Access-Control-Allow-Origin':
|
|
32
|
+
'Access-Control-Allow-Origin': builderDaemonUrl(port),
|
|
57
33
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
58
34
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
59
35
|
}
|
|
@@ -67,6 +43,7 @@ export interface HandlerContext {
|
|
|
67
43
|
sessionsDir: string
|
|
68
44
|
port: number
|
|
69
45
|
version: string
|
|
46
|
+
platform?: BuilderPlatform
|
|
70
47
|
}
|
|
71
48
|
|
|
72
49
|
function gateOracleFor(sm: SessionManager): GateOracle {
|
|
@@ -89,9 +66,18 @@ interface CanvasSelection {
|
|
|
89
66
|
scope: CanvasScope
|
|
90
67
|
}
|
|
91
68
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
69
|
+
function readCanvasFile(path: string, assertReadable: AssertReadable): string {
|
|
70
|
+
try {
|
|
71
|
+
assertReadable(path)
|
|
72
|
+
return readFileSync(path, 'utf-8')
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error('Canvas file access refused')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveCanvasSelection(body: z.infer<typeof planInputSchema>, assertReadable: AssertReadable): CanvasSelection {
|
|
79
|
+
const view = body.view ?? null
|
|
80
|
+
const directory = body.directory ?? null
|
|
95
81
|
if (!!view === !!directory) {
|
|
96
82
|
throw new Error('Provide exactly one of view or directory')
|
|
97
83
|
}
|
|
@@ -105,7 +91,7 @@ function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection
|
|
|
105
91
|
}
|
|
106
92
|
const filename = basename(view)
|
|
107
93
|
return {
|
|
108
|
-
files: new Map([[filename,
|
|
94
|
+
files: new Map([[filename, readCanvasFile(view, assertReadable)]]),
|
|
109
95
|
scope: { kind: 'view', filename },
|
|
110
96
|
}
|
|
111
97
|
}
|
|
@@ -118,13 +104,15 @@ function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection
|
|
|
118
104
|
if (filenames.length === 0) throw new Error('No .jsx files found in directory')
|
|
119
105
|
const files = new Map<string, string>()
|
|
120
106
|
for (const filename of filenames) {
|
|
121
|
-
files.set(filename,
|
|
107
|
+
files.set(filename, readCanvasFile(join(directory, filename), assertReadable))
|
|
122
108
|
}
|
|
123
109
|
return { files, scope: { kind: 'directory' } }
|
|
124
110
|
}
|
|
125
111
|
|
|
126
112
|
export function createHandlers(ctx: HandlerContext): Route[] {
|
|
127
113
|
const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, buildStream } = ctx
|
|
114
|
+
const platform = ctx.platform ?? new BuilderPlatform((id) => sessionManager.get(id)?.projectRoot)
|
|
115
|
+
const assertReadable: AssertReadable = (path) => platform.storage.assertPublicFile(path)
|
|
128
116
|
|
|
129
117
|
function handleHealth(): Response {
|
|
130
118
|
return jsonResponse({
|
|
@@ -138,10 +126,11 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
138
126
|
}
|
|
139
127
|
|
|
140
128
|
function handlePhase(_req: Request, url: URL): Response {
|
|
141
|
-
const root = url.searchParams.get('root')
|
|
142
|
-
if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
|
|
143
129
|
const sessionId = url.searchParams.get('session') ?? ''
|
|
144
130
|
try {
|
|
131
|
+
const projectRoot = sessionId ? platform.projectRoot(sessionId) : undefined
|
|
132
|
+
const root = url.searchParams.get('root') ?? (projectRoot ? join(projectRoot, '.fabrika') : null)
|
|
133
|
+
if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
|
|
145
134
|
const state = derivePhaseState(root, sessionId, gateOracleFor(sessionManager))
|
|
146
135
|
return jsonResponse(state)
|
|
147
136
|
} catch (error: unknown) {
|
|
@@ -152,12 +141,12 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
152
141
|
async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
153
142
|
const sessionId = routeParam(match, 'id')
|
|
154
143
|
try {
|
|
155
|
-
const parsedBody
|
|
156
|
-
if (!
|
|
157
|
-
const body = parsedBody
|
|
144
|
+
const parsedBody = planInputSchema.safeParse(await req.json())
|
|
145
|
+
if (!parsedBody.success) return jsonResponse({ error: 'Expected a JSON object' }, 400)
|
|
146
|
+
const body = parsedBody.data
|
|
158
147
|
let selection: CanvasSelection
|
|
159
148
|
try {
|
|
160
|
-
selection = resolveCanvasSelection(body)
|
|
149
|
+
selection = resolveCanvasSelection(body, assertReadable)
|
|
161
150
|
} catch (error) {
|
|
162
151
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400)
|
|
163
152
|
}
|
|
@@ -177,17 +166,18 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
177
166
|
)
|
|
178
167
|
}
|
|
179
168
|
|
|
180
|
-
const
|
|
169
|
+
const boundRoot = platform.projectRoot(sessionId)
|
|
170
|
+
const projectRoot = platform.storage.assertProjectRoot(boundRoot ?? body.projectRoot ?? process.cwd(), true)
|
|
181
171
|
const isNew = !sessionManager.get(sessionId)
|
|
182
|
-
const gate =
|
|
183
|
-
const label = gate ??
|
|
184
|
-
const response =
|
|
172
|
+
const gate = body.gate
|
|
173
|
+
const label = gate ?? body.label
|
|
174
|
+
const response = body.response
|
|
185
175
|
|
|
186
176
|
const compiled = new Map<string, string>()
|
|
187
177
|
const errors: Record<string, string> = {}
|
|
188
178
|
await Promise.all(
|
|
189
179
|
[...canvasFiles.entries()].map(async ([filename, jsx]) => {
|
|
190
|
-
const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR })
|
|
180
|
+
const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR, assertReadable })
|
|
191
181
|
if (result.ok) compiled.set(filename, result.js)
|
|
192
182
|
else errors[filename] = result.error
|
|
193
183
|
}),
|
|
@@ -209,9 +199,9 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
209
199
|
}
|
|
210
200
|
|
|
211
201
|
broadcastPlanUpdate(sessionId)
|
|
212
|
-
watchSession(sessionId, sessionManager, broadcastPlanUpdate)
|
|
202
|
+
watchSession(sessionId, sessionManager, broadcastPlanUpdate, { tempDir: COMPILE_TEMP_DIR, assertReadable })
|
|
213
203
|
|
|
214
|
-
const browserUrl =
|
|
204
|
+
const browserUrl = `${builderDaemonUrl(ctx.port)}/s/${sessionId}`
|
|
215
205
|
return jsonResponse({
|
|
216
206
|
ok: true,
|
|
217
207
|
browserUrl,
|
|
@@ -233,32 +223,42 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
233
223
|
const revParam = url.searchParams.get('rev')
|
|
234
224
|
const rev = revParam ? parseInt(revParam, 10) : undefined
|
|
235
225
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
226
|
+
const session = sessionManager.get(sessionId)
|
|
227
|
+
const revision = rev ?? session?.currentRevision
|
|
228
|
+
if (!session || !revision || !session.revisions.some((r) => r.revision === revision && r.canvasFiles.some((f) => f.filename === jsxFilename))) {
|
|
229
|
+
return jsonResponse({ error: 'No compiled canvas' }, 404)
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const projectRoot = platform.storage.assertProjectRoot(session.projectRoot)
|
|
233
|
+
const jsx = readCanvasFile(sessionManager.getRevisionJsxPath(sessionId, revision, jsxFilename), assertReadable)
|
|
234
|
+
// Stored bundles predate admission and dependencies can change after a successful compile.
|
|
235
|
+
const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR, assertReadable })
|
|
236
|
+
if (!result.ok) return jsonResponse({ error: 'Canvas compilation failed' }, 400)
|
|
237
|
+
sessionManager.saveCompiled(sessionId, jsxFilename, result.js, revision)
|
|
238
|
+
return new Response(result.js, {
|
|
239
|
+
headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
|
|
240
|
+
})
|
|
241
|
+
} catch {
|
|
242
|
+
return jsonResponse({ error: 'Canvas file access refused' }, 403)
|
|
250
243
|
}
|
|
251
|
-
|
|
252
|
-
if (!compiled) return jsonResponse({ error: 'No compiled canvas' }, 404)
|
|
253
|
-
return new Response(compiled, {
|
|
254
|
-
headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
|
|
255
|
-
})
|
|
256
244
|
}
|
|
257
245
|
|
|
258
246
|
function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
259
247
|
const sessionId = routeParam(match, 'id')
|
|
260
248
|
const session = sessionManager.get(sessionId)
|
|
261
|
-
if (!session)
|
|
249
|
+
if (!session) {
|
|
250
|
+
const project = platform.storage.project(sessionId)
|
|
251
|
+
if (!project) return jsonResponse({ error: 'Session not found' }, 404)
|
|
252
|
+
const config = platform.storage.config(project)
|
|
253
|
+
return jsonResponse({
|
|
254
|
+
projectRoot: project.projectRoot,
|
|
255
|
+
currentRevision: 0,
|
|
256
|
+
revisions: [],
|
|
257
|
+
canvasFiles: [],
|
|
258
|
+
createdAt: config.createdAt,
|
|
259
|
+
updatedAt: config.createdAt,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
262
|
return jsonResponse({
|
|
263
263
|
projectRoot: session.projectRoot,
|
|
264
264
|
currentRevision: session.currentRevision,
|
|
@@ -271,23 +271,20 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
271
271
|
|
|
272
272
|
function handleArtifact(_req: Request, url: URL, match: URLPatternResult): Response {
|
|
273
273
|
const sessionId = routeParam(match, 'id')
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
274
|
+
const projectRoot = platform.projectRoot(sessionId)
|
|
275
|
+
if (!projectRoot) return jsonResponse({ error: 'Session not found' }, 404)
|
|
276
276
|
|
|
277
277
|
const rel = url.searchParams.get('path')
|
|
278
278
|
if (!rel) return jsonResponse({ error: 'Missing ?path=<relative path>' }, 400)
|
|
279
279
|
|
|
280
|
-
const root = resolve(session.projectRoot)
|
|
281
|
-
const target = resolve(root, rel)
|
|
282
|
-
if (target !== root && !target.startsWith(root + sep)) {
|
|
283
|
-
return jsonResponse({ error: 'Path escapes project root' }, 403)
|
|
284
|
-
}
|
|
285
280
|
try {
|
|
281
|
+
const target = platform.storage.artifact(projectRoot, rel)
|
|
282
|
+
platform.storage.assertPublicFile(target)
|
|
286
283
|
const st = statSync(target)
|
|
287
284
|
if (!st.isFile()) return jsonResponse({ error: 'Not a file' }, 404)
|
|
288
285
|
return jsonResponse({ path: rel, content: readFileSync(target, 'utf-8') })
|
|
289
|
-
} catch {
|
|
290
|
-
return jsonResponse({ error: 'Artifact not found' }, 404)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return jsonResponse({ error: 'Artifact not found or outside the project' }, error instanceof PlatformError ? 403 : 404)
|
|
291
288
|
}
|
|
292
289
|
}
|
|
293
290
|
|
|
@@ -317,6 +314,12 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
317
314
|
const skeletons: unknown[] = []
|
|
318
315
|
for (const entry of readdirSync(dir).sort()) {
|
|
319
316
|
if (!/\.test\.ts$/.test(entry)) continue
|
|
317
|
+
try {
|
|
318
|
+
platform.storage.artifact(session.projectRoot, join('tests', 'browser', entry))
|
|
319
|
+
platform.storage.assertPublicFile(join(dir, entry))
|
|
320
|
+
} catch {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
320
323
|
const src = readFileSync(join(dir, entry), 'utf-8')
|
|
321
324
|
const re = /browserTest\s*\(/g
|
|
322
325
|
let mm: RegExpExecArray | null
|
|
@@ -415,9 +418,9 @@ export function createHandlers(ctx: HandlerContext): Route[] {
|
|
|
415
418
|
async function handleBuildEvent(req: Request): Promise<Response> {
|
|
416
419
|
try {
|
|
417
420
|
const body: unknown = await req.json()
|
|
418
|
-
const event =
|
|
419
|
-
if (event
|
|
420
|
-
const ev = buildStream.append(event)
|
|
421
|
+
const event = buildEventSchema.safeParse(body)
|
|
422
|
+
if (!event.success) return jsonResponse({ error: 'Invalid or missing status' }, 400)
|
|
423
|
+
const ev = buildStream.append(event.data)
|
|
421
424
|
return jsonResponse({ ok: true, event: ev })
|
|
422
425
|
} catch (error: unknown) {
|
|
423
426
|
return jsonResponse({ error: errorMessage(error) }, 500)
|
package/daemon/src/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { createWebSocketManager, SessionManager, unwatchAll, type WSData } from '@fabrika/canvas-kernel/server'
|
|
2
2
|
import { dispatch } from '@fabrika/daemon-kit'
|
|
3
3
|
import { resolve } from 'path'
|
|
4
|
+
import { BUILDER_DAEMON_HOST } from '../../shared/urls'
|
|
5
|
+
import { admitRequest } from './admission'
|
|
4
6
|
import { BuildStream } from './build-stream'
|
|
5
|
-
import {
|
|
7
|
+
import { createHandlers } from './handlers'
|
|
6
8
|
import { SESSIONS_DIR } from './paths'
|
|
9
|
+
import { BuilderPlatform, type BuilderPlatformOptions } from './platform'
|
|
10
|
+
import { createPlatformRoutes } from './platform-routes'
|
|
7
11
|
import { createStaticHandlers } from './static'
|
|
8
12
|
|
|
13
|
+
export { type Phase, type PhaseId, PHASES } from '../../shared/phases'
|
|
9
14
|
export type { BuildEvent } from './build-stream'
|
|
10
|
-
export { derivePhaseState,
|
|
11
|
-
export type { Phase, PhaseId, PhaseState } from './phases'
|
|
15
|
+
export { derivePhaseState, type PhaseState } from './phases'
|
|
12
16
|
|
|
13
17
|
export interface GateRecord {
|
|
14
18
|
name: string
|
|
@@ -23,6 +27,7 @@ const VERSION = '0.0.0'
|
|
|
23
27
|
export interface StartDaemonOptions {
|
|
24
28
|
port?: number
|
|
25
29
|
sessionsDir?: string
|
|
30
|
+
platform?: BuilderPlatformOptions
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface RunningDaemon {
|
|
@@ -40,6 +45,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
|
|
|
40
45
|
sessionManager.cleanupStale()
|
|
41
46
|
const wsManager = createWebSocketManager(sessionManager, { feedbackConsumption: 'external' })
|
|
42
47
|
const buildStream = new BuildStream()
|
|
48
|
+
const platform = new BuilderPlatform((id) => sessionManager.get(id)?.projectRoot, opts.platform)
|
|
43
49
|
|
|
44
50
|
const portRef = { value: port }
|
|
45
51
|
|
|
@@ -48,6 +54,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
|
|
|
48
54
|
broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
|
|
49
55
|
broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
|
|
50
56
|
buildStream,
|
|
57
|
+
platform,
|
|
51
58
|
sessionsDir,
|
|
52
59
|
get port() {
|
|
53
60
|
return portRef.value
|
|
@@ -55,16 +62,18 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
|
|
|
55
62
|
version: VERSION,
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
const routes = [...createHandlers(handlerCtx), ...createStaticHandlers()]
|
|
65
|
+
const routes = [...createPlatformRoutes(platform), ...createHandlers(handlerCtx), ...createStaticHandlers()]
|
|
59
66
|
|
|
60
67
|
const server = Bun.serve<WSData>({
|
|
61
68
|
port,
|
|
62
|
-
hostname:
|
|
69
|
+
hostname: BUILDER_DAEMON_HOST,
|
|
63
70
|
fetch(req, server) {
|
|
64
71
|
const url = new URL(req.url)
|
|
72
|
+
const rejection = admitRequest(req, url, portRef.value)
|
|
73
|
+
if (rejection) return rejection
|
|
65
74
|
|
|
66
75
|
if (req.method === 'OPTIONS') {
|
|
67
|
-
return new Response(null, { status: 204
|
|
76
|
+
return new Response(null, { status: 204 })
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
const wsBrowser = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
|
|
@@ -113,6 +122,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
|
|
|
113
122
|
port: boundPort,
|
|
114
123
|
sessionManager,
|
|
115
124
|
stop() {
|
|
125
|
+
platform.stop()
|
|
116
126
|
try {
|
|
117
127
|
unwatchAll()
|
|
118
128
|
} catch {
|