@fabrika/builder 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUILD-CONTRACT.md +8 -0
- package/README.md +9 -0
- package/cli/src/bin.ts +35 -2
- package/cli/src/build-status.ts +7 -15
- package/cli/src/commands/advance.ts +3 -2
- package/cli/src/commands/ensure-daemon.ts +3 -3
- package/cli/src/commands/gate.ts +4 -10
- package/cli/src/commands/init.ts +44 -7
- package/cli/src/commands/next.ts +3 -3
- package/cli/src/commands/platform.ts +81 -0
- package/cli/src/config.ts +1 -9
- package/cli/src/context.ts +4 -1
- package/cli/src/daemon-client.ts +36 -185
- package/cli/src/daemon-lifecycle.ts +63 -142
- package/cli/src/derive.ts +4 -8
- package/cli/src/errors.ts +1 -0
- package/cli/src/index.ts +2 -3
- package/cli/src/inject-skills.ts +8 -31
- package/cli/src/layout.ts +8 -18
- package/cli/src/packet.ts +18 -17
- package/cli/src/platform-client.ts +40 -0
- package/daemon/src/admission.ts +19 -0
- package/daemon/src/bin.ts +7 -9
- package/daemon/src/handlers.ts +82 -79
- package/daemon/src/index.ts +14 -5
- package/daemon/src/paths.ts +5 -6
- package/daemon/src/phases.ts +27 -126
- package/daemon/src/platform-compiler.ts +21 -0
- package/daemon/src/platform-remote.ts +146 -0
- package/daemon/src/platform-routes.ts +46 -0
- package/daemon/src/platform-source.ts +147 -0
- package/daemon/src/platform-storage.ts +222 -0
- package/daemon/src/platform.ts +481 -0
- package/decisions/README.md +4 -0
- package/decisions/patterns/zerops-control-deploy.md +49 -0
- package/decisions/patterns/zerops-local-app.md +57 -0
- package/decisions/patterns/zerops-postgres-seeds.md +37 -0
- package/decisions/technologies/zerops-fabrika-auth.md +53 -0
- package/decisions/technologies/zerops-platform.md +64 -0
- package/package.json +13 -5
- package/shared/index.ts +365 -0
- package/{cli/src → shared}/phases.ts +42 -50
- package/shared/urls.ts +41 -0
- package/skill/SKILL.md +34 -0
- package/stacks/README.md +2 -0
- package/stacks/zerops-app.md +66 -0
- package/ui/dist/assets/client.2fkhreps.js +214 -0
- package/ui/dist/assets/client.css +1 -1
- package/ui/dist/assets/components.js +57 -57
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/client.fhjppkvr.js +0 -214
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import {
|
|
21
|
+
type BuilderDeploymentState,
|
|
22
|
+
builderDeploymentStateSchema,
|
|
23
|
+
type BuilderProjectConfig,
|
|
24
|
+
type BuilderProjectConnection,
|
|
25
|
+
builderProjectConnectionSchema,
|
|
26
|
+
parseBuilderProjectConfig,
|
|
27
|
+
} from '../../shared'
|
|
28
|
+
|
|
29
|
+
export class PlatformError extends Error {}
|
|
30
|
+
|
|
31
|
+
export function within(root: string, path: string): boolean {
|
|
32
|
+
const rel = relative(root, path)
|
|
33
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const storedTextSchema = z.string().min(1).max(8192)
|
|
37
|
+
export const storedIntegerSchema = z.number().int().safe().nonnegative()
|
|
38
|
+
const projectRecordSchema = z.object({
|
|
39
|
+
sessionId: storedTextSchema,
|
|
40
|
+
projectRoot: storedTextSchema,
|
|
41
|
+
deployment: builderDeploymentStateSchema.nullable(),
|
|
42
|
+
recoveryRequired: z.boolean(),
|
|
43
|
+
})
|
|
44
|
+
const storedCredentialSchema = z.object({
|
|
45
|
+
connection: builderProjectConnectionSchema,
|
|
46
|
+
token: storedTextSchema,
|
|
47
|
+
expiresAt: storedIntegerSchema,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export function noSymlinks(path: string): void {
|
|
51
|
+
const absolute = resolve(path)
|
|
52
|
+
let current = parse(absolute).root
|
|
53
|
+
for (const component of relative(current, absolute).split(sep).filter(Boolean)) {
|
|
54
|
+
current = join(current, component)
|
|
55
|
+
if (lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink()) throw new PlatformError('Symlinks are not allowed in platform storage paths')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function privateDirectory(path: string, create: boolean): void {
|
|
60
|
+
noSymlinks(path)
|
|
61
|
+
if (create) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
62
|
+
const stat = lstatSync(path, { throwIfNoEntry: false })
|
|
63
|
+
if (!stat) return
|
|
64
|
+
if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid())) {
|
|
65
|
+
throw new PlatformError('Platform storage must be an owner-only directory')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readJson(path: string, privateFile = false): unknown {
|
|
70
|
+
noSymlinks(path)
|
|
71
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
72
|
+
try {
|
|
73
|
+
const stat = fstatSync(fd)
|
|
74
|
+
if (!stat.isFile() || stat.size > 1024 * 1024 || stat.nlink !== 1) throw new PlatformError('Invalid platform storage file')
|
|
75
|
+
if (privateFile && ((stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()))) {
|
|
76
|
+
throw new PlatformError('Credential file must be owner-only')
|
|
77
|
+
}
|
|
78
|
+
return JSON.parse(readFileSync(fd, 'utf8'))
|
|
79
|
+
} finally {
|
|
80
|
+
closeSync(fd)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeJson(path: string, value: unknown): void {
|
|
85
|
+
noSymlinks(path)
|
|
86
|
+
const temp = join(dirname(path), `.write-${randomUUID()}`)
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(temp, `${JSON.stringify(value, null, '\t')}\n`, { flag: 'wx', mode: 0o600 })
|
|
89
|
+
renameSync(temp, path)
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(temp, { force: true })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ProjectRecord {
|
|
96
|
+
sessionId: string
|
|
97
|
+
projectRoot: string
|
|
98
|
+
deployment: BuilderDeploymentState | null
|
|
99
|
+
recoveryRequired: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface StoredCredential {
|
|
103
|
+
connection: BuilderProjectConnection
|
|
104
|
+
token: string
|
|
105
|
+
expiresAt: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class PlatformStorage {
|
|
109
|
+
constructor(readonly stateDir: string, readonly credentialDir: string, readonly dataDir: string) {}
|
|
110
|
+
|
|
111
|
+
assertProjectRoot(path: string, allowMissing = false): string {
|
|
112
|
+
noSymlinks(path)
|
|
113
|
+
const root = allowMissing && !existsSync(path) ? resolve(path) : realpathSync(path)
|
|
114
|
+
if (
|
|
115
|
+
(existsSync(root) && !lstatSync(root).isDirectory()) || within(root, homedir()) || within(root, resolve(this.credentialDir))
|
|
116
|
+
|| within(root, resolve(this.dataDir))
|
|
117
|
+
|| within(root, resolve(this.stateDir))
|
|
118
|
+
) {
|
|
119
|
+
throw new PlatformError('Project root must not contain home or daemon storage')
|
|
120
|
+
}
|
|
121
|
+
if (within(resolve(this.credentialDir), root) || within(resolve(this.stateDir), root)) {
|
|
122
|
+
throw new PlatformError('Project root cannot be daemon storage')
|
|
123
|
+
}
|
|
124
|
+
return root
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private path(dir: string, id: string, create = false): string {
|
|
128
|
+
privateDirectory(dir, create)
|
|
129
|
+
const path = join(dir, `${createHash('sha256').update(id).digest('hex')}.json`)
|
|
130
|
+
noSymlinks(path)
|
|
131
|
+
return path
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
project(id: string): ProjectRecord | null {
|
|
135
|
+
const path = this.path(this.stateDir, id)
|
|
136
|
+
if (!existsSync(path)) return null
|
|
137
|
+
const project = this.readProject(path)
|
|
138
|
+
if (project.sessionId !== id) throw new PlatformError('Project registration mismatch')
|
|
139
|
+
this.assertProjectRoot(project.projectRoot)
|
|
140
|
+
return project
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
projects(): ProjectRecord[] {
|
|
144
|
+
privateDirectory(this.stateDir, false)
|
|
145
|
+
if (!existsSync(this.stateDir)) return []
|
|
146
|
+
return readdirSync(this.stateDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).map((name) => {
|
|
147
|
+
const path = join(this.stateDir, name)
|
|
148
|
+
const project = this.readProject(path)
|
|
149
|
+
if (this.path(this.stateDir, project.sessionId) !== path) throw new PlatformError('Project registration mismatch')
|
|
150
|
+
return project
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private readProject(path: string): ProjectRecord {
|
|
155
|
+
const result = projectRecordSchema.safeParse(readJson(path, true))
|
|
156
|
+
if (!result.success) {
|
|
157
|
+
if (result.error.issues.some(issue => issue.path[0] === 'recoveryRequired')) throw new PlatformError('Invalid project recovery state')
|
|
158
|
+
if (result.error.issues.some(issue => issue.path[0] === 'sessionId' || issue.path[0] === 'projectRoot')) {
|
|
159
|
+
throw new PlatformError('Invalid stored or upstream data')
|
|
160
|
+
}
|
|
161
|
+
if (result.error.issues.some(issue => issue.path[0] === 'deployment')) throw new Error('Invalid deployment state')
|
|
162
|
+
throw new PlatformError('Invalid stored or upstream data')
|
|
163
|
+
}
|
|
164
|
+
return result.data
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
saveProject(project: ProjectRecord): void {
|
|
168
|
+
this.assertProjectRoot(project.projectRoot)
|
|
169
|
+
writeJson(this.path(this.stateDir, project.sessionId, true), project)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
config(project: ProjectRecord): BuilderProjectConfig {
|
|
173
|
+
const config = parseBuilderProjectConfig(readJson(join(project.projectRoot, '.fabrika', 'config.json')))
|
|
174
|
+
if (config.sessionId !== project.sessionId) throw new PlatformError('Project config session does not match registration')
|
|
175
|
+
return config
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
saveConfig(project: ProjectRecord, config: BuilderProjectConfig): void {
|
|
179
|
+
this.config(project)
|
|
180
|
+
writeJson(join(project.projectRoot, '.fabrika', 'config.json'), parseBuilderProjectConfig(config))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
credential(id: string): StoredCredential | null {
|
|
184
|
+
const path = this.path(this.credentialDir, id)
|
|
185
|
+
if (!existsSync(path)) return null
|
|
186
|
+
const result = storedCredentialSchema.safeParse(readJson(path, true))
|
|
187
|
+
if (!result.success) {
|
|
188
|
+
if (result.error.issues.some(issue => issue.path[0] === 'connection')) throw new Error('Invalid project connection')
|
|
189
|
+
throw new PlatformError('Invalid stored or upstream data')
|
|
190
|
+
}
|
|
191
|
+
return result.data
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
saveCredential(id: string, credential: StoredCredential): void {
|
|
195
|
+
writeJson(this.path(this.credentialDir, id, true), credential)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
removeCredential(id: string): void {
|
|
199
|
+
const path = this.path(this.credentialDir, id)
|
|
200
|
+
noSymlinks(path)
|
|
201
|
+
rmSync(path, { force: true })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
artifact(root: string, path: string): string {
|
|
205
|
+
const safeRoot = this.assertProjectRoot(root)
|
|
206
|
+
const requested = resolve(safeRoot, path)
|
|
207
|
+
if (!within(safeRoot, requested)) throw new PlatformError('Artifact path is outside the project')
|
|
208
|
+
const target = realpathSync(requested)
|
|
209
|
+
if (!within(safeRoot, target) || within(resolve(this.credentialDir), target) || within(resolve(this.stateDir), target)) {
|
|
210
|
+
throw new PlatformError('Artifact path is outside the project')
|
|
211
|
+
}
|
|
212
|
+
return target
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
assertPublicFile(path: string): void {
|
|
216
|
+
const target = realpathSync(path)
|
|
217
|
+
if (within(resolve(this.credentialDir), target) || within(resolve(this.stateDir), target)) {
|
|
218
|
+
throw new PlatformError('Daemon storage cannot be exposed as project content')
|
|
219
|
+
}
|
|
220
|
+
if (lstatSync(target).nlink !== 1) throw new PlatformError('Hard-linked project content is not supported')
|
|
221
|
+
}
|
|
222
|
+
}
|