@fabrika/builder 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/BUILD-CONTRACT.md +8 -0
  2. package/README.md +9 -0
  3. package/cli/src/bin.ts +35 -2
  4. package/cli/src/build-status.ts +7 -15
  5. package/cli/src/commands/advance.ts +3 -2
  6. package/cli/src/commands/ensure-daemon.ts +3 -3
  7. package/cli/src/commands/gate.ts +4 -10
  8. package/cli/src/commands/init.ts +44 -7
  9. package/cli/src/commands/next.ts +3 -3
  10. package/cli/src/commands/platform.ts +81 -0
  11. package/cli/src/config.ts +4 -11
  12. package/cli/src/context.ts +4 -1
  13. package/cli/src/daemon-client.ts +36 -185
  14. package/cli/src/daemon-lifecycle.ts +63 -142
  15. package/cli/src/derive.ts +4 -8
  16. package/cli/src/errors.ts +1 -0
  17. package/cli/src/index.ts +2 -3
  18. package/cli/src/inject-skills.ts +8 -31
  19. package/cli/src/layout.ts +8 -18
  20. package/cli/src/packet.ts +18 -17
  21. package/cli/src/platform-client.ts +40 -0
  22. package/daemon/src/admission.ts +21 -0
  23. package/daemon/src/bin.ts +12 -14
  24. package/daemon/src/handlers.ts +84 -81
  25. package/daemon/src/index.ts +16 -6
  26. package/daemon/src/paths.ts +5 -6
  27. package/daemon/src/phases.ts +27 -126
  28. package/daemon/src/platform-compiler.ts +21 -0
  29. package/daemon/src/platform-remote.ts +146 -0
  30. package/daemon/src/platform-routes.ts +46 -0
  31. package/daemon/src/platform-source.ts +147 -0
  32. package/daemon/src/platform-storage.ts +222 -0
  33. package/daemon/src/platform.ts +481 -0
  34. package/decisions/README.md +4 -0
  35. package/decisions/patterns/zerops-control-deploy.md +49 -0
  36. package/decisions/patterns/zerops-local-app.md +57 -0
  37. package/decisions/patterns/zerops-postgres-seeds.md +37 -0
  38. package/decisions/technologies/zerops-fabrika-auth.md +53 -0
  39. package/decisions/technologies/zerops-platform.md +64 -0
  40. package/package.json +13 -5
  41. package/shared/index.ts +365 -0
  42. package/{cli/src → shared}/phases.ts +42 -50
  43. package/shared/urls.ts +48 -0
  44. package/skill/SKILL.md +34 -0
  45. package/stacks/README.md +2 -0
  46. package/stacks/zerops-app.md +66 -0
  47. package/ui/dist/assets/client.css +1 -1
  48. package/ui/dist/assets/client.krrpk10y.js +214 -0
  49. package/ui/dist/assets/components.js +57 -57
  50. package/ui/dist/index.html +1 -1
  51. package/ui/dist/assets/client.fhjppkvr.js +0 -214
@@ -1,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
- export function resolveDataDir(): string {
10
- const explicit = process.env['FABRIKA_DATA_DIR']
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
@@ -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(fabrikaDir: string, glob: string): string[] {
115
- const abs = join(fabrikaDir, glob)
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, fabrikaDir: string): boolean {
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
- const matches = expandGlob(fabrikaDir, glob).filter(isNonEmptyFile)
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
- if (oracle.hasGateRevision && phase.gateName) {
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, fabrikaDir: string): string[] {
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(fabrikaDir, glob).filter(p => {
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
- if (producesSatisfied(phase, fabrikaDir) && gateSatisfied(phase, sessionId, oracle)) {
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 = PHASES[PHASES.length - 1]!
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: build.id,
213
- index: build.index,
214
- completed: completed.filter(id => id !== build.id),
215
- reads: resolveReads(build, fabrikaDir),
216
- produces: build.produces,
217
- gate: build.gate,
218
- ...(build.gateName ? { gateName: build.gateName } : {}),
219
- satisfied: false,
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
+ }