@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.
@@ -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
- }