@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/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))
|
package/src/services/config.ts
DELETED
|
@@ -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
|
-
}
|
package/src/services/errors.ts
DELETED
|
@@ -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
|
-
}) {}
|
package/src/services/opencode.ts
DELETED
|
@@ -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
|
-
}
|
package/src/services/process.ts
DELETED
|
@@ -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
|
-
})
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
import { Duration, Effect, Layer, Option, PlatformError, Schedule, Schema, Scope, ServiceMap } from "effect"
|
|
2
|
-
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
3
|
-
import { BinaryNotFound, CommandFailed } from "./errors.js"
|
|
4
|
-
import { parseURL, renderQR, trim } from "../qr.js"
|
|
5
|
-
import { ignoreErrors, spawnExitCode, spawnString, spawnInScope, streamToAppender } from "./process.js"
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Parsing helpers
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
function parseConflictPort(value: string) {
|
|
12
|
-
const match = value.match(/listener already exists for port\s+(\d+)/i)
|
|
13
|
-
return match ? Number(match[1]) : undefined
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type ServeMapping = {
|
|
17
|
-
readonly url: string
|
|
18
|
-
readonly proxy: string | undefined
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const ServeHandlerSchema = Schema.Struct({
|
|
22
|
-
Proxy: Schema.optional(Schema.String),
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
const ServeHandlersSchema = Schema.Record(Schema.String, ServeHandlerSchema)
|
|
26
|
-
|
|
27
|
-
const ServeEndpointSchema = Schema.Struct({
|
|
28
|
-
Handlers: Schema.optional(ServeHandlersSchema),
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
const ServeWebSchema = Schema.Record(Schema.String, ServeEndpointSchema)
|
|
32
|
-
|
|
33
|
-
const ServeNodeSchema = Schema.Struct({
|
|
34
|
-
Web: Schema.optional(ServeWebSchema),
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const ServeNodesSchema = Schema.Record(Schema.String, ServeNodeSchema)
|
|
38
|
-
|
|
39
|
-
const ServeStatusSchema = Schema.Struct({
|
|
40
|
-
Web: Schema.optional(ServeWebSchema),
|
|
41
|
-
Foreground: Schema.optional(ServeNodesSchema),
|
|
42
|
-
Background: Schema.optional(ServeNodesSchema),
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
type ServeWeb = Schema.Schema.Type<typeof ServeWebSchema>
|
|
46
|
-
type ServeStatus = Schema.Schema.Type<typeof ServeStatusSchema>
|
|
47
|
-
|
|
48
|
-
const decodeServeStatus = Schema.decodeUnknownSync(ServeStatusSchema)
|
|
49
|
-
|
|
50
|
-
function appendMappingsFromWeb(web: ServeWeb | undefined, mappings: Array<ServeMapping>) {
|
|
51
|
-
if (!web) return
|
|
52
|
-
|
|
53
|
-
for (const [host, endpoint] of Object.entries(web)) {
|
|
54
|
-
const handlers = endpoint.Handlers
|
|
55
|
-
if (!handlers || Object.keys(handlers).length === 0) {
|
|
56
|
-
mappings.push({ url: `https://${host}`, proxy: undefined })
|
|
57
|
-
continue
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
for (const [path, handler] of Object.entries(handlers)) {
|
|
61
|
-
const normalizedPath = path === "/" ? "" : String(path)
|
|
62
|
-
mappings.push({
|
|
63
|
-
url: `https://${host}${normalizedPath}`,
|
|
64
|
-
proxy: handler.Proxy,
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseServeMappings(raw: string): ReadonlyArray<ServeMapping> {
|
|
71
|
-
const start = raw.indexOf("{")
|
|
72
|
-
if (start === -1) return []
|
|
73
|
-
|
|
74
|
-
const jsonText = raw.slice(start)
|
|
75
|
-
|
|
76
|
-
let status: ServeStatus
|
|
77
|
-
try {
|
|
78
|
-
status = decodeServeStatus(JSON.parse(jsonText))
|
|
79
|
-
} catch {
|
|
80
|
-
return []
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const mappings: Array<ServeMapping> = []
|
|
84
|
-
|
|
85
|
-
appendMappingsFromWeb(status.Web, mappings)
|
|
86
|
-
for (const node of Object.values(status.Foreground ?? {})) {
|
|
87
|
-
appendMappingsFromWeb(node.Web, mappings)
|
|
88
|
-
}
|
|
89
|
-
for (const node of Object.values(status.Background ?? {})) {
|
|
90
|
-
appendMappingsFromWeb(node.Web, mappings)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return mappings
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function pickRemoteUrl(statusOutput: string, target: string) {
|
|
97
|
-
const statusMappings = parseServeMappings(statusOutput)
|
|
98
|
-
const exact = statusMappings.find((item) => item.proxy === target)
|
|
99
|
-
if (exact) return exact.url
|
|
100
|
-
if (statusMappings.length > 0) return statusMappings[0]!.url
|
|
101
|
-
return undefined
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Service
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
export class Tailscale extends ServiceMap.Service<
|
|
109
|
-
Tailscale,
|
|
110
|
-
{
|
|
111
|
-
/** Ensure Tailscale is connected; prompts login when disconnected. */
|
|
112
|
-
readonly ensure: (
|
|
113
|
-
append: (line: string) => void,
|
|
114
|
-
) => Effect.Effect<string, BinaryNotFound | CommandFailed | PlatformError.PlatformError>
|
|
115
|
-
/** Publish local OpenCode port via tailscale serve and return remote URL. */
|
|
116
|
-
readonly publish: (
|
|
117
|
-
bin: string,
|
|
118
|
-
port: number,
|
|
119
|
-
append: (line: string) => void,
|
|
120
|
-
) => Effect.Effect<string, CommandFailed | PlatformError.PlatformError>
|
|
121
|
-
}
|
|
122
|
-
>()("@tailcode/Tailscale") {
|
|
123
|
-
static readonly layer = Layer.effect(Tailscale)(
|
|
124
|
-
Effect.gen(function* () {
|
|
125
|
-
const spawner = yield* ChildProcessSpawner
|
|
126
|
-
const scope = yield* Effect.scope
|
|
127
|
-
|
|
128
|
-
const run = (bin: string, args: string[]) => spawnExitCode(spawner, bin, args)
|
|
129
|
-
|
|
130
|
-
const runString = (bin: string, args: string[]) => spawnString(spawner, bin, args)
|
|
131
|
-
|
|
132
|
-
const readServeStatus = (bin: string) =>
|
|
133
|
-
runString(bin, ["serve", "status", "--json"]).pipe(Effect.catch(() => Effect.succeed("")))
|
|
134
|
-
|
|
135
|
-
const waitForTailnetConnection = (bin: string) =>
|
|
136
|
-
run(bin, ["ip", "-4"]).pipe(
|
|
137
|
-
Effect.timeoutOrElse({
|
|
138
|
-
duration: Duration.seconds(2),
|
|
139
|
-
onTimeout: () => Effect.fail(new CommandFailed({ command: "tailscale ip", message: "timeout" })),
|
|
140
|
-
}),
|
|
141
|
-
Effect.flatMap((code) =>
|
|
142
|
-
code === 0
|
|
143
|
-
? Effect.void
|
|
144
|
-
: Effect.fail(new CommandFailed({ command: "tailscale ip", message: "not connected yet" })),
|
|
145
|
-
),
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
const retryConnectionPolicy = Schedule.spaced(Duration.millis(250)).pipe(Schedule.both(Schedule.recurs(80)))
|
|
149
|
-
|
|
150
|
-
const retryPublishPolicy = Schedule.spaced(Duration.millis(500)).pipe(Schedule.both(Schedule.recurs(28)))
|
|
151
|
-
|
|
152
|
-
/** Ensure daemon availability and interactive login if needed. */
|
|
153
|
-
const ensure = Effect.fn("Tailscale.ensure")(function* (append: (line: string) => void) {
|
|
154
|
-
const bin = Bun.which("tailscale")
|
|
155
|
-
if (!bin) return yield* new BinaryNotFound({ binary: "tailscale" })
|
|
156
|
-
|
|
157
|
-
append("Checking Tailscale connection...\n")
|
|
158
|
-
const checkCode = yield* run(bin, ["ip", "-4"]).pipe(
|
|
159
|
-
Effect.timeoutOrElse({
|
|
160
|
-
duration: Duration.seconds(3),
|
|
161
|
-
onTimeout: () => Effect.succeed(1),
|
|
162
|
-
}),
|
|
163
|
-
)
|
|
164
|
-
if (checkCode === 0) return bin
|
|
165
|
-
|
|
166
|
-
append("Tailscale is not connected. Starting login flow...\n")
|
|
167
|
-
const login = yield* runString(bin, ["up", "--qr"]).pipe(
|
|
168
|
-
Effect.timeoutOrElse({
|
|
169
|
-
duration: Duration.seconds(60),
|
|
170
|
-
onTimeout: () => Effect.succeed(""),
|
|
171
|
-
}),
|
|
172
|
-
Effect.orElseSucceed(() => ""),
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
const loginURL = parseURL(login)
|
|
176
|
-
if (loginURL) {
|
|
177
|
-
append(`Open this URL (or scan QR): ${loginURL}\n`)
|
|
178
|
-
append(renderQR(loginURL) + "\n")
|
|
179
|
-
} else if (login) {
|
|
180
|
-
append("Follow the Tailscale login prompts in your terminal.\n")
|
|
181
|
-
append(trim(login, 4000) + "\n")
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
yield* Effect.retryOrElse(waitForTailnetConnection(bin), retryConnectionPolicy, () =>
|
|
185
|
-
Effect.fail(
|
|
186
|
-
new CommandFailed({
|
|
187
|
-
command: "tailscale ip",
|
|
188
|
-
message: "Timed out waiting for Tailscale to connect",
|
|
189
|
-
}),
|
|
190
|
-
),
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
return bin
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
/** Publish localhost port into tailnet and register scope cleanup. */
|
|
197
|
-
const publish = Effect.fn("Tailscale.publish")(function* (
|
|
198
|
-
bin: string,
|
|
199
|
-
port: number,
|
|
200
|
-
append: (line: string) => void,
|
|
201
|
-
) {
|
|
202
|
-
const target = `http://127.0.0.1:${port}`
|
|
203
|
-
|
|
204
|
-
const spawnServe = () =>
|
|
205
|
-
spawnInScope(spawner, scope, bin, ["serve", "--bg", "--yes", "--https", String(port), target])
|
|
206
|
-
|
|
207
|
-
const waitForProxy = () => {
|
|
208
|
-
const pollForProxy = readServeStatus(bin).pipe(
|
|
209
|
-
Effect.flatMap((status) => {
|
|
210
|
-
const found = pickRemoteUrl(status, target)
|
|
211
|
-
if (found) return Effect.succeed(found)
|
|
212
|
-
return Effect.fail(
|
|
213
|
-
new CommandFailed({
|
|
214
|
-
command: "tailscale serve",
|
|
215
|
-
message: "waiting for tailscale serve to register proxy...",
|
|
216
|
-
}),
|
|
217
|
-
)
|
|
218
|
-
}),
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
return Effect.retryOrElse(pollForProxy, retryPublishPolicy, () =>
|
|
222
|
-
Effect.fail(
|
|
223
|
-
new CommandFailed({
|
|
224
|
-
command: "tailscale serve",
|
|
225
|
-
message: "Timed out waiting for tailscale serve to register proxy",
|
|
226
|
-
}),
|
|
227
|
-
),
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const existingStatus = yield* readServeStatus(bin)
|
|
232
|
-
if (parseServeMappings(existingStatus).some((item) => item.proxy === target)) {
|
|
233
|
-
const existingUrl = pickRemoteUrl(existingStatus, target)
|
|
234
|
-
if (existingUrl) {
|
|
235
|
-
append("Reusing existing tailscale serve listener.\n")
|
|
236
|
-
return existingUrl
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
append("Publishing with tailscale serve...\n")
|
|
241
|
-
|
|
242
|
-
const handle = yield* spawnServe()
|
|
243
|
-
|
|
244
|
-
// Register cleanup as an acquired resource so release is tied atomically
|
|
245
|
-
// to the current scope even across interruption boundaries.
|
|
246
|
-
yield* Effect.acquireRelease(Effect.void, () =>
|
|
247
|
-
ignoreErrors(run(bin, ["serve", "--https", String(port), "off"])),
|
|
248
|
-
).pipe(Scope.provide(scope))
|
|
249
|
-
|
|
250
|
-
let serveOutput = ""
|
|
251
|
-
yield* Effect.forkScoped(
|
|
252
|
-
streamToAppender(handle.all, (text) => {
|
|
253
|
-
append(text)
|
|
254
|
-
serveOutput = trim(serveOutput + text, 8000)
|
|
255
|
-
}),
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
const firstAttempt = yield* waitForProxy().pipe(Effect.option)
|
|
259
|
-
if (Option.isSome(firstAttempt)) return firstAttempt.value
|
|
260
|
-
|
|
261
|
-
const conflictPort = parseConflictPort(serveOutput)
|
|
262
|
-
if (conflictPort === undefined) {
|
|
263
|
-
return yield* new CommandFailed({
|
|
264
|
-
command: "tailscale serve",
|
|
265
|
-
message: "Timed out waiting for tailscale serve to register proxy",
|
|
266
|
-
})
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
append(
|
|
270
|
-
`Port ${conflictPort} is already in use. Turning off existing HTTPS listener on that port and retrying once...\n`,
|
|
271
|
-
)
|
|
272
|
-
yield* run(bin, ["serve", "--https", String(port), "off"]).pipe(Effect.ignore)
|
|
273
|
-
|
|
274
|
-
serveOutput = ""
|
|
275
|
-
const retryHandle = yield* spawnServe()
|
|
276
|
-
yield* Effect.forkScoped(
|
|
277
|
-
streamToAppender(retryHandle.all, (text) => {
|
|
278
|
-
append(text)
|
|
279
|
-
serveOutput = trim(serveOutput + text, 8000)
|
|
280
|
-
}),
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
const secondAttempt = yield* waitForProxy().pipe(Effect.option)
|
|
284
|
-
if (Option.isSome(secondAttempt)) return secondAttempt.value
|
|
285
|
-
|
|
286
|
-
const retryConflictPort = parseConflictPort(serveOutput)
|
|
287
|
-
if (retryConflictPort !== undefined) {
|
|
288
|
-
return yield* new CommandFailed({
|
|
289
|
-
command: "tailscale serve",
|
|
290
|
-
message:
|
|
291
|
-
`Listener already exists for port ${retryConflictPort} and could not be claimed automatically. ` +
|
|
292
|
-
`Please run 'tailscale serve --https ${port} off' manually and retry.`,
|
|
293
|
-
})
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return yield* new CommandFailed({
|
|
297
|
-
command: "tailscale serve",
|
|
298
|
-
message: "Timed out waiting for tailscale serve to register proxy",
|
|
299
|
-
})
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
return {
|
|
303
|
-
ensure,
|
|
304
|
-
publish,
|
|
305
|
-
}
|
|
306
|
-
}),
|
|
307
|
-
)
|
|
308
|
-
}
|
package/src/state.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
2
|
-
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
|
|
3
|
-
|
|
4
|
-
export type Phase = "welcome" | "running" | "done" | "error" | "install"
|
|
5
|
-
export type Step = "idle" | "tailscale" | "opencode" | "publish"
|
|
6
|
-
export type MissingBinary = "" | "tailscale" | "opencode"
|
|
7
|
-
|
|
8
|
-
export const phase = Atom.make<Phase>("welcome")
|
|
9
|
-
export const step = Atom.make<Step>("idle")
|
|
10
|
-
export const log = Atom.make<string>("")
|
|
11
|
-
export const url = Atom.make("")
|
|
12
|
-
export const error = Atom.make("")
|
|
13
|
-
export const missingBinary = Atom.make<MissingBinary>("")
|
|
14
|
-
|
|
15
|
-
export const registry = AtomRegistry.make()
|
package/tsconfig.json
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
-
"extends": "@tsconfig/bun/tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"jsx": "preserve",
|
|
6
|
-
"jsxImportSource": "@opentui/solid",
|
|
7
|
-
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
8
|
-
"plugins": [{ "name": "@effect/language-service" }]
|
|
9
|
-
},
|
|
10
|
-
"include": ["src/**/*.ts", "src/**/*.tsx", "bin/**/*.ts"]
|
|
11
|
-
}
|