From e34b4c1e868bf5bbc9fc669d5de5865df6b1d247 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 02:54:03 -0700 Subject: [PATCH 1/3] refactor tailscale command errors Co-authored-by: codex --- packages/tailscale/src/tailscale.test.ts | 87 +++++++- packages/tailscale/src/tailscale.ts | 265 ++++++++++++----------- 2 files changed, 220 insertions(+), 132 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f1d47ad9d21..0f145a1bc06 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -16,6 +18,10 @@ import { parseTailscaleStatus, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, + TailscaleCommandExitError, + TailscaleCommandSpawnError, + TailscaleCommandTimeoutError, + TailscaleStatusParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -100,6 +106,17 @@ describe("tailscale", () => { }), ); + it.effect("preserves status decoding failures without exposing cause text", () => + Effect.gen(function* () { + const error = yield* parseTailscaleStatus("{not-json").pipe(Effect.flip); + + assert.instanceOf(error, TailscaleStatusParseError); + assert.equal(error.message, "Failed to decode tailscale status JSON."); + assert.isDefined(error.cause); + assert.notInclude(error.message, String(error.cause)); + }), + ); + it.effect("builds clean HTTPS base URLs", () => Effect.sync(() => { assert.equal( @@ -131,6 +148,44 @@ describe("tailscale", () => { }); }); + it.effect("preserves tailscale spawn failures as causes", () => { + const systemCause = new Error("private executable lookup detail"); + const cause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + cause: systemCause, + }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(cause)), + ); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to spawn tailscale status --json."); + assert.notInclude(error.message, systemCause.message); + }); + }); + + it.effect("keeps nonzero exit diagnostics structured", () => { + const layer = mockSpawnerLayer(() => ({ code: 7, stderr: "not logged in" })); + + return Effect.gen(function* () { + const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.equal(error.exitCode, 7); + assert.equal(error.stderr, "not logged in"); + assert.equal(error.message, "tailscale status --json exited with code 7."); + }); + }); + it.effect("times out tailscale status through TestClock", () => { const layer = Layer.merge( TestClock.layer(), @@ -146,11 +201,11 @@ describe("tailscale", () => { yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT); const error = yield* Fiber.join(fiber); - if (error._tag !== "TailscaleCommandError") { - assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`); - } - assert.equal(error.message, "Tailscale status timed out."); - assert.equal(error.exitCode, null); + assert.instanceOf(error, TailscaleCommandTimeoutError); + assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.equal(error.timeoutMs, 1_500); + assert.isTrue(Cause.isTimeoutError(error.cause)); + assert.equal(error.message, "tailscale status --json timed out after 1500ms."); }).pipe(Effect.provide(layer)); }); @@ -164,6 +219,28 @@ describe("tailscale", () => { return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); }); + it.effect("retains tailscale serve exit diagnostics", () => { + const layer = mockSpawnerLayer(() => ({ code: 1, stderr: "serve permission denied" })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.deepEqual(error.command, [ + "tailscale", + "serve", + "--bg", + "--https=8443", + "http://127.0.0.1:13773", + ]); + assert.equal(error.exitCode, 1); + assert.equal(error.stderr, "serve permission denied"); + }); + }); + it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f468dec7294..f7444ea0df7 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,5 +1,4 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -18,20 +17,85 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => platform === "win32" ? "tailscale.exe" : "tailscale"; -export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} +const TailscaleCommandContext = { + command: Schema.Array(Schema.String), +}; + +export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( + "TailscaleCommandSpawnError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to spawn ${this.command.join(" ")}.`; + } +} -export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ - readonly cause: unknown; -}> {} +export class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( + "TailscaleCommandOutputError", + { + ...TailscaleCommandContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read output from ${this.command.join(" ")}.`; + } +} -export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ - readonly reason: string; -}> {} +export class TailscaleCommandExitError extends Schema.TaggedErrorClass()( + "TailscaleCommandExitError", + { + ...TailscaleCommandContext, + exitCode: Schema.Number, + stderr: Schema.String, + }, +) { + override get message(): string { + return `${this.command.join(" ")} exited with code ${this.exitCode}.`; + } +} + +export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( + "TailscaleCommandTimeoutError", + { + ...TailscaleCommandContext, + timeoutMs: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `${this.command.join(" ")} timed out after ${this.timeoutMs}ms.`; + } +} + +export const TailscaleCommandError = Schema.Union([ + TailscaleCommandSpawnError, + TailscaleCommandOutputError, + TailscaleCommandExitError, + TailscaleCommandTimeoutError, +]); +export type TailscaleCommandError = typeof TailscaleCommandError.Type; + +export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( + "TailscaleStatusParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale status JSON."; + } +} + +export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( + "TailscaleUnavailableError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), @@ -61,19 +125,6 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect - new TailscaleCommandError({ - command: ["tailscale", ...args], - message, - exitCode, - stderr, - }); - const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { @@ -139,55 +190,38 @@ export const readTailscaleStatus = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", - null, - ), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : "Failed to run tailscale status.", - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError( - args, - `Tailscale status exited with code ${exitCode}.`, - exitCode, - stderr, - ); - } - return yield* parseTailscaleStatus(stdout); -}).pipe( - Effect.scoped, - Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => + const command = [tailscaleCommandForPlatform(hostPlatform), ...args]; + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(command[0]!, args)) + .pipe(Effect.mapError((cause) => new TailscaleCommandSpawnError({ command, cause }))); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new TailscaleCommandOutputError({ command, cause }))); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ command, exitCode, stderr }); + } + return yield* parseTailscaleStatus(stdout); + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail( - tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), + new TailscaleCommandTimeoutError({ + command, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), ), - onSome: Effect.succeed, }), - ), -); + ); +}); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; @@ -204,53 +238,39 @@ export function buildTailscaleHttpsBaseUrl(input: { const runTailscaleCommand = ( args: readonly string[], - input: { - readonly spawnMessage: string; - readonly runMessage: string; - readonly exitMessage: (exitCode: number) => string; - readonly timeoutMessage: string; - readonly timeout: Duration.Input; - }, + timeoutInput: Duration.Input, ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const child = yield* spawner - .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) - .pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.spawnMessage, - null, + const command = [tailscaleCommandForPlatform(hostPlatform), ...args]; + const timeout = Duration.fromInputUnsafe(timeoutInput); + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(ChildProcess.make(command[0]!, args)) + .pipe(Effect.mapError((cause) => new TailscaleCommandSpawnError({ command, cause }))); + const [stderr, exitCode] = yield* Effect.all( + [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new TailscaleCommandOutputError({ command, cause }))); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ command, exitCode, stderr }); + } + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + command, + timeoutMs: Duration.toMillis(timeout), + cause, + }), ), - ), - ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - tailscaleCommandError( - args, - cause instanceof Error ? cause.message : input.runMessage, - null, - ), - ), - ); - if (exitCode !== 0) { - return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); - } - }).pipe( - Effect.scoped, - Effect.timeoutOption(input.timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), - onSome: Effect.succeed, }), - ), - ); + ); + }); export const ensureTailscaleServe = (input: { readonly localPort: number; @@ -260,13 +280,7 @@ export const ensureTailscaleServe = (input: { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; const localHost = input.localHost ?? "127.0.0.1"; const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, { - spawnMessage: "Failed to spawn tailscale serve.", - runMessage: "Failed to run tailscale serve.", - exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); }; export const disableTailscaleServe = ( @@ -276,13 +290,10 @@ export const disableTailscaleServe = ( ): Effect.Effect => Effect.gen(function* () { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { - spawnMessage: "Failed to spawn tailscale serve off.", - runMessage: "Failed to run tailscale serve off.", - exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, - timeoutMessage: "Tailscale serve off timed out.", - timeout: TAILSCALE_SERVE_TIMEOUT, - }); + return yield* runTailscaleCommand( + ["serve", `--https=${servePort}`, "off"], + TAILSCALE_SERVE_TIMEOUT, + ); }); export const probeTailscaleHttpsEndpoint = (input: { From 311e609ff4a92c3024b4a2933f973c503000cd23 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:49:01 -0700 Subject: [PATCH 2/3] Remove unused Tailscale error Co-authored-by: codex --- packages/tailscale/src/tailscale.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f7444ea0df7..358a208db7a 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -88,15 +88,6 @@ export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( - "TailscaleUnavailableError", - { reason: Schema.String }, -) { - override get message(): string { - return this.reason; - } -} - const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), From 1db0e325fccd6dcac00278a8bd336c8c5f736343 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:24:11 -0700 Subject: [PATCH 3/3] fix(tailscale): redact command payloads Co-authored-by: codex --- packages/tailscale/src/tailscale.test.ts | 49 +++++++++++------ packages/tailscale/src/tailscale.ts | 68 +++++++++++++++++------- 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 0f145a1bc06..853bb1f81c2 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -165,24 +165,35 @@ describe("tailscale", () => { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandSpawnError); - assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to spawn tailscale status --json."); + assert.equal(error.message, "Failed to spawn tailscale status."); assert.notInclude(error.message, systemCause.message); }); }); it.effect("keeps nonzero exit diagnostics structured", () => { - const layer = mockSpawnerLayer(() => ({ code: 7, stderr: "not logged in" })); + const layer = mockSpawnerLayer(() => ({ + code: 7, + stderr: "not logged in tskey-auth-secret-token-value", + })); return Effect.gen(function* () { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandExitError); - assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); assert.equal(error.exitCode, 7); - assert.equal(error.stderr, "not logged in"); - assert.equal(error.message, "tailscale status --json exited with code 7."); + assert.equal(error.stdoutLength, 0); + assert.equal(error.stderrLength, 43); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + assert.equal(error.message, "tailscale status exited with code 7."); }); }); @@ -202,10 +213,12 @@ describe("tailscale", () => { const error = yield* Fiber.join(fiber); assert.instanceOf(error, TailscaleCommandTimeoutError); - assert.deepEqual(error.command, ["tailscale", "status", "--json"]); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "status"); + assert.equal(error.argumentCount, 2); assert.equal(error.timeoutMs, 1_500); assert.isTrue(Cause.isTimeoutError(error.cause)); - assert.equal(error.message, "tailscale status --json timed out after 1500ms."); + assert.equal(error.message, "tailscale status timed out after 1500ms."); }).pipe(Effect.provide(layer)); }); @@ -220,7 +233,10 @@ describe("tailscale", () => { }); it.effect("retains tailscale serve exit diagnostics", () => { - const layer = mockSpawnerLayer(() => ({ code: 1, stderr: "serve permission denied" })); + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr: "serve permission denied tskey-auth-secret-token-value", + })); return Effect.gen(function* () { const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( @@ -229,15 +245,14 @@ describe("tailscale", () => { ); assert.instanceOf(error, TailscaleCommandExitError); - assert.deepEqual(error.command, [ - "tailscale", - "serve", - "--bg", - "--https=8443", - "http://127.0.0.1:13773", - ]); + assert.equal(error.executable, "tailscale"); + assert.equal(error.subcommand, "serve"); + assert.equal(error.argumentCount, 4); assert.equal(error.exitCode, 1); - assert.equal(error.stderr, "serve permission denied"); + assert.equal(error.stderrLength, 53); + assert.notProperty(error, "command"); + assert.notProperty(error, "stderr"); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 358a208db7a..27761490af0 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -14,11 +14,13 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. -const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; const TailscaleCommandContext = { - command: Schema.Array(Schema.String), + executable: Schema.Literals(["tailscale", "tailscale.exe"]), + subcommand: Schema.Literals(["status", "serve"]), + argumentCount: Schema.Number, }; export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( @@ -29,7 +31,7 @@ export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass new TailscaleCommandSpawnError({ command, cause }))); + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStdout(child.stdout), @@ -193,9 +203,16 @@ export const readTailscaleStatus = Effect.gen(function* () { child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, - ).pipe(Effect.mapError((cause) => new TailscaleCommandOutputError({ command, cause }))); + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + ); if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ command, exitCode, stderr }); + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); } return yield* parseTailscaleStatus(stdout); }).pipe( @@ -205,7 +222,7 @@ export const readTailscaleStatus = Effect.gen(function* () { TimeoutError: (cause) => Effect.fail( new TailscaleCommandTimeoutError({ - command, + ...commandContext, timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), cause, }), @@ -234,18 +251,31 @@ const runTailscaleCommand = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const command = [tailscaleCommandForPlatform(hostPlatform), ...args]; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand: "serve" as const, + argumentCount: args.length, + }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { const child = yield* spawner - .spawn(ChildProcess.make(command[0]!, args)) - .pipe(Effect.mapError((cause) => new TailscaleCommandSpawnError({ command, cause }))); + .spawn(ChildProcess.make(executable, args)) + .pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, - ).pipe(Effect.mapError((cause) => new TailscaleCommandOutputError({ command, cause }))); + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + ); if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ command, exitCode, stderr }); + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stderrLength: stderr.length, + }); } }).pipe( Effect.scoped, @@ -254,7 +284,7 @@ const runTailscaleCommand = ( TimeoutError: (cause) => Effect.fail( new TailscaleCommandTimeoutError({ - command, + ...commandContext, timeoutMs: Duration.toMillis(timeout), cause, }),