@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/BUILD-CONTRACT.md
CHANGED
|
@@ -26,6 +26,14 @@ The phase is always derived from application files and gate state. No mutable ph
|
|
|
26
26
|
|
|
27
27
|
The stable session ID identifies dashboard revisions. Persistent daemon state defaults to `~/.fabrika` and can be moved with `FABRIKA_DATA_DIR`.
|
|
28
28
|
|
|
29
|
+
Project registration is independent of canvas revisions. Optional connection and preview fields are non-secret. Credentials are stored separately outside registered projects, and approved installation origins are pinned independently of project JSON.
|
|
30
|
+
|
|
31
|
+
## Platform operations
|
|
32
|
+
|
|
33
|
+
Shared parsed DTOs live in `shared/`. CLI and UI use `/api/project/:id/register`, `/platform`, `/connect`, `/disconnect`, `/deploy` and `/preview`. The daemon owns pairing, credential custody, isolated local compilation, Control RPC calls and run observation. No key enters browser state or compiler environment.
|
|
34
|
+
|
|
35
|
+
The installed console owns human application creation/selection and initial placement approval. The daemon updates environments and triggers exact remotely available commits; it does not create repositories, commit, push or deploy directly to the provider. Disconnect is local deletion, not remote revocation.
|
|
36
|
+
|
|
29
37
|
## Gate protocol
|
|
30
38
|
|
|
31
39
|
1. `gate open` sends one view or a directory of views to `POST /api/session/:id/plan`.
|
package/README.md
CHANGED
|
@@ -38,6 +38,13 @@ All commands:
|
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
40
|
fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
|
|
41
|
+
fabrika builder init [dir] --url=<control-origin>
|
|
42
|
+
fabrika builder connect --url=<control-origin> [--no-open] [--json]
|
|
43
|
+
fabrika builder status [--json]
|
|
44
|
+
fabrika builder disconnect [--json]
|
|
45
|
+
fabrika builder deploy [--wait] [--timeout=<seconds>] [--json]
|
|
46
|
+
fabrika builder preview --url=<preview-url>
|
|
47
|
+
fabrika builder preview --clear
|
|
41
48
|
fabrika builder next [--json]
|
|
42
49
|
fabrika builder advance [--force]
|
|
43
50
|
fabrika builder gate open <name> --view <file.jsx>
|
|
@@ -49,6 +56,8 @@ fabrika builder build [--issue <ID>] [--assisted]
|
|
|
49
56
|
|
|
50
57
|
Use `--profile cf-internal-app` for the full Buzola, Propustka, and Vozka internal-app stack. The default is `cf-lightweight`.
|
|
51
58
|
|
|
59
|
+
Connected initialization uses `zerops-app` for a new project. A person approves the application and environment in the installed console; the local daemon receives a thirty-day app-scoped credential. The agent and application stay local. Deployment requires a clean, remotely available Git revision and runs through Control. `disconnect` removes the local key only; revoke server access in the console. See [the connected workflow](../../docs/reference/local-builder.md) for prerequisites, custody and recovery.
|
|
60
|
+
|
|
52
61
|
## Generated application state
|
|
53
62
|
|
|
54
63
|
```text
|
package/cli/src/bin.ts
CHANGED
|
@@ -7,13 +7,27 @@ import { cmdGate } from './commands/gate'
|
|
|
7
7
|
import { cmdInduce } from './commands/induce'
|
|
8
8
|
import { cmdInit } from './commands/init'
|
|
9
9
|
import { cmdNext } from './commands/next'
|
|
10
|
+
import { cmdPlatform } from './commands/platform'
|
|
11
|
+
import { BuilderCliError } from './errors'
|
|
10
12
|
|
|
11
13
|
export const BUILDER_USAGE = `fabrika builder — guided application builder
|
|
12
14
|
|
|
13
15
|
Usage:
|
|
14
16
|
fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
|
|
17
|
+
[--session=<id>] [--no-open] [--no-daemon] [--json]
|
|
15
18
|
Scaffold .fabrika/, install agent support, open dashboard
|
|
16
19
|
fabrika builder next [--json] Print the task packet for the current derived phase
|
|
20
|
+
fabrika builder init [dir] --url=<origin> [--no-open] Initialize zerops-app and begin human consent
|
|
21
|
+
[--no-daemon] Offline init only; incompatible with --url
|
|
22
|
+
fabrika builder connect --url=<origin> [--no-open] [--json]
|
|
23
|
+
fabrika builder status [--json] Read connection and deployment status
|
|
24
|
+
fabrika builder disconnect [--json] Remove local custody; revoke separately in console
|
|
25
|
+
fabrika builder deploy [--wait] [--timeout=<seconds>] [--json]
|
|
26
|
+
fabrika builder preview --url=<url> | --clear [--json]
|
|
27
|
+
Platform commands also accept --dir=<project>
|
|
28
|
+
Results are sanitized JSON; wait defaults to 600 seconds
|
|
29
|
+
--no-open returns the local dashboard URL, never approval proof
|
|
30
|
+
All commands accept --help and never prompt for input
|
|
17
31
|
fabrika builder advance [--force] Validate outputs and gate, then print the next phase
|
|
18
32
|
fabrika builder gate open <name> --view <f.jsx> Push a canvas gate and open the dashboard
|
|
19
33
|
fabrika builder gate wait [--gate <n>] [--timeout] Wait for submitted gate feedback
|
|
@@ -24,19 +38,38 @@ Usage:
|
|
|
24
38
|
Environment:
|
|
25
39
|
FABRIKA_PORT Daemon port (default 19410)
|
|
26
40
|
FABRIKA_SESSION_ID Session id (fallback: stable hash of repo abs path)
|
|
27
|
-
FABRIKA_DATA_DIR Persistent daemon/session data (default ~/.fabrika
|
|
41
|
+
FABRIKA_DATA_DIR Persistent daemon/session data (default ~/.fabrika)
|
|
28
42
|
FABRIKA_TIMEOUT gate wait timeout in ms (default 3600000)`
|
|
29
43
|
|
|
30
44
|
export async function runBuilderCli(argv: readonly string[]): Promise<number> {
|
|
45
|
+
try {
|
|
46
|
+
return await dispatchBuilderCli(argv)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(
|
|
49
|
+
error instanceof BuilderCliError
|
|
50
|
+
? error.message
|
|
51
|
+
: 'Builder command failed. Check arguments, .fabrika/config.json, and the local dashboard; use --help for usage.',
|
|
52
|
+
)
|
|
53
|
+
return 1
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function dispatchBuilderCli(argv: readonly string[]): Promise<number> {
|
|
31
58
|
const command = argv[0]
|
|
32
59
|
const rest = argv.slice(1)
|
|
33
60
|
|
|
34
|
-
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
61
|
+
if (!command || command === '--help' || command === '-h' || command === 'help' || rest.includes('--help') || rest.includes('-h')) {
|
|
35
62
|
console.info(BUILDER_USAGE)
|
|
36
63
|
return command ? 0 : 1
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
switch (command) {
|
|
67
|
+
case 'connect':
|
|
68
|
+
case 'status':
|
|
69
|
+
case 'disconnect':
|
|
70
|
+
case 'deploy':
|
|
71
|
+
case 'preview':
|
|
72
|
+
return cmdPlatform(command, rest)
|
|
40
73
|
case 'init':
|
|
41
74
|
return cmdInit(rest)
|
|
42
75
|
case 'next':
|
package/cli/src/build-status.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
2
|
import { dirname, join } from 'path'
|
|
3
|
+
import { z } from 'zod'
|
|
3
4
|
import type { Issue } from './issues'
|
|
4
5
|
|
|
5
6
|
export type IssueStatus = 'done' | 'blocked'
|
|
@@ -12,23 +13,14 @@ export interface BuildRecord {
|
|
|
12
13
|
|
|
13
14
|
export type BuildBoard = Record<string, BuildRecord>
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
16
|
+
const buildBoardSchema = z.record(
|
|
17
|
+
z.object({ status: z.enum(['done', 'blocked']), at: z.string(), reason: z.string().optional() })
|
|
18
|
+
.transform(({ status, at, reason }) => ({ status, at, ...(reason === undefined ? {} : { reason }) })),
|
|
19
|
+
)
|
|
18
20
|
|
|
19
21
|
function parseBoard(value: unknown): BuildBoard | null {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
for (const [id, candidate] of Object.entries(value)) {
|
|
23
|
-
if (!isRecord(candidate)) return null
|
|
24
|
-
const status = candidate['status']
|
|
25
|
-
const at = candidate['at']
|
|
26
|
-
const reason = candidate['reason']
|
|
27
|
-
if ((status !== 'done' && status !== 'blocked') || typeof at !== 'string') return null
|
|
28
|
-
if (reason !== undefined && typeof reason !== 'string') return null
|
|
29
|
-
board[id] = { status, at, ...(reason === undefined ? {} : { reason }) }
|
|
30
|
-
}
|
|
31
|
-
return board
|
|
22
|
+
const result = buildBoardSchema.safeParse(value)
|
|
23
|
+
return result.success ? result.data : null
|
|
32
24
|
}
|
|
33
25
|
|
|
34
26
|
function boardPath(fabrikaDir: string): string {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from 'util'
|
|
2
|
+
import { PHASE_COUNT, phaseById, phaseIndex, PHASES } from '../../../shared/phases'
|
|
2
3
|
import { resolveContext } from '../context'
|
|
3
|
-
import { consumeFeedback
|
|
4
|
+
import { consumeFeedback } from '../daemon-client'
|
|
5
|
+
import { isDaemonRunning } from '../daemon-lifecycle'
|
|
4
6
|
import { isNonEmptyFile, matchGlob, producesSatisfied } from '../derive'
|
|
5
7
|
import { resolvePhaseState } from '../phase-state'
|
|
6
|
-
import { PHASE_COUNT, phaseById, phaseIndex, PHASES } from '../phases'
|
|
7
8
|
|
|
8
9
|
export async function cmdAdvance(argv: string[]): Promise<number> {
|
|
9
10
|
const { values } = parseArgs({
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { BASE_URL } from '../config'
|
|
2
|
-
import {
|
|
3
|
-
import { ensureDaemon } from '../daemon-lifecycle'
|
|
2
|
+
import { daemon, ensureDaemon } from '../daemon-lifecycle'
|
|
4
3
|
|
|
5
4
|
export async function cmdEnsureDaemon(_argv: string[]): Promise<number> {
|
|
6
5
|
const up = await ensureDaemon()
|
|
@@ -8,7 +7,8 @@ export async function cmdEnsureDaemon(_argv: string[]): Promise<number> {
|
|
|
8
7
|
console.log(JSON.stringify({ ok: false, running: false, baseUrl: BASE_URL }))
|
|
9
8
|
return 1
|
|
10
9
|
}
|
|
11
|
-
const
|
|
10
|
+
const health = await daemon.health()
|
|
11
|
+
const info = health.status === 'up' ? health : null
|
|
12
12
|
console.log(
|
|
13
13
|
JSON.stringify({
|
|
14
14
|
ok: true,
|
package/cli/src/commands/gate.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { WaitError } from '@fabrika/daemon-kit'
|
|
1
2
|
import { existsSync, statSync } from 'fs'
|
|
2
3
|
import { extname, resolve } from 'path'
|
|
3
4
|
import { parseArgs } from 'util'
|
|
4
5
|
import { BASE_URL, TIMEOUT_MS } from '../config'
|
|
5
6
|
import { resolveContext } from '../context'
|
|
6
|
-
import {
|
|
7
|
+
import { pushPlan, waitForFeedback } from '../daemon-client'
|
|
7
8
|
import { ensureDaemon, openBrowser } from '../daemon-lifecycle'
|
|
8
9
|
|
|
9
10
|
export async function cmdGate(argv: string[]): Promise<number> {
|
|
@@ -143,22 +144,15 @@ async function gateWait(argv: string[]): Promise<number> {
|
|
|
143
144
|
return 1
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const pending = await peekFeedback(ctx.sessionId)
|
|
147
|
-
if (pending !== null) {
|
|
148
|
-
process.stdout.write(pending)
|
|
149
|
-
return 0
|
|
150
|
-
}
|
|
151
|
-
|
|
152
147
|
try {
|
|
153
148
|
const feedback = await waitForFeedback(ctx.sessionId, timeoutMs)
|
|
154
149
|
process.stdout.write(feedback)
|
|
155
150
|
return 0
|
|
156
151
|
} catch (error: unknown) {
|
|
157
|
-
|
|
158
|
-
if (reason === 'timeout') {
|
|
152
|
+
if (error instanceof WaitError && error.reason === 'timeout') {
|
|
159
153
|
console.error('[fabrika] timeout waiting for gate feedback.')
|
|
160
154
|
} else {
|
|
161
|
-
console.error(`[fabrika] gate wait failed: ${
|
|
155
|
+
console.error(`[fabrika] gate wait failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
162
156
|
}
|
|
163
157
|
return 1
|
|
164
158
|
}
|
package/cli/src/commands/init.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { rmSync } from 'fs'
|
|
2
|
-
import { join } from 'path'
|
|
3
|
-
import { parseArgs } from 'util'
|
|
1
|
+
import { rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { type BuilderPlatformStatus, parseBuilderOrigin } from '../../../shared'
|
|
4
5
|
import { BASE_URL, SESSIONS_DIR } from '../config'
|
|
5
6
|
import { ensureDaemon, openBrowser } from '../daemon-lifecycle'
|
|
7
|
+
import { BuilderCliError } from '../errors'
|
|
6
8
|
import { type AgentHost, injectAgentSupport, parseAgentHosts } from '../inject-skills'
|
|
7
|
-
import { scaffold } from '../layout'
|
|
9
|
+
import { readConfig, scaffold } from '../layout'
|
|
10
|
+
import { platformRequest, publicPlatformStatus } from '../platform-client'
|
|
8
11
|
|
|
9
12
|
async function resetStaleSession(sessionId: string): Promise<void> {
|
|
10
13
|
try {
|
|
@@ -21,7 +24,9 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
21
24
|
const { values, positionals } = parseArgs({
|
|
22
25
|
args: argv,
|
|
23
26
|
options: {
|
|
24
|
-
profile: { type: 'string'
|
|
27
|
+
profile: { type: 'string' },
|
|
28
|
+
url: { type: 'string' },
|
|
29
|
+
json: { type: 'boolean' },
|
|
25
30
|
session: { type: 'string' },
|
|
26
31
|
agent: { type: 'string', default: 'all' },
|
|
27
32
|
'no-daemon': { type: 'boolean', default: false },
|
|
@@ -38,7 +43,21 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
38
43
|
return 1
|
|
39
44
|
}
|
|
40
45
|
const dir = positionals[0] ?? process.cwd()
|
|
41
|
-
|
|
46
|
+
if (positionals.length > 1) throw new Error('Expected at most one project directory.')
|
|
47
|
+
const origin = values.url === undefined ? undefined : parseBuilderOrigin(values.url, 'control origin')
|
|
48
|
+
if (origin && values['no-daemon']) throw new BuilderCliError('--url cannot be combined with --no-daemon.')
|
|
49
|
+
const existing = readConfig(dir)
|
|
50
|
+
const profile = values.profile ?? existing?.profile ?? (origin ? 'zerops-app' : 'cf-lightweight')
|
|
51
|
+
if (existing && (profile !== existing.profile || (values.session && values.session !== existing.sessionId))) {
|
|
52
|
+
throw new BuilderCliError('Existing project profile and session cannot be replaced by init.')
|
|
53
|
+
}
|
|
54
|
+
if (origin && (profile !== 'zerops-app' || (existing?.connection && existing.connection.receipt.provider !== 'zerops'))) {
|
|
55
|
+
throw new BuilderCliError('Connected initialization requires zerops-app.')
|
|
56
|
+
}
|
|
57
|
+
if (origin && existing?.connection && existing.connection.controlOrigin !== origin) {
|
|
58
|
+
throw new BuilderCliError('Disconnect explicitly before changing installations.')
|
|
59
|
+
}
|
|
60
|
+
const result = scaffold(dir, { profile, sessionId: values.session })
|
|
42
61
|
const support = injectAgentSupport(result.repoRoot, { hosts })
|
|
43
62
|
|
|
44
63
|
if (result.alreadyInitialized) {
|
|
@@ -60,13 +79,29 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
const browserUrl = `${BASE_URL}/s/${result.config.sessionId}`
|
|
82
|
+
let platform: BuilderPlatformStatus | undefined
|
|
63
83
|
if (!values['no-daemon']) {
|
|
64
84
|
const up = await ensureDaemon({ quiet: true })
|
|
65
85
|
if (!result.alreadyInitialized) {
|
|
66
86
|
await resetStaleSession(result.config.sessionId)
|
|
67
87
|
console.error(`[fabrika] cleared any stale session for ${result.config.sessionId}`)
|
|
68
88
|
}
|
|
69
|
-
if (up
|
|
89
|
+
if (up) {
|
|
90
|
+
platform = await platformRequest(result.config.sessionId, 'register', { projectRoot: result.repoRoot })
|
|
91
|
+
if (origin) {
|
|
92
|
+
const pinnedOrigin = platform.connection?.controlOrigin
|
|
93
|
+
?? (platform.pairing?.status === 'pending' ? new URL(platform.pairing.authorizationUrl).origin : undefined)
|
|
94
|
+
if (pinnedOrigin && pinnedOrigin !== origin) throw new BuilderCliError('Disconnect explicitly before changing installations.')
|
|
95
|
+
if (!existing?.connection && !platform.connection && platform.pairing?.status !== 'pending' && platform.pairing?.status !== 'approved') {
|
|
96
|
+
platform = await platformRequest(result.config.sessionId, 'connect', { controlOrigin: origin })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else if (origin) {
|
|
100
|
+
throw new Error('Local builder daemon is unavailable.')
|
|
101
|
+
}
|
|
102
|
+
if (up && !values['no-open'] && platform?.pairing?.status === 'pending') {
|
|
103
|
+
openBrowser(platform.pairing.authorizationUrl, browserUrl)
|
|
104
|
+
} else if (up && !values['no-open']) {
|
|
70
105
|
openBrowser(browserUrl)
|
|
71
106
|
} else if (!up) {
|
|
72
107
|
console.error(`[fabrika] daemon not started (offline). Dashboard: ${browserUrl}`)
|
|
@@ -85,7 +120,9 @@ export async function cmdInit(argv: string[]): Promise<number> {
|
|
|
85
120
|
agents: support.hosts,
|
|
86
121
|
browserUrl,
|
|
87
122
|
createdConfig: result.createdConfig,
|
|
123
|
+
...(platform ? { platform: publicPlatformStatus(platform) } : {}),
|
|
88
124
|
}),
|
|
89
125
|
)
|
|
126
|
+
if (origin && platform?.pairing && ['failed', 'expired', 'cancelled', 'recovery_required'].includes(platform.pairing.status)) return 1
|
|
90
127
|
return 0
|
|
91
128
|
}
|
package/cli/src/commands/next.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseArgs } from 'util'
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
2
|
import { resolveContext } from '../context'
|
|
3
3
|
import { packetJson, renderPacket } from '../packet'
|
|
4
4
|
import { resolvePhaseState } from '../phase-state'
|
|
@@ -23,9 +23,9 @@ export async function cmdNext(argv: string[]): Promise<number> {
|
|
|
23
23
|
const { state, source } = await resolvePhaseState(ctx.repoRoot, ctx.fabrikaDir, ctx.sessionId)
|
|
24
24
|
|
|
25
25
|
if (values.json) {
|
|
26
|
-
console.log(JSON.stringify({ ...packetJson(state, { profile: ctx.profile }), source }))
|
|
26
|
+
console.log(JSON.stringify({ ...packetJson(state, { profile: ctx.profile, connection: ctx.connection }), source }))
|
|
27
27
|
} else {
|
|
28
|
-
process.stdout.write(renderPacket(state, { profile: ctx.profile }))
|
|
28
|
+
process.stdout.write(renderPacket(state, { profile: ctx.profile, connection: ctx.connection }))
|
|
29
29
|
}
|
|
30
30
|
return 0
|
|
31
31
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
import { parseBuilderOrigin, parseBuilderPreviewUrl } from '../../../shared'
|
|
3
|
+
import { BASE_URL } from '../config'
|
|
4
|
+
import { resolveContext } from '../context'
|
|
5
|
+
import { ensureDaemon, openBrowser } from '../daemon-lifecycle'
|
|
6
|
+
import { BuilderCliError } from '../errors'
|
|
7
|
+
import { readConfig } from '../layout'
|
|
8
|
+
import { platformRequest, publicPlatformStatus } from '../platform-client'
|
|
9
|
+
|
|
10
|
+
export type PlatformCommand = 'connect' | 'status' | 'disconnect' | 'deploy' | 'preview'
|
|
11
|
+
const stringOption: { type: 'string' } = { type: 'string' }
|
|
12
|
+
const booleanOption: { type: 'boolean' } = { type: 'boolean' }
|
|
13
|
+
|
|
14
|
+
export async function cmdPlatform(command: PlatformCommand, argv: string[]): Promise<number> {
|
|
15
|
+
const { values } = parseArgs({
|
|
16
|
+
args: argv,
|
|
17
|
+
options: {
|
|
18
|
+
json: { type: 'boolean' },
|
|
19
|
+
dir: { type: 'string' },
|
|
20
|
+
...(command === 'connect' || command === 'preview' ? { url: stringOption } : {}),
|
|
21
|
+
...(command === 'connect' ? { 'no-open': booleanOption } : {}),
|
|
22
|
+
...(command === 'preview' ? { clear: booleanOption } : {}),
|
|
23
|
+
...(command === 'deploy' ? { wait: booleanOption, timeout: stringOption } : {}),
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
const origin = command === 'connect' ? parseBuilderOrigin(values.url, 'control origin') : undefined
|
|
27
|
+
if (command === 'preview' && Boolean(values.clear) === (values.url !== undefined)) {
|
|
28
|
+
throw new BuilderCliError('Use preview --url=<url> or preview --clear.')
|
|
29
|
+
}
|
|
30
|
+
const previewUrl = command === 'preview' && !values.clear ? parseBuilderPreviewUrl(values.url) : null
|
|
31
|
+
const seconds = values.timeout === undefined ? 600 : Number(values.timeout)
|
|
32
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 86400) throw new Error('Timeout must be between 0 and 86400 seconds (exclusive of 0).')
|
|
33
|
+
if (values.timeout !== undefined && !values.wait) throw new Error('--timeout requires --wait.')
|
|
34
|
+
const ctx = resolveContext({ dir: values.dir })
|
|
35
|
+
const config = readConfig(ctx.repoRoot)
|
|
36
|
+
if (!config) throw new BuilderCliError('Run fabrika builder init first.')
|
|
37
|
+
if (
|
|
38
|
+
(command === 'connect' || command === 'deploy')
|
|
39
|
+
&& (config.profile !== 'zerops-app' || (config.connection && config.connection.receipt.provider !== 'zerops'))
|
|
40
|
+
) {
|
|
41
|
+
throw new BuilderCliError('Connected deployment requires the zerops-app profile.')
|
|
42
|
+
}
|
|
43
|
+
if (!await ensureDaemon({ quiet: true })) throw new BuilderCliError('Local builder daemon is unavailable.')
|
|
44
|
+
await platformRequest(config.sessionId, 'register', { projectRoot: ctx.repoRoot })
|
|
45
|
+
const dashboardUrl = `${BASE_URL}/s/${encodeURIComponent(config.sessionId)}`
|
|
46
|
+
let status = await platformRequest(
|
|
47
|
+
config.sessionId,
|
|
48
|
+
command === 'status' ? 'platform' : command,
|
|
49
|
+
command === 'status' ? undefined : command === 'connect' ? { controlOrigin: origin } : command === 'preview' ? { url: previewUrl } : {},
|
|
50
|
+
)
|
|
51
|
+
if (command === 'connect' && status.pairing?.status === 'pending' && !values['no-open']) {
|
|
52
|
+
openBrowser(status.pairing.authorizationUrl, dashboardUrl)
|
|
53
|
+
}
|
|
54
|
+
if (command === 'disconnect') console.error('Local credentials removed. Revoke the authorization in the platform console to revoke server access.')
|
|
55
|
+
let outcome: string | undefined
|
|
56
|
+
if (command === 'deploy' && values.wait) {
|
|
57
|
+
const deadline = Date.now() + seconds * 1000
|
|
58
|
+
while (status.deployment && ['preparing', 'pending', 'running'].includes(status.deployment.status)) {
|
|
59
|
+
if (Date.now() >= deadline) {
|
|
60
|
+
outcome = 'timed_out'
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
await Bun.sleep(Math.min(1000, deadline - Date.now()))
|
|
64
|
+
if (Date.now() >= deadline) {
|
|
65
|
+
outcome = 'timed_out'
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
status = await platformRequest(config.sessionId, 'platform', undefined, Math.max(1, Math.ceil(deadline - Date.now())))
|
|
70
|
+
} catch {
|
|
71
|
+
outcome = Date.now() >= deadline ? 'timed_out' : 'poll_failed'
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
outcome ??= status.deployment?.status ?? 'missing_run'
|
|
76
|
+
}
|
|
77
|
+
console.log(JSON.stringify({ ...publicPlatformStatus(status), dashboardUrl, ...(outcome ? { outcome } : {}) }))
|
|
78
|
+
if (command === 'connect' && status.pairing && ['failed', 'expired', 'cancelled', 'recovery_required'].includes(status.pairing.status)) return 1
|
|
79
|
+
if (command === 'deploy' && status.deployment && ['failed', 'cancelled'].includes(status.deployment.status)) return 1
|
|
80
|
+
return outcome !== undefined && outcome !== 'succeeded' ? 1 : 0
|
|
81
|
+
}
|
package/cli/src/config.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'crypto'
|
|
2
|
-
import { existsSync } from 'fs'
|
|
3
2
|
import { homedir, tmpdir } from 'os'
|
|
4
3
|
import { join, resolve } from 'path'
|
|
4
|
+
import { builderDaemonUrl } from '../../shared/urls'
|
|
5
5
|
|
|
6
6
|
export const DAEMON_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
|
|
7
|
-
export const BASE_URL =
|
|
8
|
-
export const WS_URL =
|
|
7
|
+
export const BASE_URL = builderDaemonUrl(DAEMON_PORT)
|
|
8
|
+
export const WS_URL = builderDaemonUrl(DAEMON_PORT, 'ws')
|
|
9
9
|
|
|
10
10
|
export const TIMEOUT_MS = parseInt(process.env['FABRIKA_TIMEOUT'] || String(60 * 60 * 1000), 10)
|
|
11
11
|
|
|
12
|
-
export const LEGACY_DATA_DIR = join(homedir(), '.claude', 'fabrika')
|
|
13
12
|
export const NEUTRAL_DATA_DIR = join(homedir(), '.fabrika')
|
|
14
13
|
export const DATA_DIR = resolveDataDir()
|
|
15
14
|
export const SESSIONS_DIR = join(DATA_DIR, 'sessions')
|
|
@@ -21,17 +20,11 @@ export const FABRIKA_DIRNAME = '.fabrika'
|
|
|
21
20
|
export function resolveDataDir(opts: {
|
|
22
21
|
home?: string
|
|
23
22
|
explicit?: string
|
|
24
|
-
exists?: (path: string) => boolean
|
|
25
23
|
} = {}): string {
|
|
26
24
|
const home = opts.home ?? homedir()
|
|
27
25
|
const explicit = opts.explicit ?? process.env['FABRIKA_DATA_DIR']
|
|
28
26
|
if (explicit) return resolve(explicit)
|
|
29
|
-
|
|
30
|
-
const legacy = join(home, '.claude', 'fabrika')
|
|
31
|
-
const exists = opts.exists ?? existsSync
|
|
32
|
-
if (exists(neutral)) return neutral
|
|
33
|
-
if (exists(legacy)) return legacy
|
|
34
|
-
return neutral
|
|
27
|
+
return join(home, '.fabrika')
|
|
35
28
|
}
|
|
36
29
|
|
|
37
30
|
export function getSessionId(explicit?: string, recorded?: string, repoRoot?: string): string {
|
package/cli/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { resolve } from 'path'
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { BuilderProjectConnection } from '../../shared'
|
|
2
3
|
import { getSessionId } from './config'
|
|
3
4
|
import { fabrikaDir, findRepoRoot, readConfig } from './layout'
|
|
4
5
|
|
|
@@ -8,6 +9,7 @@ export interface CommandContext {
|
|
|
8
9
|
sessionId: string
|
|
9
10
|
profile: string
|
|
10
11
|
initialized: boolean
|
|
12
|
+
connection?: BuilderProjectConnection
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function resolveContext(opts: { dir?: string; session?: string } = {}): CommandContext {
|
|
@@ -24,5 +26,6 @@ export function resolveContext(opts: { dir?: string; session?: string } = {}): C
|
|
|
24
26
|
sessionId,
|
|
25
27
|
profile: config?.profile ?? 'cf-lightweight',
|
|
26
28
|
initialized: config !== null,
|
|
29
|
+
...(config?.connection ? { connection: config.connection } : {}),
|
|
27
30
|
}
|
|
28
31
|
}
|