diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index a4148e707bf6..c39fa3e3b620 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -15,7 +15,7 @@ import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; -import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; @@ -56,8 +56,14 @@ const connectUnavailableCommand = Command.make("connect", { export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.make("t3", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), + Command.withHandler(runDefaultServerCommand), Command.withSubcommands([ + Command.make("help").pipe( + Command.withDescription("Show command help."), + Command.withHandler(() => + Effect.fail(new CliError.ShowHelp({ commandPath: ["t3"], errors: [] })), + ), + ), acpMcpBridgeCommand, acpMcpCallCommand, startCommand, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index e478f66f0731..a91ea416ffca 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -17,10 +17,12 @@ import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import { makeCli } from "../binCli.ts"; +import { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; vi.mock("node:os", async (importOriginal) => { const os = await importOriginal(); @@ -29,6 +31,8 @@ vi.mock("node:os", async (importOriginal) => { afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); +const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState)); + const runCli = (args: ReadonlyArray, env: Record = {}) => Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( Effect.provide( @@ -130,6 +134,88 @@ const withTempDirectory = ( (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), ); +describe("t3 server command safety", () => { + it.effect("rejects unknown command words without creating a home or project", () => + withTempDirectory("t3-cli-unknown-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + for (const word of [ + "account", + "login", + "clients", + "conenct", + "package.json", + "C:new-project", + ]) { + const error = yield* runCli([word, "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + expect(String(error)).toContain(`Unknown command "${word}"`); + expect(yield* pathExists(word)).toBe(word === "package.json"); + expect(yield* pathExists(baseDir)).toBe(false); + } + }), + ), + ); + + it.effect("shows help without creating state", () => + withTempDirectory("t3-cli-help-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + const help = yield* runCli(["help"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); + expect(help).toMatchObject({ _tag: "ShowHelp", commandPath: ["t3"], errors: [] }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("refuses manual startup over a live server before creating directories", () => + withTempDirectory("t3-cli-running-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "home"); + const stateDir = NodePath.join(baseDir, "userdata"); + const statePath = NodePath.join(stateDir, "server-runtime.json"); + const record = yield* encodeRuntimeState({ + version: 1, + pid: process.pid, + port: 3773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-10-01T00:00:00.000Z", + serviceManaged: true, + }); + yield* Effect.promise(() => NodeFSP.mkdir(stateDir, { recursive: true })); + yield* Effect.promise(() => NodeFSP.writeFile(statePath, record)); + const newDirectory = NodePath.join(root, "new-project"); + const platform = yield* HostProcessPlatform; + for (const args of [ + [], + ["start"], + ["."], + ["node_modules"], + ["C:new-project"], + [newDirectory], + ["start", newDirectory], + ]) { + const error = yield* runCli(args, { T3CODE_HOME: baseDir }).pipe( + Effect.provideService( + HostProcessPlatform, + args[0] === "C:new-project" ? "win32" : platform, + ), + Effect.flip, + ); + expect(String(error)).toContain("A T3 Code server is already running"); + expect(yield* Effect.promise(() => NodeFSP.readFile(statePath, "utf8"))).toBe(record); + expect(yield* pathExists(newDirectory)).toBe(false); + expect(yield* Effect.promise(() => NodeFSP.readdir(stateDir))).toEqual([ + "server-runtime.json", + ]); + } + }), + ), + ); +}); + describe("t3 app", () => { it.effect("rejects SSH before it tries to reach a desktop app", () => withTempDirectory("t3-app-ssh-test-", (root) => diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index a133c6d4b23c..efb3ec5d73cb 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -80,6 +80,53 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); }); + it.effect("keeps stale records and supervised startup out of the manual launch preflight", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-" }); + for (const [name, pid, mode, rejectRunningServer] of [ + ["stale", 2_147_483_647, "web", true], + ["desktop", process.pid, "desktop", true], + ["serve", process.pid, "web", false], + ] as const) { + const baseDir = path.join(root, name); + const stateDir = path.join(baseDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + yield* encodeUnknownJson({ + version: 1, + pid, + port: 3773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-10-01T00:00:00.000Z", + }), + ); + const cwd = path.join(root, `${name}-project`); + const config = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some(mode), + port: Option.some(8788), + cwd: Option.some(cwd), + }, + Option.none(), + { rejectRunningServer }, + ).pipe( + Effect.provide( + Layer.merge( + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), + ), + ); + expect(config.cwd).toBe(cwd); + expect(yield* fs.exists(cwd)).toBe(true); + } + }), + ); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => Effect.gen(function* () { const baseDir = yield* FileSystem.FileSystem.pipe( diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 62461e286748..e78b8b54d73b 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -19,10 +19,12 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -254,6 +256,7 @@ export const resolveServerConfig = ( options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly rejectRunningServer?: boolean; }, ) => Effect.gen(function* () { @@ -324,10 +327,20 @@ export const resolveServerConfig = ( ); const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: Option.isSome(explicitBaseDir), }); + // An interactive CLI must not start over a discovered server. Lifetime locking + // and supervisor handoff are separate; this preflight cannot arbitrate two starts. + if (options?.rejectRunningServer && mode === "web") { + const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath); + if (Option.isSome(runtime) && runtime.value.pid > 0 && isProcessAlive(runtime.value.pid)) { + return yield* new CliError.UserError({ + 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.`, + }); + } + } + yield* fs.makeDirectory(cwd, { recursive: true }); yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 298109abdb5f..44662c36b540 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -1,15 +1,23 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { Command, GlobalFlag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; import * as ServerConfig from "../config.ts"; import { runServer } from "../server.ts"; import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; -export const runServerCommand = ( +const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); + +const runServerCommand = ( flags: CliServerFlags, options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly rejectRunningServer?: boolean; }, ) => Effect.gen(function* () { @@ -18,9 +26,35 @@ export const runServerCommand = ( return yield* runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config)); }); +/** Bare words can name existing directories, but must not create typo projects. */ +export const runDefaultServerCommand = (flags: CliServerFlags) => + Effect.gen(function* () { + if (Option.isSome(flags.cwd)) { + const cwd = flags.cwd.value.trim(); + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const explicitPath = + cwd === "." || + cwd === ".." || + cwd === "~" || + /[/\\]/.test(cwd) || + (platform === "win32" && /^[a-z]:/i.test(cwd)); + if ( + !explicitPath && + (!(yield* fs.exists(cwd)) || (yield* fs.stat(cwd)).type !== "Directory") + ) { + return yield* new CliError.UserError({ + cause: cwd, + userMessage: `Unknown command ${yield* encodeCommand(cwd)}. Use "t3 --help" for commands or an explicit path such as "t3 ./my-project" for a new directory.`, + }); + } + } + return yield* runServerCommand(flags, { rejectRunningServer: true }); + }); + export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), + Command.withHandler((flags) => runServerCommand(flags, { rejectRunningServer: true })), ); export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( diff --git a/docs/user/install.md b/docs/user/install.md index 372d8e6e5607..980837199e9b 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -33,7 +33,13 @@ line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or | Move to the newest release | `t3 update` | | Remove it again | `t3 uninstall` | -Run `t3 --help` for the full reference. +Run `t3 help` or `t3 --help` for the full reference. To start in a new working +directory, use an explicit path such as `t3 ./my-project`. A bare directory name +is accepted only if it already exists. + +If `t3` or `t3 start` reports an already running server, connect to that server +instead. Stop it before starting a replacement, or use a different `--base-dir` +for an independent server. To try T3 Code once without installing it, run `npx t3@latest` instead (needs Node.js for `npx`).