@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.
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 +1 -9
  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 +19 -0
  23. package/daemon/src/bin.ts +7 -9
  24. package/daemon/src/handlers.ts +82 -79
  25. package/daemon/src/index.ts +14 -5
  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 +41 -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.2fkhreps.js +214 -0
  48. package/ui/dist/assets/client.css +1 -1
  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
@@ -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
+ }