Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, describe, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
Expand All @@ -14,13 +15,15 @@ import {
disableTailscaleServe,
ensureTailscaleServe,
isTailscaleIpv4Address,
MACOS_TAILSCALE_APP_EXECUTABLE,
parseTailscaleMagicDnsName,
parseTailscaleStatus,
readTailscaleStatus,
TAILSCALE_STATUS_TIMEOUT,
TailscaleCommandExitError,
TailscaleCommandSpawnError,
TailscaleCommandTimeoutError,
TailscaleExecutableFileCheck,
TailscaleStatusParseError,
} from "./tailscale.ts";

Expand Down Expand Up @@ -212,6 +215,93 @@ describe("tailscale", () => {
});
});

it.effect("falls back to the macOS Tailscale.app binary when `tailscale` isn't on PATH", () => {
const notFoundCause = PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
cause: new Error("ENOENT"),
});
const spawnedCommands: Array<string> = [];
const layer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const childProcess = command as unknown as { readonly command: string };
spawnedCommands.push(childProcess.command);
return childProcess.command === "tailscale"
? Effect.fail(notFoundCause)
: Effect.succeed(mockHandle({ stdout: tailscaleStatusWithSingleIpJson }));
}),
);

return Effect.gen(function* () {
const status = yield* readTailscaleStatus.pipe(
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provideService(
TailscaleExecutableFileCheck,
(filePath) => filePath === MACOS_TAILSCALE_APP_EXECUTABLE,
),
Effect.provide(layer),
);

assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.90.1.2"],
});
assert.deepEqual(spawnedCommands, ["tailscale", MACOS_TAILSCALE_APP_EXECUTABLE]);
});
});

it.effect("does not fall back when the macOS Tailscale.app binary isn't installed either", () => {
const notFoundCause = PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
cause: new Error("ENOENT"),
});
const layer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.fail(notFoundCause)),
);

return Effect.gen(function* () {
const error = yield* readTailscaleStatus.pipe(
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provideService(TailscaleExecutableFileCheck, () => false),
Effect.provide(layer),
Effect.flip,
);

assert.instanceOf(error, TailscaleCommandSpawnError);
assert.strictEqual(error.cause, notFoundCause);
});
});

it.effect("does not fall back to the macOS binary on non-macOS platforms", () => {
const notFoundCause = PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
cause: new Error("ENOENT"),
});
const layer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.fail(notFoundCause)),
);

return Effect.gen(function* () {
const error = yield* readTailscaleStatus.pipe(
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provideService(TailscaleExecutableFileCheck, () => true),
Effect.provide(layer),
Effect.flip,
);

assert.instanceOf(error, TailscaleCommandSpawnError);
assert.strictEqual(error.cause, notFoundCause);
});
});

it.effect("keeps nonzero exit diagnostics structured", () => {
const layer = mockSpawnerLayer(() => ({
code: 7,
Expand Down
63 changes: 53 additions & 10 deletions packages/tailscale/src/tailscale.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import type * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
Expand All @@ -17,6 +22,32 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" =>
platform === "win32" ? "tailscale.exe" : "tailscale";

// The Tailscale macOS app doesn't put `tailscale` on PATH; Tailscale's own
// docs have users add a shell alias to this binary instead, which is
// invisible to a direct (non-shell) subprocess spawn.
export const MACOS_TAILSCALE_APP_EXECUTABLE =
"/Applications/Tailscale.app/Contents/MacOS/Tailscale";

export type TailscaleExecutableFileCheck = (filePath: string) => boolean;

function isExistingFile(filePath: string): boolean {
try {
return NodeFS.statSync(filePath).isFile();
} catch {
return false;
}
}

/** Injectable file-existence check so tests can run against a fake filesystem. */
export const TailscaleExecutableFileCheck = Context.Reference<TailscaleExecutableFileCheck>(
"@t3tools/tailscale/TailscaleExecutableFileCheck",
{ defaultValue: () => isExistingFile },
);

function isSpawnNotFoundError(error: PlatformError.PlatformError): boolean {
return error.reason._tag === "NotFound";
}

const TailscaleCommandContext = {
executable: Schema.Literals(["tailscale", "tailscale.exe"]),
subcommand: Schema.Literals(["status", "serve"]),
Expand Down Expand Up @@ -221,18 +252,24 @@ export const readTailscaleStatus = Effect.gen(function* () {
const args = ["status", "--json"];
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const hostPlatform = yield* HostProcessPlatform;
const isExecutableFile = yield* TailscaleExecutableFileCheck;
const executable = tailscaleCommandForPlatform(hostPlatform);
const commandContext = {
executable,
subcommand: "status" as const,
argumentCount: args.length,
};
return yield* Effect.gen(function* () {
const child = yield* spawner
.spawn(ChildProcess.make(executable, args))
.pipe(
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
);
const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
Effect.catchIf(
(error) => hostPlatform === "darwin" && isSpawnNotFoundError(error),
(error) =>
isExecutableFile(MACOS_TAILSCALE_APP_EXECUTABLE)
? spawner.spawn(ChildProcess.make(MACOS_TAILSCALE_APP_EXECUTABLE, args))
: Effect.fail(error),
),
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStdout(child.stdout),
Expand Down Expand Up @@ -291,6 +328,7 @@ const runTailscaleCommand = (
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const hostPlatform = yield* HostProcessPlatform;
const isExecutableFile = yield* TailscaleExecutableFileCheck;
const executable = tailscaleCommandForPlatform(hostPlatform);
const commandContext = {
executable,
Expand All @@ -299,11 +337,16 @@ const runTailscaleCommand = (
};
const timeout = Duration.fromInputUnsafe(timeoutInput);
return yield* Effect.gen(function* () {
const child = yield* spawner
.spawn(ChildProcess.make(executable, args))
.pipe(
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
);
const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe(
Effect.catchIf(
(error) => hostPlatform === "darwin" && isSpawnNotFoundError(error),
(error) =>
isExecutableFile(MACOS_TAILSCALE_APP_EXECUTABLE)
? spawner.spawn(ChildProcess.make(MACOS_TAILSCALE_APP_EXECUTABLE, args))
: Effect.fail(error),
),
Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })),
);
const [stderr, exitCode] = yield* Effect.all(
[collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],
{ concurrency: "unbounded" },
Expand Down
Loading