@kitlangton/tailcode 0.2.3 → 0.2.4

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/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
- )
package/src/qr.ts DELETED
@@ -1,118 +0,0 @@
1
- import QRCode from "qrcode"
2
-
3
- const ANSI_ESCAPE_RE = new RegExp(
4
- `[${String.fromCharCode(27)}${String.fromCharCode(155)}][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`,
5
- "g",
6
- )
7
-
8
- export function trim(value: string, max: number) {
9
- if (value.length <= max) return value
10
- return value.slice(value.length - max)
11
- }
12
-
13
- export function parseURL(value: string) {
14
- const stripped = stripTerminalControl(value)
15
- return stripped.match(/https?:\/\/[^\s)]+/)?.[0]
16
- }
17
-
18
- export function stripTerminalControl(value: string) {
19
- return value.replace(ANSI_ESCAPE_RE, "").replace(/\r/g, "")
20
- }
21
-
22
- export function qrCell(data: ReadonlyArray<number | boolean> | Uint8Array, size: number, x: number, y: number) {
23
- if (x < 0 || y < 0 || x >= size || y >= size) return false
24
- return Boolean((data as any)[y * size + x])
25
- }
26
-
27
- export function renderQR(value: string) {
28
- const result = QRCode.create(value, { errorCorrectionLevel: "L" })
29
- const size = result.modules.size
30
- const data = result.modules.data as unknown as ReadonlyArray<number | boolean> | Uint8Array
31
-
32
- const border = 2
33
- const lines: string[] = []
34
-
35
- for (let y = -border; y < size + border; y += 2) {
36
- let line = ""
37
- for (let x = -border; x < size + border; x++) {
38
- const top = qrCell(data, size, x, y)
39
- const bot = qrCell(data, size, x, y + 1)
40
- if (top && bot) line += "█"
41
- else if (top) line += "▀"
42
- else if (bot) line += "▄"
43
- else line += " "
44
- }
45
- lines.push(line)
46
- }
47
-
48
- return lines.join("\n")
49
- }
50
-
51
- export async function copyToClipboard(text: string) {
52
- const runWithStdin = async (cmd: string, args: string[] = []) => {
53
- try {
54
- const proc = Bun.spawn([cmd, ...args], {
55
- stdin: "pipe",
56
- stdout: "ignore",
57
- stderr: "ignore",
58
- })
59
- proc.stdin.write(text)
60
- proc.stdin.end()
61
- return (await proc.exited) === 0
62
- } catch {
63
- return false
64
- }
65
- }
66
-
67
- if (process.platform === "darwin") {
68
- if (!(await runWithStdin("pbcopy"))) throw new Error("Failed to copy to clipboard")
69
- return
70
- }
71
-
72
- if (process.platform === "win32") {
73
- if (!(await runWithStdin("clip"))) throw new Error("Failed to copy to clipboard")
74
- return
75
- }
76
-
77
- const linuxTools: ReadonlyArray<{ cmd: string; args: string[] }> = [
78
- { cmd: "wl-copy", args: [] },
79
- { cmd: "xclip", args: ["-selection", "clipboard"] },
80
- { cmd: "xsel", args: ["--clipboard", "--input"] },
81
- ]
82
-
83
- for (const tool of linuxTools) {
84
- if (!Bun.which(tool.cmd)) continue
85
- if (await runWithStdin(tool.cmd, tool.args)) return
86
- }
87
-
88
- throw new Error("No supported clipboard tool found")
89
- }
90
-
91
- export async function openInBrowser(url: string) {
92
- const run = async (cmd: string, args: string[]) => {
93
- try {
94
- const proc = Bun.spawn([cmd, ...args], {
95
- stdin: "ignore",
96
- stdout: "ignore",
97
- stderr: "ignore",
98
- })
99
- return (await proc.exited) === 0
100
- } catch {
101
- return false
102
- }
103
- }
104
-
105
- if (process.platform === "darwin") {
106
- if (!(await run("open", [url]))) throw new Error("Failed to open browser")
107
- return
108
- }
109
-
110
- if (process.platform === "win32") {
111
- if (!(await run("cmd", ["/c", "start", "", url]))) {
112
- throw new Error("Failed to open browser")
113
- }
114
- return
115
- }
116
-
117
- if (!(await run("xdg-open", [url]))) throw new Error("Failed to open browser")
118
- }
package/src/runtime.ts DELETED
@@ -1,6 +0,0 @@
1
- import * as Atom from "effect/unstable/reactivity/Atom"
2
- import { Layer } from "effect"
3
- import { AppConfig } from "./services/config.js"
4
- import { BunServices } from "@effect/platform-bun"
5
-
6
- export const appRuntime = Atom.runtime(Layer.mergeAll(AppConfig.layer, BunServices.layer))
@@ -1,28 +0,0 @@
1
- import { Config, Effect, Layer, Redacted, Schema, ServiceMap } from "effect"
2
-
3
- const Port = Schema.Int.pipe(Schema.brand("Port"))
4
- type Port = typeof Port.Type
5
-
6
- const Password = Schema.Redacted(Schema.String).pipe(Schema.brand("Password"))
7
- type Password = typeof Password.Type
8
-
9
- export class AppConfig extends ServiceMap.Service<
10
- AppConfig,
11
- {
12
- readonly port: Port
13
- readonly password: Password
14
- }
15
- >()("@tailcode/AppConfig") {
16
- static readonly layer = Layer.effect(AppConfig)(
17
- Effect.gen(function* () {
18
- const config = Config.all({
19
- port: Config.schema(Port, "TAILCODE_PORT").pipe(Config.withDefault(() => Port.makeUnsafe(4096))),
20
- password: Config.schema(Password, "TAILCODE_PASSWORD").pipe(
21
- Config.withDefault(() => Password.makeUnsafe(Redacted.make(""))),
22
- ),
23
- })
24
-
25
- return yield* config
26
- }),
27
- ).pipe(Layer.orDie)
28
- }
@@ -1,14 +0,0 @@
1
- import { Schema } from "effect"
2
-
3
- export class BinaryNotFound extends Schema.TaggedErrorClass<BinaryNotFound>()("BinaryNotFound", {
4
- binary: Schema.String,
5
- }) {}
6
-
7
- export class CommandFailed extends Schema.TaggedErrorClass<CommandFailed>()("CommandFailed", {
8
- command: Schema.String,
9
- message: Schema.String,
10
- }) {}
11
-
12
- export class HealthCheckFailed extends Schema.TaggedErrorClass<HealthCheckFailed>()("HealthCheckFailed", {
13
- message: Schema.String,
14
- }) {}
@@ -1,108 +0,0 @@
1
- import { Duration, Effect, Layer, PlatformError, Redacted, Schedule, ServiceMap } from "effect"
2
- import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
3
- import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
4
- import { BinaryNotFound, HealthCheckFailed } from "./errors.js"
5
- import { trim } from "../qr.js"
6
- import { spawnInScope, streamToAppender } from "./process.js"
7
-
8
- // ---------------------------------------------------------------------------
9
- // Service
10
- // ---------------------------------------------------------------------------
11
-
12
- export class OpenCode extends ServiceMap.Service<
13
- OpenCode,
14
- {
15
- /** Start local OpenCode server and wait until health endpoint responds. */
16
- readonly start: (
17
- port: number,
18
- password: string | Redacted.Redacted<string>,
19
- append: (line: string) => void,
20
- ) => Effect.Effect<ChildProcessHandle | undefined, BinaryNotFound | HealthCheckFailed | PlatformError.PlatformError>
21
- }
22
- >()("@tailcode/OpenCode") {
23
- static readonly layer = Layer.effect(OpenCode)(
24
- Effect.gen(function* () {
25
- const spawner = yield* ChildProcessSpawner
26
- const scope = yield* Effect.scope
27
-
28
- /** Start opencode bound to localhost and tie lifecycle to service scope. */
29
- const start = Effect.fn("OpenCode.start")(function* (
30
- port: number,
31
- password: string | Redacted.Redacted<string>,
32
- append: (line: string) => void,
33
- ) {
34
- const alreadyHealthy = yield* Effect.tryPromise({
35
- try: () => fetch(`http://127.0.0.1:${port}/global/health`).then((r) => r.ok),
36
- catch: () => false as const,
37
- }).pipe(Effect.catch(() => Effect.succeed(false)))
38
-
39
- if (alreadyHealthy) {
40
- append(`OpenCode server already running on 127.0.0.1:${port}\n`)
41
- return undefined
42
- }
43
-
44
- const bin = Bun.which("opencode")
45
- if (!bin) return yield* new BinaryNotFound({ binary: "opencode" })
46
-
47
- append(`Starting OpenCode server on 127.0.0.1:${port}...\n`)
48
-
49
- const env: Record<string, string> = {}
50
- for (const [k, v] of Object.entries(process.env)) {
51
- if (v !== undefined) env[k] = v
52
- }
53
- const passwordValue = Redacted.isRedacted(password) ? Redacted.value(password) : password
54
- if (passwordValue) env.OPENCODE_SERVER_PASSWORD = passwordValue
55
-
56
- const handle = yield* spawnInScope(
57
- spawner,
58
- scope,
59
- bin,
60
- ["serve", "--hostname", "127.0.0.1", "--port", String(port)],
61
- {
62
- env,
63
- extendEnv: false,
64
- },
65
- )
66
-
67
- let buffer = ""
68
- yield* Effect.forkIn(
69
- streamToAppender(handle.all, (text) => {
70
- buffer = trim(buffer + text, 8000)
71
- append(text)
72
- }),
73
- scope,
74
- )
75
-
76
- const checkHealth = Effect.tryPromise({
77
- try: () =>
78
- fetch(`http://127.0.0.1:${port}/global/health`).then((r) => {
79
- if (!r.ok) throw new Error("not healthy")
80
- }),
81
- catch: () => new HealthCheckFailed({ message: "not healthy yet" }),
82
- }).pipe(
83
- Effect.timeoutOrElse({
84
- duration: Duration.seconds(2),
85
- onTimeout: () => Effect.fail(new HealthCheckFailed({ message: "health check timeout" })),
86
- }),
87
- )
88
-
89
- const healthCheckPolicy = Schedule.spaced(Duration.millis(250)).pipe(Schedule.both(Schedule.recurs(40)))
90
-
91
- yield* Effect.retryOrElse(checkHealth, healthCheckPolicy, () =>
92
- Effect.gen(function* () {
93
- yield* handle.kill().pipe(Effect.ignore)
94
- return yield* new HealthCheckFailed({
95
- message: `OpenCode server did not become healthy\n${buffer}`,
96
- })
97
- }),
98
- )
99
-
100
- return handle
101
- })
102
-
103
- return {
104
- start,
105
- }
106
- }),
107
- )
108
- }
@@ -1,52 +0,0 @@
1
- import { Effect, Scope, Stream } from "effect"
2
- import * as ChildProcess from "effect/unstable/process/ChildProcess"
3
- import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
4
-
5
- type CommandOptions = Record<string, unknown> | undefined
6
-
7
- /** Build a process command with consistent non-interactive IO defaults. */
8
- export const command = (bin: string, args: string[], options?: CommandOptions) =>
9
- ChildProcess.make(bin, args, {
10
- ...options,
11
- stdin: "ignore",
12
- stdout: "pipe",
13
- stderr: "pipe",
14
- })
15
-
16
- /** Spawn a process in an existing scope so finalization is tied to that scope. */
17
- export const spawnInScope = (
18
- spawner: ChildProcessSpawner,
19
- scope: Scope.Scope,
20
- bin: string,
21
- args: string[],
22
- options?: CommandOptions,
23
- ) => Scope.provide(spawner.spawn(command(bin, args, options)), scope)
24
-
25
- /** Run a command to completion and return only its exit code. */
26
- export const spawnExitCode = (spawner: ChildProcessSpawner, bin: string, args: string[], options?: CommandOptions) =>
27
- Effect.scoped(spawner.spawn(command(bin, args, options)).pipe(Effect.flatMap((handle) => handle.exitCode)))
28
-
29
- /** Run a command to completion and collect combined stdout/stderr text. */
30
- export const spawnString = (spawner: ChildProcessSpawner, bin: string, args: string[], options?: CommandOptions) =>
31
- Effect.scoped(
32
- spawner
33
- .spawn(command(bin, args, options))
34
- .pipe(Effect.flatMap((handle) => Stream.mkString(Stream.decodeText(handle.all)))),
35
- )
36
-
37
- /** Forward decoded process output chunks into an append callback. */
38
- export const streamToAppender = <E, R>(stream: Stream.Stream<Uint8Array, E, R>, append: (line: string) => void) =>
39
- Stream.runForEach(stream, (chunk) =>
40
- Effect.sync(() => {
41
- const text = new TextDecoder().decode(chunk)
42
- if (!text) return
43
- append(text)
44
- }),
45
- )
46
-
47
- /** Swallow any failure and return void for best-effort cleanup paths. */
48
- export const ignoreErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
49
- Effect.match(effect, {
50
- onFailure: () => undefined,
51
- onSuccess: () => undefined,
52
- })