@kitlangton/tailcode 0.1.0

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/src/app.tsx ADDED
@@ -0,0 +1,565 @@
1
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
2
+ import { TextAttributes } from "@opentui/core"
3
+ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
4
+ import { useAtomSet, useAtomValue } from "@effect/atom-solid/Hooks"
5
+ import type { MissingBinary, Step } from "./state.js"
6
+ import { phase, step, log, url, error, missingBinary } from "./state.js"
7
+ import { renderQR, copyToClipboard, openInBrowser } from "./qr.js"
8
+ import { flowFn, signalExit, waitForFlowStop } from "./flow.js"
9
+
10
+ // -- Palette --
11
+ export const color = {
12
+ bg: "#05070b",
13
+ panel: "#141820",
14
+ panelSoft: "#191e28",
15
+ tail: "#8e96a3",
16
+ code: "#dde1e8",
17
+ codeShadow: "#444c5c",
18
+ accent: "#5fa8ff",
19
+ success: "#7ad8ae",
20
+ error: "#ff7f7f",
21
+ notice: "#f2b85d",
22
+ text: "#d2d7e2",
23
+ muted: "#8a93a5",
24
+ dim: "#666f82",
25
+ }
26
+
27
+ // -- Wordmark (same pixel font as opencode's logo.ts) --
28
+ const LOGO = {
29
+ left: [" ", "▀▀█▀ ▄▀█ ▀▀█▀ ▄▀ ", " ▄▀ ▄▀▀█ ▄▀ ▄▀ ", " ▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀"],
30
+ right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
31
+ }
32
+
33
+ type InstallGuide = {
34
+ readonly title: string
35
+ readonly docsUrl: string
36
+ readonly installCommand: string
37
+ readonly installHint: string
38
+ }
39
+
40
+ type StageState = "pending" | "active" | "done" | "error"
41
+ const STAGE_ORDER: ReadonlyArray<Step> = ["tailscale", "opencode", "publish"]
42
+
43
+ function getInstallGuide(binary: MissingBinary, platform: NodeJS.Platform): InstallGuide {
44
+ if (binary === "opencode") {
45
+ if (platform === "darwin") {
46
+ return {
47
+ title: "Install OpenCode for macOS",
48
+ docsUrl: "https://opencode.ai/docs/#install",
49
+ installCommand: "brew install anomalyco/tap/opencode",
50
+ installHint: "Alternative: bun install -g opencode-ai",
51
+ }
52
+ }
53
+
54
+ if (platform === "win32") {
55
+ return {
56
+ title: "Install OpenCode for Windows",
57
+ docsUrl: "https://opencode.ai/docs/windows-wsl",
58
+ installCommand: "choco install opencode",
59
+ installHint: "Recommended path is WSL2. Open the docs for setup details.",
60
+ }
61
+ }
62
+
63
+ return {
64
+ title: "Install OpenCode for Linux",
65
+ docsUrl: "https://opencode.ai/docs/#install",
66
+ installCommand: "curl -fsSL https://opencode.ai/install | bash",
67
+ installHint: "Alternative: bun install -g opencode-ai",
68
+ }
69
+ }
70
+
71
+ if (platform === "darwin") {
72
+ return {
73
+ title: "Install Tailscale for macOS",
74
+ docsUrl: "https://tailscale.com/docs/install/mac",
75
+ installCommand: "brew install --cask tailscale-app",
76
+ installHint: "If Homebrew is unavailable, open the docs link for installer options.",
77
+ }
78
+ }
79
+
80
+ if (platform === "win32") {
81
+ return {
82
+ title: "Install Tailscale for Windows",
83
+ docsUrl: "https://tailscale.com/docs/install/windows",
84
+ installCommand: "winget install --id tailscale.tailscale --exact",
85
+ installHint: "If winget is unavailable, use the installer from the docs link.",
86
+ }
87
+ }
88
+
89
+ return {
90
+ title: "Install Tailscale for Linux",
91
+ docsUrl: "https://tailscale.com/docs/install/linux",
92
+ installCommand: "curl -fsSL https://tailscale.com/install.sh | sh",
93
+ installHint: "Install may require sudo depending on your distro.",
94
+ }
95
+ }
96
+
97
+ export function logoLine(line: string, fg: string, shadow: string, bold: boolean) {
98
+ const attrs = bold ? TextAttributes.BOLD : undefined
99
+ const els: any[] = []
100
+ let i = 0
101
+ while (i < line.length) {
102
+ const rest = line.slice(i)
103
+ const m = rest.search(/[_^~]/)
104
+ if (m === -1) {
105
+ els.push(
106
+ <text fg={fg} attributes={attrs}>
107
+ {rest}
108
+ </text>,
109
+ )
110
+ break
111
+ }
112
+ if (m > 0)
113
+ els.push(
114
+ <text fg={fg} attributes={attrs}>
115
+ {rest.slice(0, m)}
116
+ </text>,
117
+ )
118
+ const c = rest[m]
119
+ if (c === "_")
120
+ els.push(
121
+ <text fg={fg} bg={shadow} attributes={attrs}>
122
+ {" "}
123
+ </text>,
124
+ )
125
+ else if (c === "^")
126
+ els.push(
127
+ <text fg={fg} bg={shadow} attributes={attrs}>
128
+
129
+ </text>,
130
+ )
131
+ else if (c === "~")
132
+ els.push(
133
+ <text fg={shadow} attributes={attrs}>
134
+
135
+ </text>,
136
+ )
137
+ i += m + 1
138
+ }
139
+ return els
140
+ }
141
+
142
+ type AppProps = {
143
+ readonly onStart?: () => void
144
+ readonly onQuit?: () => void
145
+ readonly resetToWelcomeOnMount?: boolean
146
+ }
147
+
148
+ type PanelTone = "primary" | "soft"
149
+ type PanelProps = {
150
+ readonly tone?: PanelTone
151
+ readonly gap?: number
152
+ readonly paddingTop?: number
153
+ readonly paddingBottom?: number
154
+ readonly children: any
155
+ }
156
+
157
+ function Panel(props: PanelProps) {
158
+ return (
159
+ <box
160
+ flexDirection="column"
161
+ backgroundColor={props.tone === "soft" ? color.panelSoft : color.panel}
162
+ paddingLeft={2}
163
+ paddingRight={2}
164
+ paddingTop={props.paddingTop ?? 1}
165
+ paddingBottom={props.paddingBottom ?? 1}
166
+ gap={props.gap ?? 0}
167
+ >
168
+ {props.children}
169
+ </box>
170
+ )
171
+ }
172
+
173
+ type ActionItem = {
174
+ readonly key: string
175
+ readonly label: string
176
+ readonly keyFg?: string
177
+ readonly labelFg?: string
178
+ }
179
+
180
+ function ActionRow(props: { readonly items: ReadonlyArray<ActionItem> }) {
181
+ return (
182
+ <box flexDirection="row" gap={1}>
183
+ {props.items.map((item, index) => (
184
+ <>
185
+ <Show when={index > 0}>
186
+ <text fg={color.dim}>·</text>
187
+ </Show>
188
+ <text fg={item.keyFg ?? color.muted}>{item.key}</text>
189
+ <text fg={item.labelFg ?? color.dim}>{item.label}</text>
190
+ </>
191
+ ))}
192
+ </box>
193
+ )
194
+ }
195
+
196
+ export function App(props: AppProps = {}) {
197
+ const dims = useTerminalDimensions()
198
+ const currentPhase = useAtomValue(phase)
199
+ const logText = useAtomValue(log)
200
+ const remoteUrl = useAtomValue(url)
201
+ const errorMessage = useAtomValue(error)
202
+ const currentStep = useAtomValue(step)
203
+ const currentMissingBinary = useAtomValue(missingBinary)
204
+ const installGuide = createMemo(() => getInstallGuide(currentMissingBinary() || "tailscale", process.platform))
205
+
206
+ const setPhase = useAtomSet(phase)
207
+ const setError = useAtomSet(error)
208
+ const setUrl = useAtomSet(url)
209
+ const setLog = useAtomSet(log)
210
+ const setMissingBinary = useAtomSet(missingBinary)
211
+ const triggerFlow = useAtomSet(flowFn)
212
+
213
+ const [flashedKey, setFlashedKey] = createSignal("")
214
+ const [spinnerFrame, setSpinnerFrame] = createSignal(0)
215
+ const [showTailscaleQR, setShowTailscaleQR] = createSignal(false)
216
+
217
+ const TAILSCALE_DOWNLOAD_URL = "https://tailscale.com/download"
218
+
219
+ const qrCode = createMemo(() => {
220
+ const targetUrl = showTailscaleQR() ? TAILSCALE_DOWNLOAD_URL : remoteUrl()
221
+ if (!targetUrl) return ""
222
+ try {
223
+ return renderQR(targetUrl)
224
+ } catch {
225
+ return ""
226
+ }
227
+ })
228
+
229
+ const localCmd = () => `opencode attach http://127.0.0.1:4096`
230
+
231
+ const terminalWidth = createMemo(() => {
232
+ const width = Number(dims().width ?? 80)
233
+ return Number.isFinite(width) && width > 0 ? width : 80
234
+ })
235
+
236
+ const panelWidth = createMemo(() => {
237
+ const available = Math.max(26, terminalWidth() - 4)
238
+ return Math.min(104, available)
239
+ })
240
+
241
+ const compact = createMemo(() => panelWidth() < 74)
242
+ const spinnerGlyph = createMemo(() => {
243
+ const frames = ["|", "/", "-", "\\"]
244
+ return frames[spinnerFrame() % frames.length] ?? "|"
245
+ })
246
+ const recentLog = createMemo(() => {
247
+ const lines = logText()
248
+ .split("\n")
249
+ .map((line) => line.trim())
250
+ .filter(Boolean)
251
+ return lines.slice(-4).join("\n")
252
+ })
253
+
254
+ const flash = (key: string) => {
255
+ setFlashedKey(key)
256
+ setTimeout(() => setFlashedKey(""), 1500)
257
+ }
258
+
259
+ const quit = () => {
260
+ if (props.onQuit) {
261
+ props.onQuit()
262
+ return
263
+ }
264
+
265
+ signalExit()
266
+ const fallback = setTimeout(() => process.exit(0), 5000)
267
+ void waitForFlowStop().finally(() => {
268
+ clearTimeout(fallback)
269
+ process.exit(0)
270
+ })
271
+ }
272
+
273
+ const keyColor = (key: string) => (flashedKey() === key ? color.notice : color.muted)
274
+ const labelColor = (key: string) => (flashedKey() === key ? color.notice : color.dim)
275
+
276
+ const start = () => {
277
+ if (props.onStart) {
278
+ props.onStart()
279
+ return
280
+ }
281
+
282
+ setError("")
283
+ setMissingBinary("")
284
+ setUrl("")
285
+ setLog("")
286
+ setPhase("running")
287
+ triggerFlow(undefined)
288
+ }
289
+
290
+ useKeyboard((evt) => {
291
+ if (evt.name === "q" || evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
292
+ quit()
293
+ return
294
+ }
295
+ if (currentPhase() === "welcome" && evt.name === "return") {
296
+ evt.preventDefault()
297
+ start()
298
+ return
299
+ }
300
+ if (currentPhase() === "install" && evt.name === "return") {
301
+ evt.preventDefault()
302
+ start()
303
+ return
304
+ }
305
+ if (currentPhase() === "error" && evt.name === "return") {
306
+ evt.preventDefault()
307
+ start()
308
+ return
309
+ }
310
+ if (currentPhase() === "install" && (evt.name === "1" || evt.name === "2")) {
311
+ evt.preventDefault()
312
+ if (evt.name === "1") {
313
+ void copyToClipboard(installGuide().installCommand)
314
+ .then(() => flash("1"))
315
+ .catch(() => {})
316
+ } else if (evt.name === "2") {
317
+ void openInBrowser(installGuide().docsUrl)
318
+ .then(() => flash("2"))
319
+ .catch(() => {})
320
+ }
321
+ return
322
+ }
323
+ if (currentPhase() === "done" && (evt.name === "1" || evt.name === "2" || evt.name === "3" || evt.name === "4")) {
324
+ evt.preventDefault()
325
+ if (evt.name === "1") {
326
+ void copyToClipboard(remoteUrl())
327
+ .then(() => flash("1"))
328
+ .catch(() => {})
329
+ } else if (evt.name === "2") {
330
+ void openInBrowser(remoteUrl())
331
+ .then(() => flash("2"))
332
+ .catch(() => {})
333
+ } else if (evt.name === "3") {
334
+ void copyToClipboard(localCmd())
335
+ .then(() => flash("3"))
336
+ .catch(() => {})
337
+ } else {
338
+ setShowTailscaleQR((v) => !v)
339
+ flash("4")
340
+ }
341
+ return
342
+ }
343
+ })
344
+
345
+ onMount(() => {
346
+ const timer = setInterval(() => {
347
+ setSpinnerFrame((value) => value + 1)
348
+ }, 120)
349
+
350
+ onCleanup(() => {
351
+ clearInterval(timer)
352
+ })
353
+
354
+ if (props.resetToWelcomeOnMount === false) return
355
+ setPhase("welcome")
356
+ })
357
+
358
+ const stageState = (id: Step): StageState => {
359
+ const active = currentStep()
360
+
361
+ if (currentPhase() === "done") return "done"
362
+ if (currentPhase() === "install") {
363
+ const failedStep: Step = currentMissingBinary() === "opencode" ? "opencode" : "tailscale"
364
+ return id === failedStep ? "error" : "pending"
365
+ }
366
+
367
+ const activeIndex = STAGE_ORDER.indexOf(active)
368
+ const currentIndex = STAGE_ORDER.indexOf(id)
369
+
370
+ if (currentPhase() === "error") {
371
+ if (active === id) return "error"
372
+ if (activeIndex > currentIndex) return "done"
373
+ return "pending"
374
+ }
375
+
376
+ if (active === id && currentPhase() === "running") return "active"
377
+ if (activeIndex > currentIndex) return "done"
378
+ return "pending"
379
+ }
380
+
381
+ const stageColor = (id: Step) => {
382
+ const state = stageState(id)
383
+ if (state === "done") return color.success
384
+ if (state === "active") return color.accent
385
+ if (state === "error") return color.error
386
+ return color.muted
387
+ }
388
+
389
+ const stageBg = (id: Step) => {
390
+ const state = stageState(id)
391
+ if (state === "done") return "#1d2d27"
392
+ if (state === "active") return "#1f3046"
393
+ if (state === "error") return "#382429"
394
+ return color.panelSoft
395
+ }
396
+
397
+ const stageBadge = (id: Step, label: string) => {
398
+ const state = stageState(id)
399
+ const glyph = state === "done" ? "✓" : state === "active" ? spinnerGlyph() : state === "error" ? "✕" : "·"
400
+
401
+ const glyphColor = state === "pending" ? color.dim : stageColor(id)
402
+
403
+ return (
404
+ <box backgroundColor={stageBg(id)} paddingLeft={1} paddingRight={1} flexDirection="row">
405
+ <text fg={glyphColor}>{glyph}</text>
406
+ <text fg={stageColor(id)}>{` ${label}`}</text>
407
+ </box>
408
+ )
409
+ }
410
+
411
+ const stageRow = () => (
412
+ <box flexDirection={compact() ? "column" : "row"} gap={0}>
413
+ {stageBadge("tailscale", "Tailscale")}
414
+ {stageBadge("opencode", "OpenCode")}
415
+ {stageBadge("publish", "Publish")}
416
+ </box>
417
+ )
418
+
419
+ return (
420
+ <box flexDirection="column" flexGrow={1} backgroundColor={color.bg}>
421
+ <box
422
+ flexDirection="column"
423
+ flexGrow={1}
424
+ justifyContent="center"
425
+ alignItems="center"
426
+ paddingLeft={2}
427
+ paddingRight={2}
428
+ >
429
+ <box flexDirection="column" width={panelWidth()}>
430
+ <box flexDirection="column" alignItems="center">
431
+ {LOGO.left.map((line, i) => (
432
+ <box flexDirection="row" gap={1}>
433
+ <box flexDirection="row">{logoLine(line, color.tail, color.dim, false)}</box>
434
+ <box flexDirection="row">{logoLine(LOGO.right[i]!, color.code, color.codeShadow, true)}</box>
435
+ </box>
436
+ ))}
437
+ </box>
438
+ <text>{""}</text>
439
+ <box flexDirection="column" alignItems="flex-start">
440
+ {stageRow()}
441
+ </box>
442
+ <Show when={currentPhase() === "welcome"}>
443
+ <Panel>
444
+ <text fg={color.dim}>Guided setup for OpenCode over Tailscale</text>
445
+ <text>{""}</text>
446
+ <text fg={color.text}>This wizard will:</text>
447
+ <text fg={color.muted}>· Connect to Tailscale</text>
448
+ <text fg={color.muted}>· Start OpenCode server on localhost</text>
449
+ <text fg={color.muted}>· Publish with tailscale serve</text>
450
+ <text>{""}</text>
451
+ <ActionRow
452
+ items={[
453
+ { key: "enter", label: "start", keyFg: color.accent, labelFg: color.dim },
454
+ { key: "q", label: "quit", keyFg: color.muted, labelFg: color.dim },
455
+ ]}
456
+ />
457
+ </Panel>
458
+ </Show>
459
+
460
+ <Show when={currentPhase() === "running"}>
461
+ <Panel>
462
+ <text fg={color.dim}>Usually finishes in a few seconds.</text>
463
+ <text>{""}</text>
464
+ <ActionRow items={[{ key: "q", label: "quit", keyFg: color.muted, labelFg: color.dim }]} />
465
+ </Panel>
466
+ </Show>
467
+
468
+ <Show when={currentPhase() === "install"}>
469
+ <Panel>
470
+ <text fg={color.error}>{() => errorMessage()}</text>
471
+ </Panel>
472
+
473
+ <Panel tone="soft">
474
+ <text fg={color.dim}>{() => installGuide().title}</text>
475
+ <text fg={color.accent}>{() => installGuide().installCommand}</text>
476
+ <ActionRow
477
+ items={[
478
+ { key: "1", label: "copy install command", keyFg: keyColor("1"), labelFg: labelColor("1") },
479
+ { key: "2", label: "open install guide", keyFg: keyColor("2"), labelFg: labelColor("2") },
480
+ ]}
481
+ />
482
+ <text>{""}</text>
483
+ <text fg={color.dim}>{() => installGuide().installHint}</text>
484
+ <text fg={color.muted}>{() => installGuide().docsUrl}</text>
485
+ <text>{""}</text>
486
+ <ActionRow
487
+ items={[
488
+ { key: "enter", label: "check again", keyFg: color.accent, labelFg: color.dim },
489
+ { key: "q", label: "quit", keyFg: color.muted, labelFg: color.dim },
490
+ ]}
491
+ />
492
+ </Panel>
493
+ </Show>
494
+
495
+ <Show when={currentPhase() === "error"}>
496
+ <Panel>
497
+ <text fg={color.error}>Setup Failed</text>
498
+ </Panel>
499
+
500
+ <Panel tone="soft">
501
+ <text fg={color.error}>{() => `Error: ${errorMessage()}`}</text>
502
+ <Show when={recentLog()}>
503
+ <text>{""}</text>
504
+ <text fg={color.muted}>Recent output</text>
505
+ <text fg={color.dim}>{() => recentLog()}</text>
506
+ </Show>
507
+ <text>{""}</text>
508
+ <ActionRow
509
+ items={[
510
+ { key: "enter", label: "retry", keyFg: color.accent, labelFg: color.dim },
511
+ { key: "q", label: "quit", keyFg: color.muted, labelFg: color.dim },
512
+ ]}
513
+ />
514
+ </Panel>
515
+ </Show>
516
+
517
+ <Show when={currentPhase() === "done"}>
518
+ <Panel gap={0}>
519
+ <text fg={color.dim}>Connect from any device on your tailnet</text>
520
+ <text fg={color.accent}>{() => `${remoteUrl()}`}</text>
521
+ <ActionRow
522
+ items={[
523
+ { key: "1", label: "copy URL", keyFg: keyColor("1"), labelFg: labelColor("1") },
524
+ { key: "2", label: "open browser", keyFg: keyColor("2"), labelFg: labelColor("2") },
525
+ ]}
526
+ />
527
+ <text>{""}</text>
528
+ <text fg={color.dim}>Attach from this machine</text>
529
+ <text fg={color.text}>{() => `${localCmd()}`}</text>
530
+ <ActionRow
531
+ items={[
532
+ { key: "3", label: "copy command", keyFg: keyColor("3"), labelFg: labelColor("3") },
533
+ { key: "q", label: "quit", keyFg: color.muted, labelFg: color.dim },
534
+ ]}
535
+ />
536
+ </Panel>
537
+ </Show>
538
+
539
+ <Show when={currentPhase() === "done" && qrCode() && !compact()}>
540
+ <Panel tone="soft" paddingBottom={0}>
541
+ <Show when={showTailscaleQR()}>
542
+ <text fg={color.accent}>Scan to install Tailscale on your phone</text>
543
+ <text fg={color.dim}>{TAILSCALE_DOWNLOAD_URL}</text>
544
+ </Show>
545
+ <Show when={!showTailscaleQR()}>
546
+ <text fg={color.muted}>Scan to connect from mobile</text>
547
+ <text fg={color.dim}>Phone must be on your tailnet</text>
548
+ <ActionRow
549
+ items={[{ key: "4", label: "download Tailscale mobile app", keyFg: color.muted, labelFg: color.dim }]}
550
+ />
551
+ </Show>
552
+ <text>{() => qrCode()}</text>
553
+ </Panel>
554
+ </Show>
555
+
556
+ <Show when={currentPhase() === "done" && qrCode() && compact()}>
557
+ <Panel tone="soft">
558
+ <text fg={color.dim}>Terminal is narrow, use [1] to copy the URL.</text>
559
+ </Panel>
560
+ </Show>
561
+ </box>
562
+ </box>
563
+ </box>
564
+ )
565
+ }
package/src/flow.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { Deferred, Effect, Layer } from "effect"
2
+ import { appRuntime } from "./runtime.js"
3
+ import { registry, phase, step, log, url, error, missingBinary } from "./state.js"
4
+ import { Tailscale } from "./services/tailscale.js"
5
+ import { OpenCode } from "./services/opencode.js"
6
+ import { AppConfig } from "./services/config.js"
7
+ import { stripTerminalControl, trim } from "./qr.js"
8
+ import { BinaryNotFound } from "./services/errors.js"
9
+
10
+ function append(line: string) {
11
+ const clean = stripTerminalControl(line)
12
+ if (!clean) return
13
+ registry.update(log, (prev) => trim(prev + clean, 16_000))
14
+ }
15
+
16
+ // Resolved when the user requests a clean exit so the scope finalizer runs
17
+ const exitSignal = Deferred.makeUnsafe<void>()
18
+ let exitSignaled = false
19
+ let flowRunning = false
20
+ const shutdownWaiters = new Set<() => void>()
21
+
22
+ function resolveShutdownWaiters() {
23
+ for (const resolve of shutdownWaiters) resolve()
24
+ shutdownWaiters.clear()
25
+ }
26
+
27
+ export function signalExit() {
28
+ if (exitSignaled) return
29
+ exitSignaled = true
30
+ Deferred.doneUnsafe(exitSignal, Effect.succeed(undefined))
31
+ }
32
+
33
+ export function waitForFlowStop() {
34
+ if (!flowRunning) return Promise.resolve()
35
+ return new Promise<void>((resolve) => {
36
+ shutdownWaiters.add(resolve)
37
+ })
38
+ }
39
+
40
+ export const flowFn = appRuntime.fn<void>()(() =>
41
+ Effect.gen(function* () {
42
+ flowRunning = true
43
+ registry.set(missingBinary, "")
44
+
45
+ const config = yield* AppConfig
46
+ const tailscale = yield* Tailscale
47
+ const opencode = yield* OpenCode
48
+
49
+ // Step 1: Tailscale — ensure connected
50
+ registry.set(step, "tailscale")
51
+ const bin = yield* tailscale.ensure(append)
52
+
53
+ // Step 2: OpenCode — start server (handle lives in scope via ChildProcess.spawn)
54
+ registry.set(step, "opencode")
55
+ yield* opencode.start(config.port, config.password, append)
56
+
57
+ // Step 3: Publish via tailscale serve
58
+ registry.set(step, "publish")
59
+ const remote = yield* tailscale.publish(bin, config.port, append)
60
+
61
+ registry.set(url, remote)
62
+ registry.set(log, "")
63
+ registry.set(step, "idle")
64
+ registry.set(missingBinary, "")
65
+ registry.set(phase, "done")
66
+
67
+ // Hold scope open (keeping finalizer alive) until quit is signalled.
68
+ // Re-triggering flowFn interrupts this fiber instead.
69
+ yield* Deferred.await(exitSignal)
70
+ }).pipe(
71
+ Effect.catch((err) =>
72
+ Effect.sync(() => {
73
+ if ((err as any)?._tag === "BinaryNotFound") {
74
+ const missing = err as BinaryNotFound
75
+ registry.set(error, `${missing.binary} is not installed`)
76
+ if (missing.binary === "tailscale" || missing.binary === "opencode") {
77
+ registry.set(missingBinary, missing.binary)
78
+ } else {
79
+ registry.set(missingBinary, "")
80
+ }
81
+ registry.set(phase, "install")
82
+ return
83
+ }
84
+
85
+ registry.set(missingBinary, "")
86
+ registry.set(error, "message" in err ? (err as any).message : String(err))
87
+ registry.set(phase, "error")
88
+ }),
89
+ ),
90
+ Effect.provide(Layer.mergeAll(Tailscale.layer, OpenCode.layer)),
91
+ Effect.ensuring(
92
+ Effect.sync(() => {
93
+ flowRunning = false
94
+ resolveShutdownWaiters()
95
+ }),
96
+ ),
97
+ ),
98
+ )
99
+
100
+ function gracefulProcessExit() {
101
+ signalExit()
102
+ const fallback = setTimeout(() => process.exit(0), 5000)
103
+ void waitForFlowStop().finally(() => {
104
+ clearTimeout(fallback)
105
+ process.exit(0)
106
+ })
107
+ }
108
+
109
+ // Signal handlers
110
+ process.on("SIGINT", () => {
111
+ gracefulProcessExit()
112
+ })
113
+ process.on("SIGTERM", () => {
114
+ gracefulProcessExit()
115
+ })
package/src/main.tsx ADDED
@@ -0,0 +1,18 @@
1
+ import { render } from "@opentui/solid"
2
+ import { RegistryContext } from "@effect/atom-solid/RegistryContext"
3
+ import { App } from "./app.js"
4
+ import { registry } from "./state.js"
5
+
6
+ // -- Render --
7
+
8
+ render(
9
+ () => (
10
+ <RegistryContext.Provider value={registry}>
11
+ <App />
12
+ </RegistryContext.Provider>
13
+ ),
14
+ {
15
+ targetFps: 60,
16
+ exitOnCtrlC: false,
17
+ },
18
+ )