@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
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; legacy auto-detected)
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':
@@ -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
- function isRecord(value: unknown): value is Record<string, unknown> {
16
- return typeof value === 'object' && value !== null && !Array.isArray(value)
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
- if (!isRecord(value)) return null
21
- const board: BuildBoard = {}
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, isDaemonRunning } from '../daemon-client'
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 { health } from '../daemon-client'
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 info = await health()
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,
@@ -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 { isDaemonRunning, peekFeedback, pushPlan, waitForFeedback } from '../daemon-client'
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
- const reason = error instanceof Error ? error.message : String(error)
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: ${reason}`)
155
+ console.error(`[fabrika] gate wait failed: ${error instanceof Error ? error.message : String(error)}`)
162
156
  }
163
157
  return 1
164
158
  }
@@ -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', default: 'cf-lightweight' },
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
- const result = scaffold(dir, { profile: values.profile, sessionId: values.session })
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 && !values['no-open']) {
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
  }
@@ -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 = `http://localhost:${DAEMON_PORT}`
8
- export const WS_URL = `ws://localhost:${DAEMON_PORT}`
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
- const neutral = join(home, '.fabrika')
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 {
@@ -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
  }