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
10 changes: 8 additions & 2 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/cli/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:os")>();
Expand All @@ -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<string>, env: Record<string, string> = {}) =>
Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe(
Effect.provide(
Expand Down Expand Up @@ -130,6 +134,88 @@ const withTempDirectory = <A, E, R>(
(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) =>
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -254,6 +256,7 @@ export const resolveServerConfig = (
options?: {
readonly startupPresentation?: ServerConfig.StartupPresentation;
readonly forceAutoBootstrapProjectFromCwd?: boolean;
readonly rejectRunningServer?: boolean;
},
) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 36 additions & 2 deletions apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
@@ -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* () {
Expand All @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Loading