Skip to content

Commit fe417df

Browse files
authored
fix(cli): reject accidental server launches (#15795)
1 parent efecd3c commit fe417df

6 files changed

Lines changed: 198 additions & 6 deletions

File tree

‎apps/server/src/binCli.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { pairCommand } from "./cli/pair.ts";
1515
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1616
import { sharedServerCommandFlags } from "./cli/config.ts";
1717
import { projectCommand } from "./cli/project.ts";
18-
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
18+
import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1919
import { updateCommand } from "./cli/update.ts";
2020
import { uninstallCommand } from "./cli/uninstall.ts";
2121
import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
@@ -56,8 +56,14 @@ const connectUnavailableCommand = Command.make("connect", {
5656
export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
5757
Command.make("t3", { ...sharedServerCommandFlags }).pipe(
5858
Command.withDescription("Run the T3 Code server."),
59-
Command.withHandler((flags) => runServerCommand(flags)),
59+
Command.withHandler(runDefaultServerCommand),
6060
Command.withSubcommands([
61+
Command.make("help").pipe(
62+
Command.withDescription("Show command help."),
63+
Command.withHandler(() =>
64+
Effect.fail(new CliError.ShowHelp({ commandPath: ["t3"], errors: [] })),
65+
),
66+
),
6167
acpMcpBridgeCommand,
6268
acpMcpCallCommand,
6369
startCommand,

‎apps/server/src/cli/app.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import * as NetService from "@t3tools/shared/Net";
1717
import * as ConfigProvider from "effect/ConfigProvider";
1818
import * as Effect from "effect/Effect";
1919
import * as Layer from "effect/Layer";
20+
import * as Schema from "effect/Schema";
2021
import { Command } from "effect/unstable/cli";
2122
import { afterEach, describe, expect, vi } from "vite-plus/test";
2223

2324
import { makeCli } from "../binCli.ts";
25+
import { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
2426

2527
vi.mock("node:os", async (importOriginal) => {
2628
const os = await importOriginal<typeof import("node:os")>();
@@ -29,6 +31,8 @@ vi.mock("node:os", async (importOriginal) => {
2931

3032
afterEach(() => vi.mocked(NodeOS.homedir).mockReset());
3133

34+
const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState));
35+
3236
const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
3337
Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe(
3438
Effect.provide(
@@ -130,6 +134,88 @@ const withTempDirectory = <A, E, R>(
130134
(root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })),
131135
);
132136

137+
describe("t3 server command safety", () => {
138+
it.effect("rejects unknown command words without creating a home or project", () =>
139+
withTempDirectory("t3-cli-unknown-", (root) =>
140+
Effect.gen(function* () {
141+
const baseDir = NodePath.join(root, "home");
142+
for (const word of [
143+
"account",
144+
"login",
145+
"clients",
146+
"conenct",
147+
"package.json",
148+
"C:new-project",
149+
]) {
150+
const error = yield* runCli([word, "--base-dir", baseDir]).pipe(
151+
Effect.provideService(HostProcessPlatform, "linux"),
152+
Effect.flip,
153+
);
154+
expect(String(error)).toContain(`Unknown command "${word}"`);
155+
expect(yield* pathExists(word)).toBe(word === "package.json");
156+
expect(yield* pathExists(baseDir)).toBe(false);
157+
}
158+
}),
159+
),
160+
);
161+
162+
it.effect("shows help without creating state", () =>
163+
withTempDirectory("t3-cli-help-", (root) =>
164+
Effect.gen(function* () {
165+
const baseDir = NodePath.join(root, "home");
166+
const help = yield* runCli(["help"], { T3CODE_HOME: baseDir }).pipe(Effect.flip);
167+
expect(help).toMatchObject({ _tag: "ShowHelp", commandPath: ["t3"], errors: [] });
168+
expect(yield* pathExists(baseDir)).toBe(false);
169+
}),
170+
),
171+
);
172+
173+
it.effect("refuses manual startup over a live server before creating directories", () =>
174+
withTempDirectory("t3-cli-running-", (root) =>
175+
Effect.gen(function* () {
176+
const baseDir = NodePath.join(root, "home");
177+
const stateDir = NodePath.join(baseDir, "userdata");
178+
const statePath = NodePath.join(stateDir, "server-runtime.json");
179+
const record = yield* encodeRuntimeState({
180+
version: 1,
181+
pid: process.pid,
182+
port: 3773,
183+
origin: "http://127.0.0.1:3773",
184+
startedAt: "2026-10-01T00:00:00.000Z",
185+
serviceManaged: true,
186+
});
187+
yield* Effect.promise(() => NodeFSP.mkdir(stateDir, { recursive: true }));
188+
yield* Effect.promise(() => NodeFSP.writeFile(statePath, record));
189+
const newDirectory = NodePath.join(root, "new-project");
190+
const platform = yield* HostProcessPlatform;
191+
for (const args of [
192+
[],
193+
["start"],
194+
["."],
195+
["node_modules"],
196+
["C:new-project"],
197+
[newDirectory],
198+
["start", newDirectory],
199+
]) {
200+
const error = yield* runCli(args, { T3CODE_HOME: baseDir }).pipe(
201+
Effect.provideService(
202+
HostProcessPlatform,
203+
args[0] === "C:new-project" ? "win32" : platform,
204+
),
205+
Effect.flip,
206+
);
207+
expect(String(error)).toContain("A T3 Code server is already running");
208+
expect(yield* Effect.promise(() => NodeFSP.readFile(statePath, "utf8"))).toBe(record);
209+
expect(yield* pathExists(newDirectory)).toBe(false);
210+
expect(yield* Effect.promise(() => NodeFSP.readdir(stateDir))).toEqual([
211+
"server-runtime.json",
212+
]);
213+
}
214+
}),
215+
),
216+
);
217+
});
218+
133219
describe("t3 app", () => {
134220
it.effect("rejects SSH before it tries to reach a desktop app", () =>
135221
withTempDirectory("t3-app-ssh-test-", (root) =>

‎apps/server/src/cli/config.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,53 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
8080
);
8181
});
8282

83+
it.effect("keeps stale records and supervised startup out of the manual launch preflight", () =>
84+
Effect.gen(function* () {
85+
const fs = yield* FileSystem.FileSystem;
86+
const path = yield* Path.Path;
87+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-" });
88+
for (const [name, pid, mode, rejectRunningServer] of [
89+
["stale", 2_147_483_647, "web", true],
90+
["desktop", process.pid, "desktop", true],
91+
["serve", process.pid, "web", false],
92+
] as const) {
93+
const baseDir = path.join(root, name);
94+
const stateDir = path.join(baseDir, "userdata");
95+
yield* fs.makeDirectory(stateDir, { recursive: true });
96+
yield* fs.writeFileString(
97+
path.join(stateDir, "server-runtime.json"),
98+
yield* encodeUnknownJson({
99+
version: 1,
100+
pid,
101+
port: 3773,
102+
origin: "http://127.0.0.1:3773",
103+
startedAt: "2026-10-01T00:00:00.000Z",
104+
}),
105+
);
106+
const cwd = path.join(root, `${name}-project`);
107+
const config = yield* resolveServerConfig(
108+
{
109+
...minimalWebFlags(baseDir),
110+
mode: Option.some(mode),
111+
port: Option.some(8788),
112+
cwd: Option.some(cwd),
113+
},
114+
Option.none(),
115+
{ rejectRunningServer },
116+
).pipe(
117+
Effect.provide(
118+
Layer.merge(
119+
NetService.layer,
120+
ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
121+
),
122+
),
123+
);
124+
expect(config.cwd).toBe(cwd);
125+
expect(yield* fs.exists(cwd)).toBe(true);
126+
}
127+
}),
128+
);
129+
83130
it.effect("enables a trimmed reusable auth token only for web dev mode", () =>
84131
Effect.gen(function* () {
85132
const baseDir = yield* FileSystem.FileSystem.pipe(

‎apps/server/src/cli/config.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import * as Schema from "effect/Schema";
1919
import * as SchemaIssue from "effect/SchemaIssue";
2020
import * as SchemaTransformation from "effect/SchemaTransformation";
2121
import { Argument, Flag } from "effect/unstable/cli";
22+
import * as CliError from "effect/unstable/cli/CliError";
2223

2324
import { readBootstrapEnvelope } from "../bootstrap.ts";
2425
import * as ServerConfig from "../config.ts";
2526
import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
27+
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
2628

2729
const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe(
2830
Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
@@ -254,6 +256,7 @@ export const resolveServerConfig = (
254256
options?: {
255257
readonly startupPresentation?: ServerConfig.StartupPresentation;
256258
readonly forceAutoBootstrapProjectFromCwd?: boolean;
259+
readonly rejectRunningServer?: boolean;
257260
},
258261
) =>
259262
Effect.gen(function* () {
@@ -324,10 +327,20 @@ export const resolveServerConfig = (
324327
);
325328
const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
326329
const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
327-
yield* fs.makeDirectory(cwd, { recursive: true });
328330
const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
329331
baseDirIsExplicit: Option.isSome(explicitBaseDir),
330332
});
333+
// An interactive CLI must not start over a discovered server. Lifetime locking
334+
// and supervisor handoff are separate; this preflight cannot arbitrate two starts.
335+
if (options?.rejectRunningServer && mode === "web") {
336+
const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath);
337+
if (Option.isSome(runtime) && runtime.value.pid > 0 && isProcessAlive(runtime.value.pid)) {
338+
return yield* new CliError.UserError({
339+
cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`,
340+
});
341+
}
342+
}
343+
yield* fs.makeDirectory(cwd, { recursive: true });
331344
yield* ServerConfig.ensureServerDirectories(derivedPaths);
332345
const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
333346
derivedPaths.settingsPath,

‎apps/server/src/cli/server.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12
import * as Effect from "effect/Effect";
3+
import * as FileSystem from "effect/FileSystem";
4+
import * as Option from "effect/Option";
5+
import * as Schema from "effect/Schema";
26
import { Command, GlobalFlag } from "effect/unstable/cli";
7+
import * as CliError from "effect/unstable/cli/CliError";
38

49
import * as ServerConfig from "../config.ts";
510
import { runServer } from "../server.ts";
611
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";
712

8-
export const runServerCommand = (
13+
const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String));
14+
15+
const runServerCommand = (
916
flags: CliServerFlags,
1017
options?: {
1118
readonly startupPresentation?: ServerConfig.StartupPresentation;
1219
readonly forceAutoBootstrapProjectFromCwd?: boolean;
20+
readonly rejectRunningServer?: boolean;
1321
},
1422
) =>
1523
Effect.gen(function* () {
@@ -18,9 +26,35 @@ export const runServerCommand = (
1826
return yield* runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config));
1927
});
2028

29+
/** Bare words can name existing directories, but must not create typo projects. */
30+
export const runDefaultServerCommand = (flags: CliServerFlags) =>
31+
Effect.gen(function* () {
32+
if (Option.isSome(flags.cwd)) {
33+
const cwd = flags.cwd.value.trim();
34+
const fs = yield* FileSystem.FileSystem;
35+
const platform = yield* HostProcessPlatform;
36+
const explicitPath =
37+
cwd === "." ||
38+
cwd === ".." ||
39+
cwd === "~" ||
40+
/[/\\]/.test(cwd) ||
41+
(platform === "win32" && /^[a-z]:/i.test(cwd));
42+
if (
43+
!explicitPath &&
44+
(!(yield* fs.exists(cwd)) || (yield* fs.stat(cwd)).type !== "Directory")
45+
) {
46+
return yield* new CliError.UserError({
47+
cause: cwd,
48+
userMessage: `Unknown command ${yield* encodeCommand(cwd)}. Use "t3 --help" for commands or an explicit path such as "t3 ./my-project" for a new directory.`,
49+
});
50+
}
51+
}
52+
return yield* runServerCommand(flags, { rejectRunningServer: true });
53+
});
54+
2155
export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
2256
Command.withDescription("Run the T3 Code server."),
23-
Command.withHandler((flags) => runServerCommand(flags)),
57+
Command.withHandler((flags) => runServerCommand(flags, { rejectRunningServer: true })),
2458
);
2559

2660
export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(

‎docs/user/install.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or
3333
| Move to the newest release | `t3 update` |
3434
| Remove it again | `t3 uninstall` |
3535

36-
Run `t3 --help` for the full reference.
36+
Run `t3 help` or `t3 --help` for the full reference. To start in a new working
37+
directory, use an explicit path such as `t3 ./my-project`. A bare directory name
38+
is accepted only if it already exists.
39+
40+
If `t3` or `t3 start` reports an already running server, connect to that server
41+
instead. Stop it before starting a replacement, or use a different `--base-dir`
42+
for an independent server.
3743

3844
To try T3 Code once without installing it, run `npx t3@latest` instead (needs
3945
Node.js for `npx`).

0 commit comments

Comments
 (0)