authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 21:36:44-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 21:36:44-03:00
logfe417dff4cdc149d6873bb4fbaaa388731628d0b
treee33b63ac38c6680636b3543e71a782f36f1f1630
parentefecd3cf8bcec3d1891b5f5a27dc2f6d797c6448
signature Signed by PGP key B5690EEEBB952194

fix(cli): reject accidental server launches (#15795)


6 files changed, 198 insertions(+), 6 deletions(-)

apps/server/src/binCli.ts+8-2
......@@ -15,7 +15,7 @@ import { pairCommand } from "./cli/pair.ts";
1515import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1616import { sharedServerCommandFlags } from "./cli/config.ts";
1717import { projectCommand } from "./cli/project.ts";
18import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
18import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1919import { updateCommand } from "./cli/update.ts";
2020import { uninstallCommand } from "./cli/uninstall.ts";
2121import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
......@@ -56,8 +56,14 @@ const connectUnavailableCommand = Command.make("connect", {
5656export 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+86
......@@ -17,10 +17,12 @@ import * as NetService from "@t3tools/shared/Net";
1717import * as ConfigProvider from "effect/ConfigProvider";
1818import * as Effect from "effect/Effect";
1919import * as Layer from "effect/Layer";
20import * as Schema from "effect/Schema";
2021import { Command } from "effect/unstable/cli";
2122import { afterEach, describe, expect, vi } from "vite-plus/test";
2223
2324import { makeCli } from "../binCli.ts";
25import { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
2426
2527vi.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
3032afterEach(() => vi.mocked(NodeOS.homedir).mockReset());
3133
34const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState));
35
3236const 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
137describe("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
133219describe("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+47
......@@ -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+14-1
......@@ -19,10 +19,12 @@ import * as Schema from "effect/Schema";
1919import * as SchemaIssue from "effect/SchemaIssue";
2020import * as SchemaTransformation from "effect/SchemaTransformation";
2121import { Argument, Flag } from "effect/unstable/cli";
22import * as CliError from "effect/unstable/cli/CliError";
2223
2324import { readBootstrapEnvelope } from "../bootstrap.ts";
2425import * as ServerConfig from "../config.ts";
2526import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
27import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
2628
2729const 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+36-2
......@@ -1,15 +1,23 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
26import { Command, GlobalFlag } from "effect/unstable/cli";
7import * as CliError from "effect/unstable/cli/CliError";
38
49import * as ServerConfig from "../config.ts";
510import { runServer } from "../server.ts";
611import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";
712
8export const runServerCommand = (
13const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String));
14
15const 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. */
30export 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
2155export 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
2660export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
docs/user/install.md+7-1
......@@ -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
36Run `t3 --help` for the full reference.
36Run `t3 help` or `t3 --help` for the full reference. To start in a new working
37directory, use an explicit path such as `t3 ./my-project`. A bare directory name
38is accepted only if it already exists.
39
40If `t3` or `t3 start` reports an already running server, connect to that server
41instead. Stop it before starting a replacement, or use a different `--base-dir`
42for an independent server.
3743
3844To try T3 Code once without installing it, run `npx t3@latest` instead (needs
3945Node.js for `npx`).