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