@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/daemon/src/paths.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { createCanvasPaths } from '@fabrika/canvas-kernel/server'
|
|
2
|
-
import { existsSync } from 'fs'
|
|
3
2
|
import { homedir, tmpdir } from 'os'
|
|
4
3
|
import { join, resolve } from 'path'
|
|
5
4
|
|
|
6
5
|
export const NEUTRAL_DATA_DIR = join(homedir(), '.fabrika')
|
|
7
|
-
export const LEGACY_DATA_DIR = join(homedir(), '.claude', 'fabrika')
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// The CLI passes `--data-dir`; a daemon started by hand falls back to FABRIKA_DATA_DIR.
|
|
8
|
+
export function resolveDataDir(argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): string {
|
|
9
|
+
const flag = argv.indexOf('--data-dir')
|
|
10
|
+
const explicit = (flag === -1 ? undefined : argv[flag + 1]) ?? env['FABRIKA_DATA_DIR']
|
|
11
11
|
if (explicit) return resolve(explicit)
|
|
12
|
-
if (existsSync(NEUTRAL_DATA_DIR)) return NEUTRAL_DATA_DIR
|
|
13
|
-
if (existsSync(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR
|
|
14
12
|
return NEUTRAL_DATA_DIR
|
|
15
13
|
}
|
|
16
14
|
|
|
@@ -18,6 +16,7 @@ export const paths = createCanvasPaths({
|
|
|
18
16
|
appName: 'fabrika',
|
|
19
17
|
dataDir: resolveDataDir(),
|
|
20
18
|
tempDir: join(tmpdir(), 'fabrika'),
|
|
19
|
+
ensure: false,
|
|
21
20
|
})
|
|
22
21
|
|
|
23
22
|
export const DATA_DIR = paths.dataDir
|
package/daemon/src/phases.ts
CHANGED
|
@@ -1,99 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'fs'
|
|
2
2
|
import { basename, dirname, join } from 'path'
|
|
3
|
-
|
|
4
|
-
export type PhaseId =
|
|
5
|
-
| 'intake'
|
|
6
|
-
| 'vision'
|
|
7
|
-
| 'flows'
|
|
8
|
-
| 'tech'
|
|
9
|
-
| 'design'
|
|
10
|
-
| 'skeletons'
|
|
11
|
-
| 'issues'
|
|
12
|
-
| 'build'
|
|
13
|
-
|
|
14
|
-
export interface Phase {
|
|
15
|
-
id: PhaseId
|
|
16
|
-
index: number
|
|
17
|
-
title: string
|
|
18
|
-
reads: string[]
|
|
19
|
-
produces: string[]
|
|
20
|
-
gate: boolean
|
|
21
|
-
gateName?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const PHASES: Phase[] = [
|
|
25
|
-
{
|
|
26
|
-
id: 'intake',
|
|
27
|
-
index: 1,
|
|
28
|
-
title: 'Intake (brief → classification)',
|
|
29
|
-
reads: ['intent/brief.md'],
|
|
30
|
-
produces: ['intent/brief.md'],
|
|
31
|
-
gate: true,
|
|
32
|
-
gateName: 'intake',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
id: 'vision',
|
|
36
|
-
index: 2,
|
|
37
|
-
title: 'Vision + scope + principles',
|
|
38
|
-
reads: ['intent/brief.md'],
|
|
39
|
-
produces: ['intent/glossary.md', 'principles/principles.md'],
|
|
40
|
-
gate: true,
|
|
41
|
-
gateName: 'vision',
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
id: 'flows',
|
|
45
|
-
index: 3,
|
|
46
|
-
title: 'User flows',
|
|
47
|
-
reads: ['intent/*', 'principles/*'],
|
|
48
|
-
produces: ['architecture/flows/*.flow.md'],
|
|
49
|
-
gate: true,
|
|
50
|
-
gateName: 'flows',
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: 'tech',
|
|
54
|
-
index: 4,
|
|
55
|
-
title: 'Tech + scaffold decision',
|
|
56
|
-
reads: ['architecture/flows/*'],
|
|
57
|
-
produces: ['architecture/tech.md'],
|
|
58
|
-
gate: true,
|
|
59
|
-
gateName: 'tech',
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
id: 'design',
|
|
63
|
-
index: 5,
|
|
64
|
-
title: 'Design tokens + mockups',
|
|
65
|
-
reads: ['architecture/flows/*', 'architecture/tech.md'],
|
|
66
|
-
produces: ['architecture/design.md', 'pages/*.jsx'],
|
|
67
|
-
gate: true,
|
|
68
|
-
gateName: 'design',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
id: 'skeletons',
|
|
72
|
-
index: 6,
|
|
73
|
-
title: 'Opice skeletons',
|
|
74
|
-
reads: ['architecture/flows/*', 'architecture/spec/*'],
|
|
75
|
-
produces: ['../tests/browser/*.test.ts'],
|
|
76
|
-
gate: true,
|
|
77
|
-
gateName: 'skeletons',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
id: 'issues',
|
|
81
|
-
index: 7,
|
|
82
|
-
title: 'Induce issues (compile step, not a real phase — SPEC §6.4)',
|
|
83
|
-
reads: ['../tests/browser/*'],
|
|
84
|
-
produces: ['issues/ISSUE-*.md'],
|
|
85
|
-
gate: false,
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
id: 'build',
|
|
89
|
-
index: 8,
|
|
90
|
-
title: 'Build (agent-assisted, supervised subagents)',
|
|
91
|
-
reads: ['issues/*'],
|
|
92
|
-
produces: [],
|
|
93
|
-
gate: true,
|
|
94
|
-
gateName: 'build',
|
|
95
|
-
},
|
|
96
|
-
]
|
|
3
|
+
import { isPhaseHint, type Phase, phaseById, type PhaseId, PHASES } from '../../shared/phases'
|
|
97
4
|
|
|
98
5
|
export interface PhaseState {
|
|
99
6
|
current: PhaseId
|
|
@@ -111,8 +18,8 @@ export interface GateOracle {
|
|
|
111
18
|
hasGateRevision?(sessionId: string, gateName: string): boolean
|
|
112
19
|
}
|
|
113
20
|
|
|
114
|
-
function expandGlob(
|
|
115
|
-
const abs = join(
|
|
21
|
+
function expandGlob(repoRoot: string, glob: string): string[] {
|
|
22
|
+
const abs = join(repoRoot, glob)
|
|
116
23
|
if (!abs.includes('*')) {
|
|
117
24
|
return existsSync(abs) ? [abs] : []
|
|
118
25
|
}
|
|
@@ -144,10 +51,10 @@ function isNonEmptyFile(p: string): boolean {
|
|
|
144
51
|
}
|
|
145
52
|
}
|
|
146
53
|
|
|
147
|
-
export function producesSatisfied(phase: Phase,
|
|
148
|
-
if (phase.produces.length === 0) return false
|
|
54
|
+
export function producesSatisfied(phase: Phase, repoRoot: string): boolean {
|
|
149
55
|
for (const glob of phase.produces) {
|
|
150
|
-
|
|
56
|
+
if (isPhaseHint(glob)) return false
|
|
57
|
+
const matches = expandGlob(repoRoot, glob).filter(isNonEmptyFile)
|
|
151
58
|
if (matches.length === 0) return false
|
|
152
59
|
}
|
|
153
60
|
return true
|
|
@@ -161,17 +68,14 @@ export function gateSatisfied(
|
|
|
161
68
|
if (!phase.gate) return true
|
|
162
69
|
if (!oracle) return true
|
|
163
70
|
if (oracle.getLatestUnconsumedFeedback(sessionId) !== null) return false
|
|
164
|
-
|
|
165
|
-
return oracle.hasGateRevision(sessionId, phase.gateName)
|
|
166
|
-
}
|
|
167
|
-
return true
|
|
71
|
+
return oracle.hasGateRevision?.(sessionId, phase.id) ?? true
|
|
168
72
|
}
|
|
169
73
|
|
|
170
|
-
function resolveReads(phase: Phase,
|
|
74
|
+
function resolveReads(phase: Phase, repoRoot: string): string[] {
|
|
171
75
|
const out: string[] = []
|
|
172
|
-
for (const glob of phase.reads) {
|
|
76
|
+
for (const glob of phase.reads.filter((read) => !isPhaseHint(read))) {
|
|
173
77
|
out.push(
|
|
174
|
-
...expandGlob(
|
|
78
|
+
...expandGlob(repoRoot, glob).filter(p => {
|
|
175
79
|
try {
|
|
176
80
|
return statSync(p).size > 0 || statSync(p).isDirectory()
|
|
177
81
|
} catch {
|
|
@@ -188,34 +92,31 @@ export function derivePhaseState(
|
|
|
188
92
|
sessionId = '',
|
|
189
93
|
oracle?: GateOracle,
|
|
190
94
|
): PhaseState {
|
|
95
|
+
const repoRoot = dirname(fabrikaDir)
|
|
191
96
|
const completed: PhaseId[] = []
|
|
192
97
|
|
|
193
98
|
for (const phase of PHASES) {
|
|
194
|
-
|
|
99
|
+
const satisfied = producesSatisfied(phase, repoRoot)
|
|
100
|
+
if (satisfied && gateSatisfied(phase, sessionId, oracle)) {
|
|
195
101
|
completed.push(phase.id)
|
|
196
102
|
continue
|
|
197
103
|
}
|
|
198
|
-
return
|
|
199
|
-
current: phase.id,
|
|
200
|
-
index: phase.index,
|
|
201
|
-
completed,
|
|
202
|
-
reads: resolveReads(phase, fabrikaDir),
|
|
203
|
-
produces: phase.produces,
|
|
204
|
-
gate: phase.gate,
|
|
205
|
-
...(phase.gateName ? { gateName: phase.gateName } : {}),
|
|
206
|
-
satisfied: producesSatisfied(phase, fabrikaDir),
|
|
207
|
-
}
|
|
104
|
+
return toState(phase, repoRoot, completed, satisfied)
|
|
208
105
|
}
|
|
209
106
|
|
|
210
|
-
const build =
|
|
107
|
+
const build = phaseById('build')
|
|
108
|
+
return toState(build, repoRoot, completed.filter(id => id !== build.id), false)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toState(phase: Phase, repoRoot: string, completed: PhaseId[], satisfied: boolean): PhaseState {
|
|
211
112
|
return {
|
|
212
|
-
current:
|
|
213
|
-
index:
|
|
214
|
-
completed
|
|
215
|
-
reads: resolveReads(
|
|
216
|
-
produces:
|
|
217
|
-
gate:
|
|
218
|
-
...(
|
|
219
|
-
satisfied
|
|
113
|
+
current: phase.id,
|
|
114
|
+
index: phase.number,
|
|
115
|
+
completed,
|
|
116
|
+
reads: resolveReads(phase, repoRoot),
|
|
117
|
+
produces: phase.produces,
|
|
118
|
+
gate: phase.gate,
|
|
119
|
+
...(phase.gate ? { gateName: phase.id } : {}),
|
|
120
|
+
satisfied,
|
|
220
121
|
}
|
|
221
122
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { compileFabrikaManifest, createZeropsArtifactSourceDescriptor, isZeropsAppConfig } from '@fabrika/provider-zerops'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
// This entry point is executed only in the credential-free child process.
|
|
6
|
+
async function compile(): Promise<void> {
|
|
7
|
+
const env = process.argv[2]
|
|
8
|
+
const output = process.argv[3]
|
|
9
|
+
if (!env || !output) throw new Error('Missing compiler arguments')
|
|
10
|
+
const module: unknown = await import(pathToFileURL(join(process.cwd(), 'fabrika.config.ts')).href)
|
|
11
|
+
const config: unknown = typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
|
|
12
|
+
if (!isZeropsAppConfig(config)) throw new Error('Unsupported project config')
|
|
13
|
+
const source = await createZeropsArtifactSourceDescriptor(await Bun.file('zerops.yaml').text())
|
|
14
|
+
await Bun.write(output, JSON.stringify(compileFabrikaManifest(config, env, source)))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (import.meta.main) {
|
|
18
|
+
await compile().catch(() => {
|
|
19
|
+
process.exitCode = 1
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createRpcClient, type RpcFetch } from '@fabrika/app'
|
|
2
|
+
import type { AppEnvDto, ControlRpcContract, JsonValue, ProviderEnvelopeDto, RunStatus } from '@fabrika/control-contract'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { parseBuilderOrigin } from '../../shared'
|
|
5
|
+
import { PlatformError, type StoredCredential, storedIntegerSchema, storedTextSchema } from './platform-storage'
|
|
6
|
+
|
|
7
|
+
const discoveryInputSchema = z.object({ version: z.literal(1), controlOrigin: z.unknown(), iamIssuer: z.unknown(), provider: z.unknown() })
|
|
8
|
+
.strict().refine(value => Object.keys(value).length === 4)
|
|
9
|
+
const rpcResultSchema = z.object({ result: z.unknown() }).passthrough().refine(value =>
|
|
10
|
+
Object.hasOwn(value, 'result') && !Object.hasOwn(value, 'error')
|
|
11
|
+
)
|
|
12
|
+
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
|
13
|
+
z.union([
|
|
14
|
+
z.null(),
|
|
15
|
+
z.string(),
|
|
16
|
+
z.boolean(),
|
|
17
|
+
z.number().finite(),
|
|
18
|
+
z.array(jsonValueSchema),
|
|
19
|
+
z.record(jsonValueSchema),
|
|
20
|
+
])
|
|
21
|
+
)
|
|
22
|
+
const envelopeSchema: z.ZodType<ProviderEnvelopeDto> = z.object({
|
|
23
|
+
provider: storedTextSchema,
|
|
24
|
+
version: storedIntegerSchema,
|
|
25
|
+
payload: jsonValueSchema,
|
|
26
|
+
})
|
|
27
|
+
const environmentsSchema = z.object({
|
|
28
|
+
items: z.array(z.object({
|
|
29
|
+
appId: storedTextSchema,
|
|
30
|
+
env: storedTextSchema,
|
|
31
|
+
domain: storedTextSchema.nullable(),
|
|
32
|
+
publicOrigin: storedTextSchema.nullable(),
|
|
33
|
+
triggerRef: storedTextSchema.nullable(),
|
|
34
|
+
namespaceId: storedTextSchema.nullable(),
|
|
35
|
+
provider: storedTextSchema,
|
|
36
|
+
target: envelopeSchema,
|
|
37
|
+
artifact: envelopeSchema,
|
|
38
|
+
createdAt: storedIntegerSchema,
|
|
39
|
+
})),
|
|
40
|
+
})
|
|
41
|
+
const runSchema = z.object({
|
|
42
|
+
id: storedTextSchema,
|
|
43
|
+
appId: storedTextSchema,
|
|
44
|
+
env: storedTextSchema,
|
|
45
|
+
ref: storedTextSchema,
|
|
46
|
+
commitSha: storedTextSchema.nullable(),
|
|
47
|
+
status: z.enum(['pending', 'running', 'succeeded', 'failed']),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export async function boundedJson(response: Response): Promise<unknown> {
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
await response.body?.cancel()
|
|
53
|
+
throw new PlatformError(`Platform request failed (HTTP ${response.status})`)
|
|
54
|
+
}
|
|
55
|
+
const reader = response.body?.getReader()
|
|
56
|
+
if (!reader) throw new PlatformError('Platform response is empty')
|
|
57
|
+
let size = 0
|
|
58
|
+
const chunks: Uint8Array[] = []
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const part = await reader.read()
|
|
62
|
+
if (part.done) break
|
|
63
|
+
size += part.value.byteLength
|
|
64
|
+
if (size > 1024 * 1024) throw new PlatformError('Platform response exceeds size limit')
|
|
65
|
+
chunks.push(part.value)
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
68
|
+
} finally {
|
|
69
|
+
await reader.cancel()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class PlatformRemote {
|
|
74
|
+
constructor(private readonly fetcher: RpcFetch = fetch, readonly signal: AbortSignal) {}
|
|
75
|
+
|
|
76
|
+
async json(url: string, body?: unknown): Promise<unknown> {
|
|
77
|
+
try {
|
|
78
|
+
return await boundedJson(
|
|
79
|
+
await this.fetcher(url, {
|
|
80
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
81
|
+
...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }),
|
|
82
|
+
redirect: 'error',
|
|
83
|
+
credentials: 'omit',
|
|
84
|
+
signal: AbortSignal.any([this.signal, AbortSignal.timeout(30_000)]),
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw error instanceof PlatformError ? error : new PlatformError('Platform request failed or returned invalid data')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async discover(origin: string): Promise<{ controlOrigin: string; iamIssuer: string }> {
|
|
93
|
+
const result = discoveryInputSchema.safeParse(await this.json(`${origin}/.well-known/fabrika-builder`))
|
|
94
|
+
if (!result.success) throw new PlatformError('Unsupported builder discovery response')
|
|
95
|
+
const discovery = result.data
|
|
96
|
+
const controlOrigin = parseBuilderOrigin(discovery.controlOrigin, 'Control origin')
|
|
97
|
+
const iamIssuer = parseBuilderOrigin(discovery.iamIssuer, 'IAM issuer')
|
|
98
|
+
if (controlOrigin !== origin) throw new PlatformError('Discovery Control origin does not match the requested installation')
|
|
99
|
+
if (discovery.provider !== 'zerops') throw new PlatformError('Unsupported provider: this builder release supports Zerops only')
|
|
100
|
+
return { controlOrigin, iamIssuer }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
control(credential: StoredCredential) {
|
|
104
|
+
return this.controlForReceipt(credential.connection.controlOrigin, credential.token)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
controlForReceipt(controlOrigin: string, token: string) {
|
|
108
|
+
const endpoint = `${controlOrigin}/api/rpc`
|
|
109
|
+
return createRpcClient<ControlRpcContract>({
|
|
110
|
+
baseUrl: endpoint,
|
|
111
|
+
fetch: async (input, init) => {
|
|
112
|
+
if (input !== endpoint) throw new PlatformError('Control endpoint mismatch')
|
|
113
|
+
const headers = new Headers(init?.headers)
|
|
114
|
+
headers.set('authorization', `Bearer ${token}`)
|
|
115
|
+
const response = await this.fetcher(input, {
|
|
116
|
+
...init,
|
|
117
|
+
headers,
|
|
118
|
+
credentials: 'omit',
|
|
119
|
+
redirect: 'error',
|
|
120
|
+
signal: AbortSignal.any([this.signal, AbortSignal.timeout(30_000)]),
|
|
121
|
+
})
|
|
122
|
+
const data = rpcResultSchema.safeParse(await boundedJson(response))
|
|
123
|
+
if (!data.success) throw new PlatformError('Control refused the request')
|
|
124
|
+
return Response.json({ result: data.data.result })
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseEnvironments(value: unknown): AppEnvDto[] {
|
|
131
|
+
const result = environmentsSchema.safeParse(value)
|
|
132
|
+
if (!result.success) {
|
|
133
|
+
const invalidList = result.error.issues.some(issue => issue.path.length === 1 && issue.path[0] === 'items')
|
|
134
|
+
throw new PlatformError(invalidList ? 'Invalid environment list' : 'Invalid stored or upstream data')
|
|
135
|
+
}
|
|
136
|
+
return result.data.items
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseRun(value: unknown): { id: string; appId: string; env: string; ref: string; commitSha: string | null; status: RunStatus } {
|
|
140
|
+
const result = runSchema.safeParse(value)
|
|
141
|
+
if (!result.success) {
|
|
142
|
+
const invalidStatus = result.error.issues.some(issue => issue.path[0] === 'status')
|
|
143
|
+
throw new PlatformError(invalidStatus ? 'Invalid deploy run status' : 'Invalid stored or upstream data')
|
|
144
|
+
}
|
|
145
|
+
return result.data
|
|
146
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Route } from '@fabrika/daemon-kit'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { type BuilderPlatformStatus, parseConnectProjectInput, parsePreviewProjectInput, parseRegisterProjectInput } from '../../shared'
|
|
4
|
+
import type { BuilderPlatform } from './platform'
|
|
5
|
+
import { boundedJson } from './platform-remote'
|
|
6
|
+
import { PlatformError } from './platform-storage'
|
|
7
|
+
|
|
8
|
+
const emptyActionSchema = z.object({}).strict()
|
|
9
|
+
|
|
10
|
+
export function createPlatformRoutes(platform: BuilderPlatform): Route[] {
|
|
11
|
+
return ['register', 'platform', 'connect', 'disconnect', 'deploy', 'preview'].map((action) => ({
|
|
12
|
+
method: action === 'platform' ? 'GET' : 'POST',
|
|
13
|
+
pattern: new URLPattern({ pathname: `/api/project/:id/${action}` }),
|
|
14
|
+
handler: async (req, _url, match) => {
|
|
15
|
+
try {
|
|
16
|
+
const id = match.pathname.groups['id']
|
|
17
|
+
if (!id) throw new PlatformError('Missing project ID')
|
|
18
|
+
if (action === 'platform') return Response.json(platform.status(id), { headers: { 'cache-control': 'no-store' } })
|
|
19
|
+
const body = await boundedJson(new Response(req.body))
|
|
20
|
+
let status: BuilderPlatformStatus
|
|
21
|
+
switch (action) {
|
|
22
|
+
case 'register':
|
|
23
|
+
status = platform.register(id, parseRegisterProjectInput(body).projectRoot)
|
|
24
|
+
break
|
|
25
|
+
case 'connect':
|
|
26
|
+
status = await platform.connect(id, parseConnectProjectInput(body).controlOrigin)
|
|
27
|
+
break
|
|
28
|
+
case 'preview':
|
|
29
|
+
status = platform.preview(id, parsePreviewProjectInput(body).url)
|
|
30
|
+
break
|
|
31
|
+
default:
|
|
32
|
+
if (!emptyActionSchema.safeParse(body).success) throw new PlatformError('Action expects an empty JSON object')
|
|
33
|
+
status = action === 'deploy' ? platform.deploy(id) : platform.disconnect(id)
|
|
34
|
+
}
|
|
35
|
+
return Response.json(status, { headers: { 'cache-control': 'no-store' } })
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return Response.json({
|
|
38
|
+
error: {
|
|
39
|
+
code: 'builder_platform',
|
|
40
|
+
message: error instanceof PlatformError ? error.message : 'Invalid project request or unavailable local storage',
|
|
41
|
+
},
|
|
42
|
+
}, { status: 400, headers: { 'cache-control': 'no-store' } })
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { AppDto } from '@fabrika/control-contract'
|
|
2
|
+
import {
|
|
3
|
+
type FabrikaManifest,
|
|
4
|
+
normalizeZeropsSourceRepository,
|
|
5
|
+
parseFabrikaManifest,
|
|
6
|
+
verifyZeropsArtifactSourceDescriptor,
|
|
7
|
+
} from '@fabrika/provider-zerops'
|
|
8
|
+
import { chmodSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { noSymlinks, PlatformError } from './platform-storage'
|
|
12
|
+
|
|
13
|
+
async function git(root: string, args: string[], signal: AbortSignal): Promise<string> {
|
|
14
|
+
const child = Bun.spawn(['git', '-C', root, ...args], {
|
|
15
|
+
env: gitEnvironment(process.env),
|
|
16
|
+
stdout: 'pipe',
|
|
17
|
+
stderr: 'ignore',
|
|
18
|
+
stdin: 'ignore',
|
|
19
|
+
signal,
|
|
20
|
+
timeout: 60_000,
|
|
21
|
+
})
|
|
22
|
+
const [output, code] = await Promise.all([new Response(child.stdout).text(), child.exited])
|
|
23
|
+
if (code !== 0 || output.length > 1024 * 1024) throw new PlatformError('Git verification failed; check the repository and your Git credentials')
|
|
24
|
+
return output.trim()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repositoryIdentity(url: string): string {
|
|
28
|
+
const ssh = /^git@github\.com:([^\s]+)$/.exec(url) ?? /^ssh:\/\/git@github\.com\/([^\s]+)$/.exec(url)
|
|
29
|
+
try {
|
|
30
|
+
const repository = normalizeZeropsSourceRepository(ssh ? `https://github.com/${ssh[1]}` : url)
|
|
31
|
+
return `${repository.owner}/${repository.name}`
|
|
32
|
+
} catch {
|
|
33
|
+
throw new PlatformError('Source must identify the approved GitHub repository')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function verifySource(root: string, app: AppDto, signal: AbortSignal): Promise<string> {
|
|
38
|
+
if (realpathSync(await git(root, ['rev-parse', '--show-toplevel'], signal)) !== root) {
|
|
39
|
+
throw new PlatformError('Project must be the Git repository root')
|
|
40
|
+
}
|
|
41
|
+
if (app.workerDir && app.workerDir !== '.') throw new PlatformError('Builder deployment requires repository-root application sources')
|
|
42
|
+
if (app.configPath && app.configPath !== 'fabrika.config.ts' && app.configPath !== './fabrika.config.ts') {
|
|
43
|
+
throw new PlatformError('Approved application config path does not match')
|
|
44
|
+
}
|
|
45
|
+
if (await git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal)) {
|
|
46
|
+
throw new PlatformError('Commit all tracked and untracked source changes before deploying')
|
|
47
|
+
}
|
|
48
|
+
for (const name of ['fabrika.config.ts', 'zerops.yaml']) {
|
|
49
|
+
noSymlinks(join(root, name))
|
|
50
|
+
if (!lstatSync(join(root, name)).isFile()) throw new PlatformError('Application config and descriptor must be regular files')
|
|
51
|
+
const entry = await git(root, ['ls-files', '--stage', '--', name], signal)
|
|
52
|
+
if (!/^100(?:644|755) [0-9a-f]{40,64} 0\t/.test(entry)) {
|
|
53
|
+
throw new PlatformError('Application config and descriptor must be tracked nonsymlink files')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sha = await git(root, ['rev-parse', '--verify', 'HEAD^{commit}'], signal)
|
|
57
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) throw new PlatformError('Repository must have a full SHA-1 HEAD commit')
|
|
58
|
+
// Read the configured URL, not `remote get-url`, which expands local transport rewrites.
|
|
59
|
+
const remote = await git(root, ['config', '--get', 'remote.origin.url'], signal)
|
|
60
|
+
if (repositoryIdentity(remote) !== repositoryIdentity(app.repoUrl)) {
|
|
61
|
+
throw new PlatformError('Repository origin does not match the approved application')
|
|
62
|
+
}
|
|
63
|
+
const branch = await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal)
|
|
64
|
+
const refs = await git(root, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], signal)
|
|
65
|
+
if (refs !== `${sha}\trefs/heads/${branch}`) throw new PlatformError('Push the current branch HEAD before deploying')
|
|
66
|
+
return sha
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function compilerEnvironment(source: Readonly<Record<string, string | undefined>>): Record<string, string> {
|
|
70
|
+
const result: Record<string, string> = { NODE_ENV: 'production' }
|
|
71
|
+
for (const key of ['PATH', 'LANG', 'LC_ALL', 'TZ']) {
|
|
72
|
+
const value = source[key]
|
|
73
|
+
if (value !== undefined) result[key] = value
|
|
74
|
+
}
|
|
75
|
+
return result
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function gitEnvironment(source: Readonly<Record<string, string | undefined>>): Record<string, string> {
|
|
79
|
+
const result: Record<string, string> = {}
|
|
80
|
+
for (
|
|
81
|
+
const key of [
|
|
82
|
+
'HOME',
|
|
83
|
+
'USER',
|
|
84
|
+
'LOGNAME',
|
|
85
|
+
'PATH',
|
|
86
|
+
'LANG',
|
|
87
|
+
'LC_ALL',
|
|
88
|
+
'TERM',
|
|
89
|
+
'DISPLAY',
|
|
90
|
+
'WAYLAND_DISPLAY',
|
|
91
|
+
'XDG_CONFIG_HOME',
|
|
92
|
+
'SSH_AUTH_SOCK',
|
|
93
|
+
'SSH_AGENT_PID',
|
|
94
|
+
'SSH_ASKPASS',
|
|
95
|
+
'SSH_ASKPASS_REQUIRE',
|
|
96
|
+
'GIT_ASKPASS',
|
|
97
|
+
'GIT_SSH',
|
|
98
|
+
'GIT_SSH_COMMAND',
|
|
99
|
+
'GIT_SSH_VARIANT',
|
|
100
|
+
'GIT_TERMINAL_PROMPT',
|
|
101
|
+
'GIT_CONFIG_GLOBAL',
|
|
102
|
+
'GIT_CONFIG_SYSTEM',
|
|
103
|
+
'GIT_CONFIG_NOSYSTEM',
|
|
104
|
+
'GIT_SSL_CAINFO',
|
|
105
|
+
'GIT_SSL_CAPATH',
|
|
106
|
+
'SSL_CERT_FILE',
|
|
107
|
+
'SSL_CERT_DIR',
|
|
108
|
+
]
|
|
109
|
+
) {
|
|
110
|
+
const value = source[key]
|
|
111
|
+
if (value !== undefined) result[key] = value
|
|
112
|
+
}
|
|
113
|
+
return result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function compileProject(root: string, env: string, appId: string, tempParent: string, signal: AbortSignal): Promise<FabrikaManifest> {
|
|
117
|
+
noSymlinks(tempParent)
|
|
118
|
+
const directory = mkdtempSync(join(tempParent, 'compile-'))
|
|
119
|
+
chmodSync(directory, 0o700)
|
|
120
|
+
const output = join(directory, 'manifest.json')
|
|
121
|
+
try {
|
|
122
|
+
const child = Bun.spawn([process.execPath, '--no-env-file', fileURLToPath(new URL('./platform-compiler.ts', import.meta.url)), env, output], {
|
|
123
|
+
cwd: root,
|
|
124
|
+
env: compilerEnvironment(process.env),
|
|
125
|
+
stdout: 'ignore',
|
|
126
|
+
stderr: 'ignore',
|
|
127
|
+
stdin: 'ignore',
|
|
128
|
+
signal,
|
|
129
|
+
timeout: 120_000,
|
|
130
|
+
})
|
|
131
|
+
if (await child.exited !== 0) throw new PlatformError('Application compilation failed')
|
|
132
|
+
noSymlinks(output)
|
|
133
|
+
const stat = lstatSync(output)
|
|
134
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new PlatformError('Invalid compiler result')
|
|
135
|
+
const parsed: unknown = JSON.parse(readFileSync(output, 'utf8'))
|
|
136
|
+
const manifest = parseFabrikaManifest(parsed, { appId, env })
|
|
137
|
+
await verifyZeropsArtifactSourceDescriptor(manifest.target.sourceDescriptor)
|
|
138
|
+
if (manifest.target.sourceDescriptor.contents !== readFileSync(join(root, 'zerops.yaml'), 'utf8')) {
|
|
139
|
+
throw new PlatformError('Compiled descriptor differs from the source')
|
|
140
|
+
}
|
|
141
|
+
return manifest
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw error instanceof PlatformError ? error : new PlatformError('Application compilation returned an invalid manifest')
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(directory, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
}
|