Skip to content
Merged
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
70 changes: 70 additions & 0 deletions scripts/dev-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,53 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
}),
);

it.effect("pins the repo toolchain ahead of an inherited PATH", () =>
Effect.gen(function* () {
// A stray /usr/bin/vp (atfs/ShapeTools) otherwise wins the bare-name
// lookup, starts no dev server, and still exits 0.
const env = yield* createDevRunnerEnv({
mode: "dev",
baseEnv: { PATH: "/usr/local/bin:/usr/bin:/bin" },
serverOffset: 0,
webOffset: 0,
t3Home: undefined,
browser: undefined,
autoBootstrapProjectFromCwd: undefined,
logWebSocketEvents: undefined,
host: undefined,
port: undefined,
devUrl: undefined,
tailscaleServe: undefined,
});

const entries = (env.PATH ?? "").split(NodePath.delimiter);
assert.ok(entries[0]?.endsWith(NodePath.join("node_modules", ".bin")));
assert.deepEqual(entries.slice(1), ["/usr/local/bin", "/usr/bin", "/bin"]);
}),
);

it.effect("keeps PATH usable when none was inherited", () =>
Effect.gen(function* () {
const env = yield* createDevRunnerEnv({
mode: "dev",
baseEnv: {},
serverOffset: 0,
webOffset: 0,
t3Home: undefined,
browser: undefined,
autoBootstrapProjectFromCwd: undefined,
logWebSocketEvents: undefined,
host: undefined,
port: undefined,
devUrl: undefined,
tailscaleServe: undefined,
});

assert.ok(env.PATH?.endsWith(NodePath.join("node_modules", ".bin")));
assert.ok(!env.PATH?.includes(NodePath.delimiter));
}),
);

it.effect("allows browser auto-open to be explicitly enabled", () =>
Effect.gen(function* () {
const env = yield* createDevRunnerEnv({
Expand Down Expand Up @@ -274,6 +321,29 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
}),
);

it.effect("prevents an ambient Command Center home from overriding the selected home", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const env = yield* createDevRunnerEnv({
mode: "dev",
baseEnv: { COMMAND_CENTER_HOME: "/opt/shared-command-center" },
serverOffset: 0,
webOffset: 0,
t3Home: "/tmp/isolated-t3",
browser: undefined,
autoBootstrapProjectFromCwd: undefined,
logWebSocketEvents: undefined,
host: undefined,
port: undefined,
devUrl: undefined,
tailscaleServe: undefined,
});

assert.equal(env.T3CODE_HOME, path.resolve("/tmp/isolated-t3"));
assert.equal(env.COMMAND_CENTER_HOME, undefined);
}),
);

it.effect("strips inherited service-launcher context", () =>
Effect.gen(function* () {
const env = yield* createDevRunnerEnv({
Expand Down
36 changes: 36 additions & 0 deletions scripts/dev-runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node

import * as NodeOS from "node:os";
// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's Path service has no `delimiter`.
import * as NodePath from "node:path";

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down Expand Up @@ -298,6 +300,34 @@ function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, neve
});
}

/**
* Repo-local binaries, ahead of whatever PATH we inherited.
*
* We spawn `vp` by bare name, so resolution is at the mercy of the caller's
* PATH. That is not hypothetical: `atfs` ships an unrelated `/usr/bin/vp`
* (ShapeTools), and when it wins the lookup it does not fail loudly — it prints
* `basename: unrecognized option '--filter=...'`, starts no dev server, and
* exits 0. The launcher then reports success while the stack is already gone.
* Pinning node_modules/.bin first makes `vp` mean this repo's toolchain
* regardless of the environment we were launched from.
*/
function devToolchainPath(
baseEnv: NodeJS.ProcessEnv,
): Effect.Effect<string | undefined, never, Path.Path> {
return Effect.gen(function* () {
const path = yield* Path.Path;
// import.meta.dirname is scripts/; its parent is the repo root.
const binDir = path.join(path.dirname(import.meta.dirname), "node_modules", ".bin");
const inherited = baseEnv.PATH;
if (inherited === undefined || inherited.length === 0) {
return binDir;
}
// Effect's Path service has no `delimiter`; the separator is platform state.
const alreadyFirst = inherited.split(NodePath.delimiter).at(0) === binDir;
return alreadyFirst ? inherited : `${binDir}${NodePath.delimiter}${inherited}`;
});
}

interface CreateDevRunnerEnvInput {
readonly mode: DevMode;
readonly baseEnv: NodeJS.ProcessEnv;
Expand Down Expand Up @@ -339,6 +369,7 @@ export function createDevRunnerEnv({

const output: NodeJS.ProcessEnv = {
...baseEnv,
PATH: yield* devToolchainPath(baseEnv),
PORT: String(webPort),
VITE_DEV_SERVER_URL:
devUrl?.toString() ??
Expand All @@ -362,6 +393,11 @@ export function createDevRunnerEnv({

if (configuredBaseDir !== undefined) {
output.T3CODE_HOME = resolvedBaseDir;
// The server gives COMMAND_CENTER_HOME precedence over T3CODE_HOME.
// Once the runner has selected an explicit or worktree-local home, an
// inherited launcher home must not redirect the child back to shared
// state.
delete output.COMMAND_CENTER_HOME;
} else {
delete output.T3CODE_HOME;
}
Expand Down
Loading