authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-05 23:02:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-05 23:02:05-07:00
log499f1463d5fe80350b34a6d511bdd873dd3d564f
tree562265f7a1c397b37b025e5906970889f57fff72
parenta2ff50dbba0725a53dfdb76184a54a621645ddd5
signature Signed by PGP key B5690EEEBB952194

Split server CLI into focused submodules (#2545)

Co-authored-by: Julius Marminge <julius@macmini.local>

10 files changed, 2140 insertions(+), 2106 deletions(-)

apps/server/src/bin.test.ts created+357
...@@ -0,0 +1,357 @@
1import * as NodeHttp from "node:http";
2import { mkdtempSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5
6import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { NetService } from "@t3tools/shared/Net";
9import { assert, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as HttpRouter from "effect/unstable/http/HttpRouter";
13import * as HttpServer from "effect/unstable/http/HttpServer";
14import * as CliError from "effect/unstable/cli/CliError";
15import * as TestConsole from "effect/testing/TestConsole";
16import { Command } from "effect/unstable/cli";
17
18import { cli } from "./bin.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
21import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
22import {
23 orchestrationDispatchRouteLayer,
24 orchestrationSnapshotRouteLayer,
25} from "./orchestration/http.ts";
26import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
27import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
28import {
29 makePersistedServerRuntimeState,
30 persistServerRuntimeState,
31} from "./serverRuntimeState.ts";
32import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
33import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
34import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
35
36const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
37
38const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
39const runCliWithRuntime = (args: ReadonlyArray<string>) =>
40 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
41
42const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
43 Effect.gen(function* () {
44 const result = yield* effect;
45 const output =
46 (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ??
47 "";
48 return { result, output };
49 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
50
51const makeCliTestServerConfig = (baseDir: string) =>
52 Effect.gen(function* () {
53 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
54 return {
55 logLevel: "Info",
56 traceMinLevel: "Info",
57 traceTimingEnabled: true,
58 traceBatchWindowMs: 200,
59 traceMaxBytes: 10 * 1024 * 1024,
60 traceMaxFiles: 10,
61 otlpTracesUrl: undefined,
62 otlpMetricsUrl: undefined,
63 otlpExportIntervalMs: 10_000,
64 otlpServiceName: "t3-server",
65 mode: "web",
66 port: 0,
67 host: "127.0.0.1",
68 cwd: process.cwd(),
69 baseDir,
70 ...derivedPaths,
71 staticDir: undefined,
72 devUrl: undefined,
73 noBrowser: true,
74 startupPresentation: "browser",
75 desktopBootstrapToken: undefined,
76 autoBootstrapProjectFromCwd: false,
77 logWebSocketEvents: false,
78 tailscaleServeEnabled: false,
79 tailscaleServePort: 443,
80 } satisfies ServerConfigShape;
81 });
82
83const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
84 Layer.mergeAll(
85 OrchestrationLayerLive.pipe(
86 Layer.provideMerge(RepositoryIdentityResolverLive),
87 Layer.provideMerge(SqlitePersistenceLayerLive),
88 ),
89 WorkspacePathsLive,
90 ).pipe(
91 Layer.provideMerge(NodeServices.layer),
92 Layer.provide(Layer.succeed(ServerConfig, config)),
93 );
94
95const readPersistedSnapshot = (baseDir: string) =>
96 Effect.gen(function* () {
97 const config = yield* makeCliTestServerConfig(baseDir);
98 return yield* Effect.gen(function* () {
99 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
100 return yield* projectionSnapshotQuery.getSnapshot();
101 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
102 });
103
104const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
105 Effect.gen(function* () {
106 const config = yield* makeCliTestServerConfig(baseDir);
107 const routesLayer = Layer.mergeAll(
108 orchestrationSnapshotRouteLayer,
109 orchestrationDispatchRouteLayer,
110 );
111 const appLayer = HttpRouter.serve(routesLayer, {
112 disableListenLog: true,
113 disableLogger: true,
114 }).pipe(
115 Layer.provideMerge(
116 ServerAuthLive.pipe(
117 Layer.provideMerge(SqlitePersistenceLayerLive),
118 Layer.provide(ServerSecretStoreLive),
119 ),
120 ),
121 Layer.provideMerge(makeProjectPersistenceLayer(config)),
122 Layer.provideMerge(
123 NodeHttpServer.layer(NodeHttp.createServer, {
124 host: "127.0.0.1",
125 port: 0,
126 }),
127 ),
128 Layer.provideMerge(NodeServices.layer),
129 Layer.provide(Layer.succeed(ServerConfig, config)),
130 );
131
132 return yield* Effect.scoped(
133 Effect.gen(function* () {
134 const server = yield* HttpServer.HttpServer;
135 const address = server.address;
136 if (typeof address === "string" || !("port" in address)) {
137 assert.fail(`Expected TCP address, got ${address}`);
138 }
139 yield* persistServerRuntimeState({
140 path: config.serverRuntimeStatePath,
141 state: makePersistedServerRuntimeState({
142 config,
143 port: address.port,
144 }),
145 });
146 return yield* run();
147 }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
148 );
149 });
150
151it.layer(NodeServices.layer)("bin cli parsing", (it) => {
152 it.effect("accepts the built-in lowercase log-level flag values", () =>
153 runCliWithRuntime(["--log-level", "debug", "--version"]),
154 );
155
156 it.effect("accepts canonical --no-<flag> boolean negation", () =>
157 runCliWithRuntime(["--no-log-websocket-events", "--version"]),
158 );
159
160 it.effect("rejects invalid log-level casing before launching the server", () =>
161 Effect.gen(function* () {
162 const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip);
163
164 if (!CliError.isCliError(error)) {
165 assert.fail(`Expected CliError, got ${String(error)}`);
166 }
167 if (error._tag !== "InvalidValue") {
168 assert.fail(`Expected InvalidValue, got ${error._tag}`);
169 }
170 assert.equal(error.option, "log-level");
171 assert.equal(error.value, "Debug");
172 }),
173 );
174
175 it.effect("executes auth pairing subcommands and redacts secrets from list output", () =>
176 Effect.gen(function* () {
177 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-"));
178
179 const createdOutput = yield* captureStdout(
180 runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]),
181 );
182 const created = JSON.parse(createdOutput.output) as {
183 readonly id: string;
184 readonly credential: string;
185 };
186 const listedOutput = yield* captureStdout(
187 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
188 );
189 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
190 readonly id: string;
191 readonly credential?: string;
192 }>;
193
194 assert.equal(typeof created.id, "string");
195 assert.equal(typeof created.credential, "string");
196 assert.equal(created.credential.length > 0, true);
197 assert.equal(listed.length, 1);
198 assert.equal(listed[0]?.id, created.id);
199 assert.equal("credential" in (listed[0] ?? {}), false);
200 }),
201 );
202
203 it.effect("executes auth session subcommands and redacts secrets from list output", () =>
204 Effect.gen(function* () {
205 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-"));
206
207 const issuedOutput = yield* captureStdout(
208 runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]),
209 );
210 const issued = JSON.parse(issuedOutput.output) as {
211 readonly sessionId: string;
212 readonly token: string;
213 readonly role: string;
214 };
215 const listedOutput = yield* captureStdout(
216 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
217 );
218 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
219 readonly sessionId: string;
220 readonly token?: string;
221 readonly role: string;
222 }>;
223
224 assert.equal(typeof issued.sessionId, "string");
225 assert.equal(typeof issued.token, "string");
226 assert.equal(issued.role, "owner");
227 assert.equal(listed.length, 1);
228 assert.equal(listed[0]?.sessionId, issued.sessionId);
229 assert.equal(listed[0]?.role, "owner");
230 assert.equal("token" in (listed[0] ?? {}), false);
231 }),
232 );
233
234 it.effect("rejects invalid ttl values before running auth commands", () =>
235 Effect.gen(function* () {
236 const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe(
237 Effect.flip,
238 );
239
240 if (!CliError.isCliError(error)) {
241 assert.fail(`Expected CliError, got ${String(error)}`);
242 }
243 if (error._tag !== "ShowHelp") {
244 assert.fail(`Expected ShowHelp, got ${error._tag}`);
245 }
246 assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]);
247 const ttlError = error.errors[0] as CliError.CliError | undefined;
248 if (!ttlError || ttlError._tag !== "InvalidValue") {
249 assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`);
250 }
251 assert.equal(ttlError.option, "ttl");
252 assert.equal(ttlError.value, "soon");
253 assert.isTrue(ttlError.message.includes("Invalid duration"));
254 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
255 }),
256 );
257
258 it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
259 Effect.gen(function* () {
260 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
261 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
262
263 yield* runCliWithRuntime([
264 "project",
265 "add",
266 workspaceRoot,
267 "--title",
268 "Alpha",
269 "--base-dir",
270 baseDir,
271 ]);
272 const afterAdd = yield* readPersistedSnapshot(baseDir);
273 const addedProject = afterAdd.projects.find(
274 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
275 );
276 assert.isTrue(addedProject !== undefined);
277 assert.equal(addedProject?.title, "Alpha");
278
279 yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
280 const afterRename = yield* readPersistedSnapshot(baseDir);
281 const renamedProject = afterRename.projects.find(
282 (project) => project.id === addedProject?.id,
283 );
284 assert.equal(renamedProject?.title, "Beta");
285 assert.equal(renamedProject?.deletedAt, null);
286
287 yield* runCliWithRuntime([
288 "project",
289 "remove",
290 addedProject?.id ?? "",
291 "--base-dir",
292 baseDir,
293 ]);
294 const afterRemove = yield* readPersistedSnapshot(baseDir);
295 const removedProject = afterRemove.projects.find(
296 (project) => project.id === addedProject?.id,
297 );
298 assert.isTrue((removedProject?.deletedAt ?? null) !== null);
299 }),
300 );
301
302 it.effect("routes project commands through a running server when runtime state is present", () =>
303 Effect.gen(function* () {
304 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
305 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
306
307 yield* withLiveProjectCliServer(baseDir, () =>
308 Effect.gen(function* () {
309 yield* runCliWithRuntime([
310 "project",
311 "add",
312 workspaceRoot,
313 "--title",
314 "Live Project",
315 "--base-dir",
316 baseDir,
317 ]);
318 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
319 const readModel = yield* projectionSnapshotQuery.getSnapshot();
320 const addedProject = readModel.projects.find(
321 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
322 );
323 assert.isTrue(addedProject !== undefined);
324 assert.equal(addedProject?.title, "Live Project");
325 }),
326 );
327 }),
328 );
329
330 it.effect("rejects dev-url on project commands", () =>
331 Effect.gen(function* () {
332 const workspaceRoot = mkdtempSync(
333 join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
334 );
335 const error = yield* runCliWithRuntime([
336 "project",
337 "add",
338 workspaceRoot,
339 "--dev-url",
340 "http://127.0.0.1:5173",
341 ]).pipe(Effect.flip);
342
343 if (!CliError.isCliError(error)) {
344 assert.fail(`Expected CliError, got ${String(error)}`);
345 }
346 if (error._tag !== "ShowHelp") {
347 assert.fail(`Expected ShowHelp, got ${error._tag}`);
348 }
349 assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
350 const optionError = error.errors[0] as CliError.CliError | undefined;
351 if (!optionError || optionError._tag !== "UnrecognizedOption") {
352 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
353 }
354 assert.equal(optionError.option, "--dev-url");
355 }),
356 );
357});
apps/server/src/bin.ts+16-5
...@@ -5,13 +5,24 @@ import * as Layer from "effect/Layer";...@@ -5,13 +5,24 @@ import * as Layer from "effect/Layer";
5import { Command } from "effect/unstable/cli";5import { Command } from "effect/unstable/cli";
66
7import { NetService } from "@t3tools/shared/Net";7import { NetService } from "@t3tools/shared/Net";
8import { cli } from "./cli.ts";
9import packageJson from "../package.json" with { type: "json" };8import packageJson from "../package.json" with { type: "json" };
9import { authCommand } from "./cli/auth.ts";
10import { sharedServerCommandFlags } from "./cli/config.ts";
11import { projectCommand } from "./cli/project.ts";
12import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1013
11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);14const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1215
13Command.run(cli, { version: packageJson.version }).pipe(16export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
14 Effect.scoped,17 Command.withDescription("Run the T3 Code server."),
15 Effect.provide(CliRuntimeLayer),18 Command.withHandler((flags) => runServerCommand(flags)),
16 NodeRuntime.runMain,19 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
17);20);
21
22if (import.meta.main) {
23 Command.run(cli, { version: packageJson.version }).pipe(
24 Effect.scoped,
25 Effect.provide(CliRuntimeLayer),
26 NodeRuntime.runMain,
27 );
28}
apps/server/src/cli-config.test.ts deleted-564
...@@ -1,564 +0,0 @@
1import os from "node:os";
2
3import { assert, expect, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
5
6import { NetService } from "@t3tools/shared/Net";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { deriveServerPaths } from "./config.ts";
9import { resolveServerConfig } from "./cli.ts";
10
11it.layer(NodeServices.layer)("cli config resolution", (it) => {
12 const defaultObservabilityConfig = {
13 traceMinLevel: "Info",
14 traceTimingEnabled: true,
15 traceBatchWindowMs: 200,
16 traceMaxBytes: 10 * 1024 * 1024,
17 traceMaxFiles: 10,
18 otlpTracesUrl: undefined,
19 otlpMetricsUrl: undefined,
20 otlpExportIntervalMs: 10_000,
21 otlpServiceName: "t3-server",
22 } as const;
23
24 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
25 const fs = yield* FileSystem.FileSystem;
26 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
27 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
28 const { fd } = yield* fs.open(filePath, { flag: "r" });
29 return fd;
30 });
31
32 it.effect("falls back to effect/config values when flags are omitted", () =>
33 Effect.gen(function* () {
34 const { join } = yield* Path.Path;
35 const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
36 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
37 const resolved = yield* resolveServerConfig(
38 {
39 mode: Option.none(),
40 port: Option.none(),
41 host: Option.none(),
42 baseDir: Option.none(),
43 cwd: Option.none(),
44 devUrl: Option.none(),
45 noBrowser: Option.none(),
46 bootstrapFd: Option.none(),
47 autoBootstrapProjectFromCwd: Option.none(),
48 logWebSocketEvents: Option.none(),
49 tailscaleServeEnabled: Option.none(),
50 tailscaleServePort: Option.none(),
51 },
52 Option.none(),
53 ).pipe(
54 Effect.provide(
55 Layer.mergeAll(
56 ConfigProvider.layer(
57 ConfigProvider.fromEnv({
58 env: {
59 T3CODE_LOG_LEVEL: "Warn",
60 T3CODE_MODE: "desktop",
61 T3CODE_PORT: "4001",
62 T3CODE_HOST: "0.0.0.0",
63 T3CODE_HOME: baseDir,
64 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
65 T3CODE_NO_BROWSER: "true",
66 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
67 T3CODE_LOG_WS_EVENTS: "true",
68 },
69 }),
70 ),
71 NetService.layer,
72 ),
73 ),
74 );
75
76 expect(resolved).toEqual({
77 logLevel: "Warn",
78 ...defaultObservabilityConfig,
79 mode: "desktop",
80 port: 4001,
81 cwd: process.cwd(),
82 baseDir,
83 ...derivedPaths,
84 host: "0.0.0.0",
85 staticDir: undefined,
86 devUrl: new URL("http://127.0.0.1:5173"),
87 noBrowser: true,
88 startupPresentation: "browser",
89 desktopBootstrapToken: undefined,
90 autoBootstrapProjectFromCwd: false,
91 logWebSocketEvents: true,
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
94 });
95 }),
96 );
97
98 it.effect("uses CLI flags when provided", () =>
99 Effect.gen(function* () {
100 const { join } = yield* Path.Path;
101 const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
102 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
103 const resolved = yield* resolveServerConfig(
104 {
105 mode: Option.some("web"),
106 port: Option.some(8788),
107 host: Option.some("127.0.0.1"),
108 baseDir: Option.some(baseDir),
109 cwd: Option.none(),
110 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
111 noBrowser: Option.some(true),
112 bootstrapFd: Option.none(),
113 autoBootstrapProjectFromCwd: Option.some(true),
114 logWebSocketEvents: Option.some(true),
115 tailscaleServeEnabled: Option.some(true),
116 tailscaleServePort: Option.some(8443),
117 },
118 Option.some("Debug"),
119 ).pipe(
120 Effect.provide(
121 Layer.mergeAll(
122 ConfigProvider.layer(
123 ConfigProvider.fromEnv({
124 env: {
125 T3CODE_LOG_LEVEL: "Warn",
126 T3CODE_MODE: "desktop",
127 T3CODE_PORT: "4001",
128 T3CODE_HOST: "0.0.0.0",
129 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
130 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
131 T3CODE_NO_BROWSER: "false",
132 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
133 T3CODE_LOG_WS_EVENTS: "false",
134 },
135 }),
136 ),
137 NetService.layer,
138 ),
139 ),
140 );
141
142 expect(resolved).toEqual({
143 logLevel: "Debug",
144 ...defaultObservabilityConfig,
145 mode: "web",
146 port: 8788,
147 cwd: process.cwd(),
148 baseDir,
149 ...derivedPaths,
150 host: "127.0.0.1",
151 staticDir: undefined,
152 devUrl: new URL("http://127.0.0.1:4173"),
153 noBrowser: true,
154 startupPresentation: "browser",
155 desktopBootstrapToken: undefined,
156 autoBootstrapProjectFromCwd: true,
157 logWebSocketEvents: true,
158 tailscaleServeEnabled: true,
159 tailscaleServePort: 8443,
160 });
161 }),
162 );
163
164 it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
165 Effect.gen(function* () {
166 const { join } = yield* Path.Path;
167 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
168 const fd = yield* openBootstrapFd({
169 noBrowser: true,
170 autoBootstrapProjectFromCwd: true,
171 logWebSocketEvents: true,
172 tailscaleServeEnabled: false,
173 tailscaleServePort: 443,
174 });
175 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
176
177 const resolved = yield* resolveServerConfig(
178 {
179 mode: Option.some("web"),
180 port: Option.some(8788),
181 host: Option.some("127.0.0.1"),
182 baseDir: Option.some(baseDir),
183 cwd: Option.none(),
184 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
185 noBrowser: Option.some(false),
186 bootstrapFd: Option.none(),
187 autoBootstrapProjectFromCwd: Option.some(false),
188 logWebSocketEvents: Option.some(false),
189 tailscaleServeEnabled: Option.none(),
190 tailscaleServePort: Option.none(),
191 },
192 Option.none(),
193 ).pipe(
194 Effect.provide(
195 Layer.mergeAll(
196 ConfigProvider.layer(
197 ConfigProvider.fromEnv({
198 env: {
199 T3CODE_BOOTSTRAP_FD: String(fd),
200 T3CODE_NO_BROWSER: "true",
201 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
202 T3CODE_LOG_WS_EVENTS: "true",
203 },
204 }),
205 ),
206 NetService.layer,
207 ),
208 ),
209 );
210
211 expect(resolved).toEqual({
212 logLevel: "Info",
213 ...defaultObservabilityConfig,
214 mode: "web",
215 port: 8788,
216 cwd: process.cwd(),
217 baseDir,
218 ...derivedPaths,
219 host: "127.0.0.1",
220 staticDir: undefined,
221 devUrl: new URL("http://127.0.0.1:4173"),
222 noBrowser: false,
223 startupPresentation: "browser",
224 desktopBootstrapToken: undefined,
225 autoBootstrapProjectFromCwd: false,
226 logWebSocketEvents: false,
227 tailscaleServeEnabled: false,
228 tailscaleServePort: 443,
229 });
230 }),
231 );
232
233 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
234 Effect.gen(function* () {
235 const { join } = yield* Path.Path;
236 const baseDir = "/tmp/t3-bootstrap-home";
237 const fd = yield* openBootstrapFd({
238 mode: "desktop",
239 port: 4888,
240 host: "127.0.0.2",
241 t3Home: baseDir,
242 devUrl: "http://127.0.0.1:5173",
243 noBrowser: true,
244 autoBootstrapProjectFromCwd: false,
245 logWebSocketEvents: true,
246 tailscaleServeEnabled: false,
247 tailscaleServePort: 443,
248 otlpTracesUrl: "http://localhost:4318/v1/traces",
249 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
250 });
251 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
252
253 const resolved = yield* resolveServerConfig(
254 {
255 mode: Option.none(),
256 port: Option.none(),
257 host: Option.none(),
258 baseDir: Option.none(),
259 cwd: Option.none(),
260 devUrl: Option.none(),
261 noBrowser: Option.none(),
262 bootstrapFd: Option.none(),
263 autoBootstrapProjectFromCwd: Option.none(),
264 logWebSocketEvents: Option.none(),
265 tailscaleServeEnabled: Option.none(),
266 tailscaleServePort: Option.none(),
267 },
268 Option.none(),
269 ).pipe(
270 Effect.provide(
271 Layer.mergeAll(
272 ConfigProvider.layer(
273 ConfigProvider.fromEnv({
274 env: {
275 T3CODE_BOOTSTRAP_FD: String(fd),
276 },
277 }),
278 ),
279 NetService.layer,
280 ),
281 ),
282 );
283
284 expect(resolved).toEqual({
285 logLevel: "Info",
286 ...defaultObservabilityConfig,
287 otlpTracesUrl: "http://localhost:4318/v1/traces",
288 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
289 mode: "desktop",
290 port: 4888,
291 cwd: process.cwd(),
292 baseDir,
293 ...derivedPaths,
294 host: "127.0.0.2",
295 staticDir: undefined,
296 devUrl: new URL("http://127.0.0.1:5173"),
297 noBrowser: true,
298 startupPresentation: "browser",
299 desktopBootstrapToken: undefined,
300 autoBootstrapProjectFromCwd: false,
301 logWebSocketEvents: true,
302 tailscaleServeEnabled: false,
303 tailscaleServePort: 443,
304 });
305 assert.equal(join(baseDir, "dev"), resolved.stateDir);
306 }),
307 );
308
309 it.effect("creates derived runtime directories during config resolution", () =>
310 Effect.gen(function* () {
311 const fs = yield* FileSystem.FileSystem;
312 const path = yield* Path.Path;
313 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" });
314 const customCwd = path.join(baseDir, "nested", "project");
315
316 const resolved = yield* resolveServerConfig(
317 {
318 mode: Option.some("desktop"),
319 port: Option.some(4888),
320 host: Option.none(),
321 baseDir: Option.some(baseDir),
322 cwd: Option.some(customCwd),
323 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
324 noBrowser: Option.none(),
325 bootstrapFd: Option.none(),
326 autoBootstrapProjectFromCwd: Option.none(),
327 logWebSocketEvents: Option.none(),
328 tailscaleServeEnabled: Option.none(),
329 tailscaleServePort: Option.none(),
330 },
331 Option.none(),
332 ).pipe(
333 Effect.provide(
334 Layer.mergeAll(
335 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
336 NetService.layer,
337 ),
338 ),
339 );
340
341 for (const directory of [
342 customCwd,
343 resolved.stateDir,
344 resolved.logsDir,
345 resolved.providerLogsDir,
346 resolved.terminalLogsDir,
347 resolved.attachmentsDir,
348 resolved.worktreesDir,
349 path.dirname(resolved.serverLogPath),
350 path.dirname(resolved.serverTracePath),
351 ]) {
352 expect(yield* fs.exists(directory)).toBe(true);
353 }
354 expect(resolved.cwd).toBe(path.resolve(customCwd));
355 }),
356 );
357
358 it.effect("applies flag then env precedence over bootstrap envelope values", () =>
359 Effect.gen(function* () {
360 const { join } = yield* Path.Path;
361 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
362 const fd = yield* openBootstrapFd({
363 mode: "desktop",
364 port: 4888,
365 host: "127.0.0.2",
366 t3Home: "/tmp/t3-bootstrap-home",
367 devUrl: "http://127.0.0.1:5173",
368 noBrowser: false,
369 autoBootstrapProjectFromCwd: false,
370 logWebSocketEvents: false,
371 tailscaleServeEnabled: false,
372 tailscaleServePort: 443,
373 });
374 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
375
376 const resolved = yield* resolveServerConfig(
377 {
378 mode: Option.none(),
379 port: Option.some(8788),
380 host: Option.some("127.0.0.1"),
381 baseDir: Option.none(),
382 cwd: Option.none(),
383 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
384 noBrowser: Option.none(),
385 bootstrapFd: Option.none(),
386 autoBootstrapProjectFromCwd: Option.none(),
387 logWebSocketEvents: Option.none(),
388 tailscaleServeEnabled: Option.none(),
389 tailscaleServePort: Option.none(),
390 },
391 Option.some("Debug"),
392 ).pipe(
393 Effect.provide(
394 Layer.mergeAll(
395 ConfigProvider.layer(
396 ConfigProvider.fromEnv({
397 env: {
398 T3CODE_MODE: "web",
399 T3CODE_BOOTSTRAP_FD: String(fd),
400 T3CODE_HOME: baseDir,
401 T3CODE_NO_BROWSER: "true",
402 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
403 T3CODE_LOG_WS_EVENTS: "true",
404 },
405 }),
406 ),
407 NetService.layer,
408 ),
409 ),
410 );
411
412 expect(resolved).toEqual({
413 logLevel: "Debug",
414 ...defaultObservabilityConfig,
415 mode: "web",
416 port: 8788,
417 cwd: process.cwd(),
418 baseDir,
419 ...derivedPaths,
420 host: "127.0.0.1",
421 staticDir: undefined,
422 devUrl: new URL("http://127.0.0.1:4173"),
423 noBrowser: true,
424 startupPresentation: "browser",
425 desktopBootstrapToken: undefined,
426 autoBootstrapProjectFromCwd: true,
427 logWebSocketEvents: true,
428 tailscaleServeEnabled: false,
429 tailscaleServePort: 443,
430 });
431 }),
432 );
433
434 it.effect("falls back to persisted observability settings when env vars are absent", () =>
435 Effect.gen(function* () {
436 const fs = yield* FileSystem.FileSystem;
437 const path = yield* Path.Path;
438 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
439 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
440 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
441 yield* fs.writeFileString(
442 derivedPaths.settingsPath,
443 `${JSON.stringify({
444 observability: {
445 otlpTracesUrl: "http://localhost:4318/v1/traces",
446 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
447 },
448 })}\n`,
449 );
450
451 const resolved = yield* resolveServerConfig(
452 {
453 mode: Option.some("desktop"),
454 port: Option.some(4888),
455 host: Option.none(),
456 baseDir: Option.some(baseDir),
457 cwd: Option.none(),
458 devUrl: Option.none(),
459 noBrowser: Option.none(),
460 bootstrapFd: Option.none(),
461 autoBootstrapProjectFromCwd: Option.none(),
462 logWebSocketEvents: Option.none(),
463 tailscaleServeEnabled: Option.none(),
464 tailscaleServePort: Option.none(),
465 },
466 Option.none(),
467 ).pipe(
468 Effect.provide(
469 Layer.mergeAll(
470 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
471 NetService.layer,
472 ),
473 ),
474 );
475
476 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
477 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
478 expect(resolved).toEqual({
479 logLevel: "Info",
480 ...defaultObservabilityConfig,
481 otlpTracesUrl: "http://localhost:4318/v1/traces",
482 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
483 mode: "desktop",
484 port: 4888,
485 cwd: process.cwd(),
486 baseDir,
487 ...derivedPaths,
488 host: "127.0.0.1",
489 staticDir: resolved.staticDir,
490 devUrl: undefined,
491 noBrowser: true,
492 startupPresentation: "browser",
493 desktopBootstrapToken: undefined,
494 autoBootstrapProjectFromCwd: false,
495 logWebSocketEvents: false,
496 tailscaleServeEnabled: false,
497 tailscaleServePort: 443,
498 });
499 }),
500 );
501
502 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
503 Effect.gen(function* () {
504 const { join } = yield* Path.Path;
505 const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
506 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
507
508 const resolved = yield* resolveServerConfig(
509 {
510 mode: Option.some("web"),
511 port: Option.some(3773),
512 host: Option.none(),
513 baseDir: Option.some(baseDir),
514 cwd: Option.none(),
515 devUrl: Option.none(),
516 noBrowser: Option.none(),
517 bootstrapFd: Option.none(),
518 autoBootstrapProjectFromCwd: Option.none(),
519 logWebSocketEvents: Option.none(),
520 tailscaleServeEnabled: Option.none(),
521 tailscaleServePort: Option.none(),
522 },
523 Option.none(),
524 {
525 startupPresentation: "headless",
526 },
527 ).pipe(
528 Effect.provide(
529 Layer.mergeAll(
530 ConfigProvider.layer(
531 ConfigProvider.fromEnv({
532 env: {
533 T3CODE_NO_BROWSER: "false",
534 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
535 },
536 }),
537 ),
538 NetService.layer,
539 ),
540 ),
541 );
542
543 expect(resolved).toEqual({
544 logLevel: "Info",
545 ...defaultObservabilityConfig,
546 mode: "web",
547 port: 3773,
548 cwd: process.cwd(),
549 baseDir,
550 ...derivedPaths,
551 host: undefined,
552 staticDir: resolved.staticDir,
553 devUrl: undefined,
554 noBrowser: true,
555 startupPresentation: "headless",
556 desktopBootstrapToken: undefined,
557 autoBootstrapProjectFromCwd: false,
558 logWebSocketEvents: false,
559 tailscaleServeEnabled: false,
560 tailscaleServePort: 443,
561 });
562 }),
563 );
564});
apps/server/src/cli.test.ts deleted-357
...@@ -1,357 +0,0 @@
1import * as NodeHttp from "node:http";
2import { mkdtempSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5
6import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { NetService } from "@t3tools/shared/Net";
9import { assert, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as HttpRouter from "effect/unstable/http/HttpRouter";
13import * as HttpServer from "effect/unstable/http/HttpServer";
14import * as CliError from "effect/unstable/cli/CliError";
15import * as TestConsole from "effect/testing/TestConsole";
16import { Command } from "effect/unstable/cli";
17
18import { cli } from "./cli.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
21import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
22import {
23 orchestrationDispatchRouteLayer,
24 orchestrationSnapshotRouteLayer,
25} from "./orchestration/http.ts";
26import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
27import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
28import {
29 makePersistedServerRuntimeState,
30 persistServerRuntimeState,
31} from "./serverRuntimeState.ts";
32import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
33import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
34import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
35
36const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
37
38const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
39const runCliWithRuntime = (args: ReadonlyArray<string>) =>
40 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
41
42const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
43 Effect.gen(function* () {
44 const result = yield* effect;
45 const output =
46 (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ??
47 "";
48 return { result, output };
49 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
50
51const makeCliTestServerConfig = (baseDir: string) =>
52 Effect.gen(function* () {
53 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
54 return {
55 logLevel: "Info",
56 traceMinLevel: "Info",
57 traceTimingEnabled: true,
58 traceBatchWindowMs: 200,
59 traceMaxBytes: 10 * 1024 * 1024,
60 traceMaxFiles: 10,
61 otlpTracesUrl: undefined,
62 otlpMetricsUrl: undefined,
63 otlpExportIntervalMs: 10_000,
64 otlpServiceName: "t3-server",
65 mode: "web",
66 port: 0,
67 host: "127.0.0.1",
68 cwd: process.cwd(),
69 baseDir,
70 ...derivedPaths,
71 staticDir: undefined,
72 devUrl: undefined,
73 noBrowser: true,
74 startupPresentation: "browser",
75 desktopBootstrapToken: undefined,
76 autoBootstrapProjectFromCwd: false,
77 logWebSocketEvents: false,
78 tailscaleServeEnabled: false,
79 tailscaleServePort: 443,
80 } satisfies ServerConfigShape;
81 });
82
83const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
84 Layer.mergeAll(
85 OrchestrationLayerLive.pipe(
86 Layer.provideMerge(RepositoryIdentityResolverLive),
87 Layer.provideMerge(SqlitePersistenceLayerLive),
88 ),
89 WorkspacePathsLive,
90 ).pipe(
91 Layer.provideMerge(NodeServices.layer),
92 Layer.provide(Layer.succeed(ServerConfig, config)),
93 );
94
95const readPersistedSnapshot = (baseDir: string) =>
96 Effect.gen(function* () {
97 const config = yield* makeCliTestServerConfig(baseDir);
98 return yield* Effect.gen(function* () {
99 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
100 return yield* projectionSnapshotQuery.getSnapshot();
101 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
102 });
103
104const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
105 Effect.gen(function* () {
106 const config = yield* makeCliTestServerConfig(baseDir);
107 const routesLayer = Layer.mergeAll(
108 orchestrationSnapshotRouteLayer,
109 orchestrationDispatchRouteLayer,
110 );
111 const appLayer = HttpRouter.serve(routesLayer, {
112 disableListenLog: true,
113 disableLogger: true,
114 }).pipe(
115 Layer.provideMerge(
116 ServerAuthLive.pipe(
117 Layer.provideMerge(SqlitePersistenceLayerLive),
118 Layer.provide(ServerSecretStoreLive),
119 ),
120 ),
121 Layer.provideMerge(makeProjectPersistenceLayer(config)),
122 Layer.provideMerge(
123 NodeHttpServer.layer(NodeHttp.createServer, {
124 host: "127.0.0.1",
125 port: 0,
126 }),
127 ),
128 Layer.provideMerge(NodeServices.layer),
129 Layer.provide(Layer.succeed(ServerConfig, config)),
130 );
131
132 return yield* Effect.scoped(
133 Effect.gen(function* () {
134 const server = yield* HttpServer.HttpServer;
135 const address = server.address;
136 if (typeof address === "string" || !("port" in address)) {
137 assert.fail(`Expected TCP address, got ${address}`);
138 }
139 yield* persistServerRuntimeState({
140 path: config.serverRuntimeStatePath,
141 state: makePersistedServerRuntimeState({
142 config,
143 port: address.port,
144 }),
145 });
146 return yield* run();
147 }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
148 );
149 });
150
151it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
152 it.effect("accepts the built-in lowercase log-level flag values", () =>
153 runCliWithRuntime(["--log-level", "debug", "--version"]),
154 );
155
156 it.effect("accepts canonical --no-<flag> boolean negation", () =>
157 runCliWithRuntime(["--no-log-websocket-events", "--version"]),
158 );
159
160 it.effect("rejects invalid log-level casing before launching the server", () =>
161 Effect.gen(function* () {
162 const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip);
163
164 if (!CliError.isCliError(error)) {
165 assert.fail(`Expected CliError, got ${String(error)}`);
166 }
167 if (error._tag !== "InvalidValue") {
168 assert.fail(`Expected InvalidValue, got ${error._tag}`);
169 }
170 assert.equal(error.option, "log-level");
171 assert.equal(error.value, "Debug");
172 }),
173 );
174
175 it.effect("executes auth pairing subcommands and redacts secrets from list output", () =>
176 Effect.gen(function* () {
177 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-"));
178
179 const createdOutput = yield* captureStdout(
180 runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]),
181 );
182 const created = JSON.parse(createdOutput.output) as {
183 readonly id: string;
184 readonly credential: string;
185 };
186 const listedOutput = yield* captureStdout(
187 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
188 );
189 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
190 readonly id: string;
191 readonly credential?: string;
192 }>;
193
194 assert.equal(typeof created.id, "string");
195 assert.equal(typeof created.credential, "string");
196 assert.equal(created.credential.length > 0, true);
197 assert.equal(listed.length, 1);
198 assert.equal(listed[0]?.id, created.id);
199 assert.equal("credential" in (listed[0] ?? {}), false);
200 }),
201 );
202
203 it.effect("executes auth session subcommands and redacts secrets from list output", () =>
204 Effect.gen(function* () {
205 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-"));
206
207 const issuedOutput = yield* captureStdout(
208 runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]),
209 );
210 const issued = JSON.parse(issuedOutput.output) as {
211 readonly sessionId: string;
212 readonly token: string;
213 readonly role: string;
214 };
215 const listedOutput = yield* captureStdout(
216 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
217 );
218 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
219 readonly sessionId: string;
220 readonly token?: string;
221 readonly role: string;
222 }>;
223
224 assert.equal(typeof issued.sessionId, "string");
225 assert.equal(typeof issued.token, "string");
226 assert.equal(issued.role, "owner");
227 assert.equal(listed.length, 1);
228 assert.equal(listed[0]?.sessionId, issued.sessionId);
229 assert.equal(listed[0]?.role, "owner");
230 assert.equal("token" in (listed[0] ?? {}), false);
231 }),
232 );
233
234 it.effect("rejects invalid ttl values before running auth commands", () =>
235 Effect.gen(function* () {
236 const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe(
237 Effect.flip,
238 );
239
240 if (!CliError.isCliError(error)) {
241 assert.fail(`Expected CliError, got ${String(error)}`);
242 }
243 if (error._tag !== "ShowHelp") {
244 assert.fail(`Expected ShowHelp, got ${error._tag}`);
245 }
246 assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]);
247 const ttlError = error.errors[0] as CliError.CliError | undefined;
248 if (!ttlError || ttlError._tag !== "InvalidValue") {
249 assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`);
250 }
251 assert.equal(ttlError.option, "ttl");
252 assert.equal(ttlError.value, "soon");
253 assert.isTrue(ttlError.message.includes("Invalid duration"));
254 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
255 }),
256 );
257
258 it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
259 Effect.gen(function* () {
260 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
261 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
262
263 yield* runCliWithRuntime([
264 "project",
265 "add",
266 workspaceRoot,
267 "--title",
268 "Alpha",
269 "--base-dir",
270 baseDir,
271 ]);
272 const afterAdd = yield* readPersistedSnapshot(baseDir);
273 const addedProject = afterAdd.projects.find(
274 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
275 );
276 assert.isTrue(addedProject !== undefined);
277 assert.equal(addedProject?.title, "Alpha");
278
279 yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
280 const afterRename = yield* readPersistedSnapshot(baseDir);
281 const renamedProject = afterRename.projects.find(
282 (project) => project.id === addedProject?.id,
283 );
284 assert.equal(renamedProject?.title, "Beta");
285 assert.equal(renamedProject?.deletedAt, null);
286
287 yield* runCliWithRuntime([
288 "project",
289 "remove",
290 addedProject?.id ?? "",
291 "--base-dir",
292 baseDir,
293 ]);
294 const afterRemove = yield* readPersistedSnapshot(baseDir);
295 const removedProject = afterRemove.projects.find(
296 (project) => project.id === addedProject?.id,
297 );
298 assert.isTrue((removedProject?.deletedAt ?? null) !== null);
299 }),
300 );
301
302 it.effect("routes project commands through a running server when runtime state is present", () =>
303 Effect.gen(function* () {
304 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
305 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
306
307 yield* withLiveProjectCliServer(baseDir, () =>
308 Effect.gen(function* () {
309 yield* runCliWithRuntime([
310 "project",
311 "add",
312 workspaceRoot,
313 "--title",
314 "Live Project",
315 "--base-dir",
316 baseDir,
317 ]);
318 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
319 const readModel = yield* projectionSnapshotQuery.getSnapshot();
320 const addedProject = readModel.projects.find(
321 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
322 );
323 assert.isTrue(addedProject !== undefined);
324 assert.equal(addedProject?.title, "Live Project");
325 }),
326 );
327 }),
328 );
329
330 it.effect("rejects dev-url on project commands", () =>
331 Effect.gen(function* () {
332 const workspaceRoot = mkdtempSync(
333 join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
334 );
335 const error = yield* runCliWithRuntime([
336 "project",
337 "add",
338 workspaceRoot,
339 "--dev-url",
340 "http://127.0.0.1:5173",
341 ]).pipe(Effect.flip);
342
343 if (!CliError.isCliError(error)) {
344 assert.fail(`Expected CliError, got ${String(error)}`);
345 }
346 if (error._tag !== "ShowHelp") {
347 assert.fail(`Expected ShowHelp, got ${error._tag}`);
348 }
349 assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
350 const optionError = error.errors[0] as CliError.CliError | undefined;
351 if (!optionError || optionError._tag !== "UnrecognizedOption") {
352 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
353 }
354 assert.equal(optionError.option, "--dev-url");
355 }),
356 );
357});
apps/server/src/cli.ts deleted-1180
...@@ -1,1180 +0,0 @@
1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import {
4 AuthSessionId,
5 CommandId,
6 OrchestrationReadModel,
7 ProjectId,
8 type ClientOrchestrationCommand,
9} from "@t3tools/contracts";
10import {
11 Config,
12 Console,
13 Duration,
14 Effect,
15 Exit,
16 FileSystem,
17 Layer,
18 LogLevel,
19 Option,
20 Path,
21 References,
22 Schema,
23 SchemaIssue,
24 SchemaTransformation,
25} from "effect";
26import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
27import {
28 FetchHttpClient,
29 HttpClient,
30 HttpClientRequest,
31 HttpClientResponse,
32} from "effect/unstable/http";
33
34import {
35 DEFAULT_PORT,
36 deriveServerPaths,
37 ensureServerDirectories,
38 resolveStaticDir,
39 ServerConfig,
40 RuntimeMode,
41 type ServerConfigShape,
42 type StartupPresentation,
43} from "./config.ts";
44import { readBootstrapEnvelope } from "./bootstrap.ts";
45import { expandHomePath, resolveBaseDir } from "./os-jank.ts";
46import { runServer } from "./server.ts";
47import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts";
48import {
49 formatIssuedPairingCredential,
50 formatIssuedSession,
51 formatPairingCredentialList,
52 formatSessionList,
53} from "./cliAuthFormat.ts";
54import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts";
55import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
56import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
57import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
58import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
59import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
60import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
61import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts";
62import {
63 clearPersistedServerRuntimeState,
64 readPersistedServerRuntimeState,
65} from "./serverRuntimeState.ts";
66import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
67import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
68
69const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
70
71const BootstrapEnvelopeSchema = Schema.Struct({
72 mode: Schema.optional(RuntimeMode),
73 port: Schema.optional(PortSchema),
74 host: Schema.optional(Schema.String),
75 t3Home: Schema.optional(Schema.String),
76 devUrl: Schema.optional(Schema.URLFromString),
77 noBrowser: Schema.optional(Schema.Boolean),
78 desktopBootstrapToken: Schema.optional(Schema.String),
79 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
80 logWebSocketEvents: Schema.optional(Schema.Boolean),
81 tailscaleServeEnabled: Schema.optional(Schema.Boolean),
82 tailscaleServePort: Schema.optional(PortSchema),
83 otlpTracesUrl: Schema.optional(Schema.String),
84 otlpMetricsUrl: Schema.optional(Schema.String),
85});
86
87const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
88 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
89 Flag.optional,
90);
91const portFlag = Flag.integer("port").pipe(
92 Flag.withSchema(PortSchema),
93 Flag.withDescription("Port for the HTTP/WebSocket server."),
94 Flag.optional,
95);
96const hostFlag = Flag.string("host").pipe(
97 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
98 Flag.optional,
99);
100const baseDirFlag = Flag.string("base-dir").pipe(
101 Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
102 Flag.optional,
103);
104const devUrlFlag = Flag.string("dev-url").pipe(
105 Flag.withSchema(Schema.URLFromString),
106 Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
107 Flag.optional,
108);
109const noBrowserFlag = Flag.boolean("no-browser").pipe(
110 Flag.withDescription("Disable automatic browser opening."),
111 Flag.optional,
112);
113const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
114 Flag.withSchema(Schema.Int),
115 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
116 Flag.optional,
117);
118const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
119 Flag.withDescription(
120 "Create a project for the current working directory on startup when missing.",
121 ),
122 Flag.optional,
123);
124const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
125 Flag.withDescription(
126 "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
127 ),
128 Flag.withAlias("log-ws-events"),
129 Flag.optional,
130);
131const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe(
132 Flag.withDescription(
133 "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.",
134 ),
135 Flag.optional,
136);
137const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
138 Flag.withSchema(PortSchema),
139 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
140 Flag.optional,
141);
142
143const EnvServerConfig = Config.all({
144 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
145 traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")),
146 traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)),
147 traceFile: Config.string("T3CODE_TRACE_FILE").pipe(
148 Config.option,
149 Config.map(Option.getOrUndefined),
150 ),
151 traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
152 traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)),
153 traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)),
154 otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe(
155 Config.option,
156 Config.map(Option.getOrUndefined),
157 ),
158 otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe(
159 Config.option,
160 Config.map(Option.getOrUndefined),
161 ),
162 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
163 Config.withDefault(10_000),
164 ),
165 otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),
166 mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe(
167 Config.option,
168 Config.map(Option.getOrUndefined),
169 ),
170 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
171 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
172 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
173 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
174 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
175 Config.option,
176 Config.map(Option.getOrUndefined),
177 ),
178 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
179 Config.option,
180 Config.map(Option.getOrUndefined),
181 ),
182 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
183 Config.option,
184 Config.map(Option.getOrUndefined),
185 ),
186 logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
187 Config.option,
188 Config.map(Option.getOrUndefined),
189 ),
190 tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe(
191 Config.option,
192 Config.map(Option.getOrUndefined),
193 ),
194 tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe(
195 Config.option,
196 Config.map(Option.getOrUndefined),
197 ),
198});
199
200interface CliServerFlags {
201 readonly mode: Option.Option<RuntimeMode>;
202 readonly port: Option.Option<number>;
203 readonly host: Option.Option<string>;
204 readonly baseDir: Option.Option<string>;
205 readonly cwd: Option.Option<string>;
206 readonly devUrl: Option.Option<URL>;
207 readonly noBrowser: Option.Option<boolean>;
208 readonly bootstrapFd: Option.Option<number>;
209 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
210 readonly logWebSocketEvents: Option.Option<boolean>;
211 readonly tailscaleServeEnabled: Option.Option<boolean>;
212 readonly tailscaleServePort: Option.Option<number>;
213}
214
215interface CliAuthLocationFlags {
216 readonly baseDir: Option.Option<string>;
217 readonly devUrl?: Option.Option<URL>;
218}
219
220const resolveOptionPrecedence = <Value>(
221 ...values: ReadonlyArray<Option.Option<Value>>
222): Option.Option<Value> => Option.firstSomeOf(values);
223
224const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
225 const fs = yield* FileSystem.FileSystem;
226 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
227 if (!exists) {
228 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
229 }
230
231 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
232 return parsePersistedServerObservabilitySettings(raw);
233});
234
235export const resolveServerConfig = (
236 flags: CliServerFlags,
237 cliLogLevel: Option.Option<LogLevel.LogLevel>,
238 options?: {
239 readonly startupPresentation?: StartupPresentation;
240 readonly forceAutoBootstrapProjectFromCwd?: boolean;
241 },
242) =>
243 Effect.gen(function* () {
244 const { findAvailablePort } = yield* NetService;
245 const path = yield* Path.Path;
246 const fs = yield* FileSystem.FileSystem;
247 const env = yield* EnvServerConfig;
248 const normalizedFlags = {
249 mode: flags.mode ?? Option.none(),
250 port: flags.port ?? Option.none(),
251 host: flags.host ?? Option.none(),
252 baseDir: flags.baseDir ?? Option.none(),
253 cwd: flags.cwd ?? Option.none(),
254 devUrl: flags.devUrl ?? Option.none(),
255 noBrowser: flags.noBrowser ?? Option.none(),
256 bootstrapFd: flags.bootstrapFd ?? Option.none(),
257 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
258 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
259 tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
260 tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
261 } satisfies CliServerFlags;
262 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
263 const bootstrapEnvelope =
264 bootstrapFd !== undefined
265 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
266 : Option.none();
267 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
268
269 const mode: RuntimeMode = Option.getOrElse(
270 resolveOptionPrecedence(
271 normalizedFlags.mode,
272 Option.fromUndefinedOr(env.mode),
273 Option.fromUndefinedOr(bootstrap?.mode),
274 ),
275 () => "web",
276 );
277
278 const port = yield* Option.match(
279 resolveOptionPrecedence(
280 normalizedFlags.port,
281 Option.fromUndefinedOr(env.port),
282 Option.fromUndefinedOr(bootstrap?.port),
283 ),
284 {
285 onSome: (value) => Effect.succeed(value),
286 onNone: () => {
287 if (mode === "desktop") {
288 return Effect.succeed(DEFAULT_PORT);
289 }
290 return findAvailablePort(DEFAULT_PORT);
291 },
292 },
293 );
294 const devUrl = Option.getOrElse(
295 resolveOptionPrecedence(
296 normalizedFlags.devUrl,
297 Option.fromUndefinedOr(env.devUrl),
298 Option.fromUndefinedOr(bootstrap?.devUrl),
299 ),
300 () => undefined,
301 );
302 const baseDir = yield* resolveBaseDir(
303 Option.getOrUndefined(
304 resolveOptionPrecedence(
305 normalizedFlags.baseDir,
306 Option.fromUndefinedOr(env.t3Home),
307 Option.fromUndefinedOr(bootstrap?.t3Home),
308 ),
309 ),
310 );
311 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
312 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
313 yield* fs.makeDirectory(cwd, { recursive: true });
314 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
315 yield* ensureServerDirectories(derivedPaths);
316 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
317 derivedPaths.settingsPath,
318 );
319 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
320 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
321 const startupPresentation = options?.startupPresentation ?? "browser";
322 const isHeadlessStartup = startupPresentation === "headless";
323 const noBrowser = Option.getOrElse(
324 resolveOptionPrecedence(
325 isHeadlessStartup ? Option.some(true) : Option.none(),
326 normalizedFlags.noBrowser,
327 Option.fromUndefinedOr(env.noBrowser),
328 Option.fromUndefinedOr(bootstrap?.noBrowser),
329 ),
330 () => mode === "desktop",
331 );
332 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
333 const autoBootstrapProjectFromCwd = Option.getOrElse(
334 resolveOptionPrecedence(
335 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
336 isHeadlessStartup ? Option.some(false) : Option.none(),
337 normalizedFlags.autoBootstrapProjectFromCwd,
338 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
339 Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
340 ),
341 () => mode === "web",
342 );
343 const logWebSocketEvents = Option.getOrElse(
344 resolveOptionPrecedence(
345 normalizedFlags.logWebSocketEvents,
346 Option.fromUndefinedOr(env.logWebSocketEvents),
347 Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
348 ),
349 () => Boolean(devUrl),
350 );
351 const tailscaleServeEnabled = Option.getOrElse(
352 resolveOptionPrecedence(
353 normalizedFlags.tailscaleServeEnabled,
354 Option.fromUndefinedOr(env.tailscaleServeEnabled),
355 Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled),
356 ),
357 () => false,
358 );
359 const tailscaleServePort = Option.getOrElse(
360 resolveOptionPrecedence(
361 normalizedFlags.tailscaleServePort,
362 Option.fromUndefinedOr(env.tailscaleServePort),
363 Option.fromUndefinedOr(bootstrap?.tailscaleServePort),
364 ),
365 () => 443,
366 );
367 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
368 const host = Option.getOrElse(
369 resolveOptionPrecedence(
370 normalizedFlags.host,
371 Option.fromUndefinedOr(env.host),
372 Option.fromUndefinedOr(bootstrap?.host),
373 ),
374 () => (mode === "desktop" ? "127.0.0.1" : undefined),
375 );
376 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
377
378 const config: ServerConfigShape = {
379 logLevel,
380 traceMinLevel: env.traceMinLevel,
381 traceTimingEnabled: env.traceTimingEnabled,
382 traceBatchWindowMs: env.traceBatchWindowMs,
383 traceMaxBytes: env.traceMaxBytes,
384 traceMaxFiles: env.traceMaxFiles,
385 otlpTracesUrl:
386 env.otlpTracesUrl ??
387 bootstrap?.otlpTracesUrl ??
388 persistedObservabilitySettings.otlpTracesUrl,
389 otlpMetricsUrl:
390 env.otlpMetricsUrl ??
391 bootstrap?.otlpMetricsUrl ??
392 persistedObservabilitySettings.otlpMetricsUrl,
393 otlpExportIntervalMs: env.otlpExportIntervalMs,
394 otlpServiceName: env.otlpServiceName,
395 mode,
396 port,
397 cwd,
398 baseDir,
399 ...derivedPaths,
400 serverTracePath,
401 host,
402 staticDir,
403 devUrl,
404 noBrowser,
405 startupPresentation,
406 desktopBootstrapToken,
407 autoBootstrapProjectFromCwd,
408 logWebSocketEvents,
409 tailscaleServeEnabled,
410 tailscaleServePort,
411 };
412
413 return config;
414 });
415
416const resolveCliAuthConfig = (
417 flags: CliAuthLocationFlags,
418 cliLogLevel: Option.Option<LogLevel.LogLevel>,
419) =>
420 resolveServerConfig(
421 {
422 mode: Option.none(),
423 port: Option.none(),
424 host: Option.none(),
425 baseDir: flags.baseDir,
426 cwd: Option.none(),
427 devUrl: flags.devUrl ?? Option.none(),
428 noBrowser: Option.none(),
429 bootstrapFd: Option.none(),
430 autoBootstrapProjectFromCwd: Option.none(),
431 logWebSocketEvents: Option.none(),
432 tailscaleServeEnabled: Option.none(),
433 tailscaleServePort: Option.none(),
434 },
435 cliLogLevel,
436 );
437
438const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
439
440const parseDurationInput = (value: string): Duration.Duration | null => {
441 const trimmed = value.trim();
442 if (trimmed.length === 0) return null;
443
444 const shorthand = DurationShorthandPattern.exec(trimmed);
445 const normalizedInput = shorthand?.groups
446 ? (() => {
447 const amountText = shorthand.groups.value;
448 const unitText = shorthand.groups.unit;
449 if (typeof amountText !== "string" || typeof unitText !== "string") {
450 return null;
451 }
452
453 const amount = Number.parseInt(amountText, 10);
454 if (!Number.isFinite(amount)) return null;
455
456 switch (unitText.toLowerCase()) {
457 case "ms":
458 return `${amount} millis`;
459 case "s":
460 return `${amount} seconds`;
461 case "m":
462 return `${amount} minutes`;
463 case "h":
464 return `${amount} hours`;
465 case "d":
466 return `${amount} days`;
467 case "w":
468 return `${amount} weeks`;
469 default:
470 return null;
471 }
472 })()
473 : (trimmed as Duration.Input);
474
475 if (normalizedInput === null) return null;
476
477 const decoded = Duration.fromInput(normalizedInput as Duration.Input);
478 return Option.isSome(decoded) ? decoded.value : null;
479};
480
481const DurationFromString = Schema.String.pipe(
482 Schema.decodeTo(
483 Schema.Duration,
484 SchemaTransformation.transformOrFail({
485 decode: (value) => {
486 const duration = parseDurationInput(value);
487 if (duration !== null) {
488 return Effect.succeed(duration);
489 }
490 return Effect.fail(
491 new SchemaIssue.InvalidValue(Option.some(value), {
492 message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
493 }),
494 );
495 },
496 encode: (duration) => Effect.succeed(Duration.format(duration)),
497 }),
498 ),
499);
500
501const runWithAuthControlPlane = <A, E>(
502 flags: CliAuthLocationFlags,
503 run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,
504 options?: {
505 readonly quietLogs?: boolean;
506 },
507) =>
508 Effect.gen(function* () {
509 const logLevel = yield* GlobalFlag.LogLevel;
510 const config = yield* resolveCliAuthConfig(flags, logLevel);
511 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
512 return yield* Effect.gen(function* () {
513 const authControlPlane = yield* AuthControlPlane;
514 return yield* run(authControlPlane);
515 }).pipe(
516 Effect.provide(
517 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
518 Layer.provide(Layer.succeed(ServerConfig, config)),
519 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
520 ),
521 ),
522 );
523 });
524
525type ProjectMutationTarget = {
526 readonly id: ProjectId;
527 readonly title: string;
528 readonly workspaceRoot: string;
529};
530
531type ProjectCommandExecutionMode = "live" | "offline";
532type ProjectCliDispatchCommand = Extract<
533 ClientOrchestrationCommand,
534 { type: "project.create" | "project.meta.update" | "project.delete" }
535>;
536
537const ProjectCliRuntimeLive = Layer.mergeAll(
538 WorkspacePathsLive,
539 OrchestrationLayerLive.pipe(
540 Layer.provideMerge(RepositoryIdentityResolverLive),
541 Layer.provideMerge(SqlitePersistenceLayerLive),
542 ),
543);
544
545const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
546const OrchestrationHttpErrorResponse = Schema.Struct({
547 error: Schema.String,
548});
549
550const withProjectCliSessionToken = <A, E, R>(
551 authControlPlane: AuthControlPlaneShape,
552 run: (token: string) => Effect.Effect<A, E, R>,
553) =>
554 Effect.acquireUseRelease(
555 authControlPlane.issueSession({
556 role: "owner",
557 label: "t3 project cli",
558 }),
559 (issued) => run(issued.token),
560 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
561 );
562
563const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
564 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
565
566const runLiveServerRequest = <A, E extends Error, R>(
567 request: HttpClientRequest.HttpClientRequest,
568 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
569) =>
570 Effect.gen(function* () {
571 const httpClient = yield* HttpClient.HttpClient;
572 const response = yield* httpClient.execute(request);
573 return yield* handle(response);
574 }).pipe(withProjectCliLiveServerTimeout);
575
576const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
577 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
578
579const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
580 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
581 Effect.map((body) => body.error),
582 Effect.catch(() => Effect.succeed(null)),
583 Effect.map((body) => {
584 if (typeof body === "string" && body.trim().length > 0) {
585 return body;
586 }
587 return `Server request failed with status ${response.status}.`;
588 }),
589 );
590
591const normalizeWorkspaceRootForProjectCommand = Effect.fn(
592 "normalizeWorkspaceRootForProjectCommand",
593)(function* (workspaceRoot: string) {
594 const workspacePaths = yield* WorkspacePaths;
595 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
596});
597
598const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
599 workspaceRoot: string,
600 explicitTitle?: string,
601) {
602 if (explicitTitle !== undefined) {
603 const trimmed = explicitTitle.trim();
604 if (trimmed.length > 0) {
605 return trimmed;
606 }
607 return yield* Effect.fail(new Error("Project title cannot be empty."));
608 }
609
610 const path = yield* Path.Path;
611 const basename = path.basename(workspaceRoot).trim();
612 return basename.length > 0 ? basename : "project";
613});
614
615const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
616 readonly snapshot: OrchestrationReadModel;
617 readonly identifier: string;
618}) {
619 const trimmedIdentifier = input.identifier.trim();
620 if (trimmedIdentifier.length === 0) {
621 return yield* Effect.fail(new Error("Project identifier cannot be empty."));
622 }
623
624 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
625 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
626 if (exactIdMatch) {
627 return {
628 id: exactIdMatch.id,
629 title: exactIdMatch.title,
630 workspaceRoot: exactIdMatch.workspaceRoot,
631 } satisfies ProjectMutationTarget;
632 }
633
634 const normalizedWorkspaceRootResult = yield* Effect.exit(
635 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
636 );
637 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
638 ? normalizedWorkspaceRootResult.value
639 : null;
640
641 const exactWorkspaceMatch =
642 normalizedWorkspaceRoot === null
643 ? undefined
644 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
645
646 const resolved = exactWorkspaceMatch;
647 if (!resolved) {
648 return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
649 }
650
651 return {
652 id: resolved.id,
653 title: resolved.title,
654 workspaceRoot: resolved.workspaceRoot,
655 } satisfies ProjectMutationTarget;
656});
657
658const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
659 runLiveServerRequest(
660 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
661 HttpClientRequest.acceptJson,
662 HttpClientRequest.bearerToken(bearerToken),
663 ),
664 HttpClientResponse.matchStatus({
665 "2xx": decodeOrchestrationReadModelResponse,
666 orElse: (response) =>
667 readErrorMessageFromResponse(response).pipe(
668 Effect.flatMap((message) => Effect.fail(new Error(message))),
669 ),
670 }),
671 );
672
673const dispatchLiveOrchestrationCommand = (
674 origin: string,
675 bearerToken: string,
676 command: ProjectCliDispatchCommand,
677) =>
678 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
679 HttpClientRequest.acceptJson,
680 HttpClientRequest.bearerToken(bearerToken),
681 HttpClientRequest.bodyJson(command),
682 Effect.flatMap((request) =>
683 runLiveServerRequest(
684 request,
685 HttpClientResponse.matchStatus({
686 "2xx": () => Effect.void,
687 orElse: (response) =>
688 readErrorMessageFromResponse(response).pipe(
689 Effect.flatMap((message) => Effect.fail(new Error(message))),
690 ),
691 }),
692 ),
693 ),
694 );
695
696const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
697 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
698 return yield* projectionSnapshotQuery.getSnapshot();
699});
700
701const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
702 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
703 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
704 if (Option.isNone(runtimeState)) {
705 return Option.none<{ readonly origin: string }>();
706 }
707
708 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
709 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
710 Effect.as({
711 origin: runtimeState.value.origin,
712 }),
713 ),
714 );
715
716 const attempted = yield* Effect.exit(attempt);
717 if (Exit.isSuccess(attempted)) {
718 return Option.some(attempted.value);
719 }
720
721 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
722 return Option.none<{ readonly origin: string }>();
723 },
724);
725
726const runProjectMutation = Effect.fn("runProjectMutation")(function* (
727 flags: CliAuthLocationFlags,
728 run: (input: {
729 readonly snapshot: OrchestrationReadModel;
730 readonly dispatch: (
731 command: ProjectCliDispatchCommand,
732 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
733 readonly mode: ProjectCommandExecutionMode;
734 }) => Effect.Effect<
735 string,
736 Error,
737 FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
738 >,
739) {
740 const logLevel = yield* GlobalFlag.LogLevel;
741 const config = yield* resolveCliAuthConfig(flags, logLevel);
742 const minimumLogLevel = config.logLevel;
743
744 return yield* Effect.gen(function* () {
745 const authControlPlane = yield* AuthControlPlane;
746 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
747
748 if (Option.isSome(liveMode)) {
749 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
750 Effect.gen(function* () {
751 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
752 const output = yield* run({
753 snapshot,
754 dispatch: (command) =>
755 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
756 mode: "live",
757 });
758 yield* Console.log(output);
759 }),
760 );
761 }
762
763 const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
764 Layer.provide(Layer.succeed(ServerConfig, config)),
765 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
766 );
767
768 return yield* Effect.gen(function* () {
769 const snapshot = yield* getOfflineSnapshot();
770 const orchestrationEngine = yield* OrchestrationEngineService;
771 const output = yield* run({
772 snapshot,
773 dispatch: (command) => orchestrationEngine.dispatch(command),
774 mode: "offline",
775 });
776 yield* Console.log(output);
777 }).pipe(Effect.provide(offlineRuntimeLayer));
778 }).pipe(
779 Effect.provide(
780 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
781 Layer.provideMerge(FetchHttpClient.layer),
782 Layer.provide(Layer.succeed(ServerConfig, config)),
783 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
784 ),
785 ),
786 );
787});
788
789const sharedServerLocationFlags = {
790 baseDir: baseDirFlag,
791 devUrl: devUrlFlag,
792} as const;
793
794const projectLocationFlags = {
795 baseDir: baseDirFlag,
796} as const;
797
798const sharedServerCommandFlags = {
799 mode: modeFlag,
800 port: portFlag,
801 host: hostFlag,
802 baseDir: baseDirFlag,
803 cwd: Argument.string("cwd").pipe(
804 Argument.withDescription(
805 "Working directory for provider sessions (defaults to the current directory).",
806 ),
807 Argument.optional,
808 ),
809 devUrl: devUrlFlag,
810 noBrowser: noBrowserFlag,
811 bootstrapFd: bootstrapFdFlag,
812 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
813 logWebSocketEvents: logWebSocketEventsFlag,
814 tailscaleServeEnabled: tailscaleServeFlag,
815 tailscaleServePort: tailscaleServePortFlag,
816} as const;
817
818const authLocationFlags = sharedServerLocationFlags;
819
820const ttlFlag = Flag.string("ttl").pipe(
821 Flag.withSchema(DurationFromString),
822 Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."),
823 Flag.optional,
824);
825
826const jsonFlag = Flag.boolean("json").pipe(
827 Flag.withDescription("Emit JSON instead of human-readable output."),
828 Flag.withDefault(false),
829);
830
831const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
832 Flag.withDescription("Role for the issued bearer session."),
833 Flag.withDefault("owner"),
834);
835
836const labelFlag = Flag.string("label").pipe(
837 Flag.withDescription("Optional human-readable label."),
838 Flag.optional,
839);
840
841const subjectFlag = Flag.string("subject").pipe(
842 Flag.withDescription("Optional session subject."),
843 Flag.optional,
844);
845
846const baseUrlFlag = Flag.string("base-url").pipe(
847 Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."),
848 Flag.optional,
849);
850
851const tokenOnlyFlag = Flag.boolean("token-only").pipe(
852 Flag.withDescription("Print only the issued bearer token."),
853 Flag.withDefault(false),
854);
855
856const pairingCreateCommand = Command.make("create", {
857 ...authLocationFlags,
858 ttl: ttlFlag,
859 label: labelFlag,
860 baseUrl: baseUrlFlag,
861 json: jsonFlag,
862}).pipe(
863 Command.withDescription("Issue a new client pairing token."),
864 Command.withHandler((flags) =>
865 runWithAuthControlPlane(
866 flags,
867 (authControlPlane) =>
868 Effect.gen(function* () {
869 const issued = yield* authControlPlane.createPairingLink({
870 role: "client",
871 subject: "one-time-token",
872 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
873 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
874 });
875 const output = formatIssuedPairingCredential(issued, {
876 json: flags.json,
877 ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}),
878 });
879 yield* Console.log(output);
880 }),
881 {
882 quietLogs: flags.json,
883 },
884 ),
885 ),
886);
887
888const pairingListCommand = Command.make("list", {
889 ...authLocationFlags,
890 json: jsonFlag,
891}).pipe(
892 Command.withDescription("List active client pairing tokens without revealing their secrets."),
893 Command.withHandler((flags) =>
894 runWithAuthControlPlane(
895 flags,
896 (authControlPlane) =>
897 Effect.gen(function* () {
898 const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });
899 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
900 }),
901 {
902 quietLogs: flags.json,
903 },
904 ),
905 ),
906);
907
908const pairingRevokeCommand = Command.make("revoke", {
909 ...authLocationFlags,
910 id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")),
911}).pipe(
912 Command.withDescription("Revoke an active client pairing token."),
913 Command.withHandler((flags) =>
914 runWithAuthControlPlane(flags, (authControlPlane) =>
915 Effect.gen(function* () {
916 const revoked = yield* authControlPlane.revokePairingLink(flags.id);
917 yield* Console.log(
918 revoked
919 ? `Revoked pairing credential ${flags.id}.\n`
920 : `No active pairing credential found for ${flags.id}.\n`,
921 );
922 }),
923 ),
924 ),
925);
926
927const pairingCommand = Command.make("pairing").pipe(
928 Command.withDescription("Manage one-time client pairing tokens."),
929 Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]),
930);
931
932const sessionIssueCommand = Command.make("issue", {
933 ...authLocationFlags,
934 ttl: ttlFlag,
935 role: sessionRoleFlag,
936 label: labelFlag,
937 subject: subjectFlag,
938 tokenOnly: tokenOnlyFlag,
939 json: jsonFlag,
940}).pipe(
941 Command.withDescription("Issue a bearer session token for headless or remote clients."),
942 Command.withHandler((flags) =>
943 runWithAuthControlPlane(
944 flags,
945 (authControlPlane) =>
946 Effect.gen(function* () {
947 const issued = yield* authControlPlane.issueSession({
948 role: flags.role,
949 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
950 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
951 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
952 });
953 yield* Console.log(
954 formatIssuedSession(issued, {
955 json: flags.json,
956 tokenOnly: flags.tokenOnly,
957 }),
958 );
959 }),
960 {
961 quietLogs: flags.json || flags.tokenOnly,
962 },
963 ),
964 ),
965);
966
967const sessionListCommand = Command.make("list", {
968 ...authLocationFlags,
969 json: jsonFlag,
970}).pipe(
971 Command.withDescription("List active sessions without revealing bearer tokens."),
972 Command.withHandler((flags) =>
973 runWithAuthControlPlane(
974 flags,
975 (authControlPlane) =>
976 Effect.gen(function* () {
977 const sessions = yield* authControlPlane.listSessions();
978 yield* Console.log(formatSessionList(sessions, { json: flags.json }));
979 }),
980 {
981 quietLogs: flags.json,
982 },
983 ),
984 ),
985);
986
987const sessionRevokeCommand = Command.make("revoke", {
988 ...authLocationFlags,
989 sessionId: Argument.string("session-id").pipe(
990 Argument.withDescription("Session id to revoke."),
991 Argument.withSchema(AuthSessionId),
992 ),
993}).pipe(
994 Command.withDescription("Revoke an active session."),
995 Command.withHandler((flags) =>
996 runWithAuthControlPlane(flags, (authControlPlane) =>
997 Effect.gen(function* () {
998 const revoked = yield* authControlPlane.revokeSession(flags.sessionId);
999 yield* Console.log(
1000 revoked
1001 ? `Revoked session ${flags.sessionId}.\n`
1002 : `No active session found for ${flags.sessionId}.\n`,
1003 );
1004 }),
1005 ),
1006 ),
1007);
1008
1009const sessionCommand = Command.make("session").pipe(
1010 Command.withDescription("Manage bearer sessions."),
1011 Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
1012);
1013
1014const authCommand = Command.make("auth").pipe(
1015 Command.withDescription("Manage the local auth control plane for headless deployments."),
1016 Command.withSubcommands([pairingCommand, sessionCommand]),
1017);
1018
1019const projectAddCommand = Command.make("add", {
1020 ...projectLocationFlags,
1021 workspaceRoot: Argument.string("path").pipe(
1022 Argument.withDescription("Workspace root to add as a project."),
1023 ),
1024 title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
1025}).pipe(
1026 Command.withDescription("Add a project."),
1027 Command.withHandler((flags) =>
1028 runProjectMutation(
1029 flags,
1030 Effect.fn("projectAddMutation")(function* ({
1031 snapshot,
1032 dispatch,
1033 }: {
1034 readonly snapshot: OrchestrationReadModel;
1035 readonly dispatch: (
1036 command: ProjectCliDispatchCommand,
1037 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1038 }) {
1039 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
1040 const existingProject = snapshot.projects.find(
1041 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
1042 );
1043 if (existingProject) {
1044 return yield* Effect.fail(
1045 new Error(`An active project already exists for '${workspaceRoot}'.`),
1046 );
1047 }
1048
1049 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
1050 const projectId = ProjectId.make(crypto.randomUUID());
1051 yield* dispatch({
1052 type: "project.create",
1053 commandId: CommandId.make(crypto.randomUUID()),
1054 projectId,
1055 title,
1056 workspaceRoot,
1057 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
1058 createdAt: new Date().toISOString(),
1059 });
1060 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
1061 }),
1062 ),
1063 ),
1064);
1065
1066const projectRemoveCommand = Command.make("remove", {
1067 ...projectLocationFlags,
1068 project: Argument.string("project").pipe(
1069 Argument.withDescription("Project id or workspace root to remove."),
1070 ),
1071}).pipe(
1072 Command.withDescription("Remove a project."),
1073 Command.withHandler((flags) =>
1074 runProjectMutation(
1075 flags,
1076 Effect.fn("projectRemoveMutation")(function* ({
1077 snapshot,
1078 dispatch,
1079 }: {
1080 readonly snapshot: OrchestrationReadModel;
1081 readonly dispatch: (
1082 command: ProjectCliDispatchCommand,
1083 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1084 }) {
1085 const project = yield* findActiveProjectTarget({
1086 snapshot,
1087 identifier: flags.project,
1088 });
1089 yield* dispatch({
1090 type: "project.delete",
1091 commandId: CommandId.make(crypto.randomUUID()),
1092 projectId: project.id,
1093 });
1094 return `Removed project ${project.id} (${project.title}).`;
1095 }),
1096 ),
1097 ),
1098);
1099
1100const projectRenameCommand = Command.make("rename", {
1101 ...projectLocationFlags,
1102 project: Argument.string("project").pipe(
1103 Argument.withDescription("Project id or workspace root to rename."),
1104 ),
1105 title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
1106}).pipe(
1107 Command.withDescription("Rename a project."),
1108 Command.withHandler((flags) =>
1109 runProjectMutation(
1110 flags,
1111 Effect.fn("projectRenameMutation")(function* ({
1112 snapshot,
1113 dispatch,
1114 }: {
1115 readonly snapshot: OrchestrationReadModel;
1116 readonly dispatch: (
1117 command: ProjectCliDispatchCommand,
1118 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1119 }) {
1120 const project = yield* findActiveProjectTarget({
1121 snapshot,
1122 identifier: flags.project,
1123 });
1124 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
1125 if (nextTitle === project.title) {
1126 return `Project ${project.id} is already named ${nextTitle}.`;
1127 }
1128
1129 yield* dispatch({
1130 type: "project.meta.update",
1131 commandId: CommandId.make(crypto.randomUUID()),
1132 projectId: project.id,
1133 title: nextTitle,
1134 });
1135 return `Renamed project ${project.id} to ${nextTitle}.`;
1136 }),
1137 ),
1138 ),
1139);
1140
1141const projectCommand = Command.make("project").pipe(
1142 Command.withDescription("Manage projects."),
1143 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
1144);
1145
1146const runServerCommand = (
1147 flags: CliServerFlags,
1148 options?: {
1149 readonly startupPresentation?: StartupPresentation;
1150 readonly forceAutoBootstrapProjectFromCwd?: boolean;
1151 },
1152) =>
1153 Effect.gen(function* () {
1154 const logLevel = yield* GlobalFlag.LogLevel;
1155 const config = yield* resolveServerConfig(flags, logLevel, options);
1156 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1157 });
1158
1159const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
1160 Command.withDescription("Run the T3 Code server."),
1161 Command.withHandler((flags) => runServerCommand(flags)),
1162);
1163
1164const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
1165 Command.withDescription(
1166 "Run the T3 Code server without opening a browser and print headless pairing details.",
1167 ),
1168 Command.withHandler((flags) =>
1169 runServerCommand(flags, {
1170 startupPresentation: "headless",
1171 forceAutoBootstrapProjectFromCwd: false,
1172 }),
1173 ),
1174);
1175
1176export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1177 Command.withDescription("Run the T3 Code server."),
1178 Command.withHandler((flags) => runServerCommand(flags)),
1179 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
1180);
apps/server/src/cli/auth.ts created+243
...@@ -0,0 +1,243 @@
1import { AuthSessionId } from "@t3tools/contracts";
2import { Console, Effect, Layer, Option, References } from "effect";
3import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
4
5import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";
6import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";
7import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
8import {
9 formatIssuedPairingCredential,
10 formatIssuedSession,
11 formatPairingCredentialList,
12 formatSessionList,
13} from "../cliAuthFormat.ts";
14import { ServerConfig } from "../config.ts";
15import {
16 authLocationFlags,
17 type CliAuthLocationFlags,
18 DurationFromString,
19 resolveCliAuthConfig,
20} from "./config.ts";
21
22const runWithAuthControlPlane = <A, E>(
23 flags: CliAuthLocationFlags,
24 run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,
25 options?: {
26 readonly quietLogs?: boolean;
27 },
28) =>
29 Effect.gen(function* () {
30 const logLevel = yield* GlobalFlag.LogLevel;
31 const config = yield* resolveCliAuthConfig(flags, logLevel);
32 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
33 return yield* Effect.gen(function* () {
34 const authControlPlane = yield* AuthControlPlane;
35 return yield* run(authControlPlane);
36 }).pipe(
37 Effect.provide(
38 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
39 Layer.provide(Layer.succeed(ServerConfig, config)),
40 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
41 ),
42 ),
43 );
44 });
45
46const ttlFlag = Flag.string("ttl").pipe(
47 Flag.withSchema(DurationFromString),
48 Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."),
49 Flag.optional,
50);
51
52const jsonFlag = Flag.boolean("json").pipe(
53 Flag.withDescription("Emit JSON instead of human-readable output."),
54 Flag.withDefault(false),
55);
56
57const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
58 Flag.withDescription("Role for the issued bearer session."),
59 Flag.withDefault("owner"),
60);
61
62const labelFlag = Flag.string("label").pipe(
63 Flag.withDescription("Optional human-readable label."),
64 Flag.optional,
65);
66
67const subjectFlag = Flag.string("subject").pipe(
68 Flag.withDescription("Optional session subject."),
69 Flag.optional,
70);
71
72const baseUrlFlag = Flag.string("base-url").pipe(
73 Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."),
74 Flag.optional,
75);
76
77const tokenOnlyFlag = Flag.boolean("token-only").pipe(
78 Flag.withDescription("Print only the issued bearer token."),
79 Flag.withDefault(false),
80);
81
82const pairingCreateCommand = Command.make("create", {
83 ...authLocationFlags,
84 ttl: ttlFlag,
85 label: labelFlag,
86 baseUrl: baseUrlFlag,
87 json: jsonFlag,
88}).pipe(
89 Command.withDescription("Issue a new client pairing token."),
90 Command.withHandler((flags) =>
91 runWithAuthControlPlane(
92 flags,
93 (authControlPlane) =>
94 Effect.gen(function* () {
95 const issued = yield* authControlPlane.createPairingLink({
96 role: "client",
97 subject: "one-time-token",
98 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
99 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
100 });
101 const output = formatIssuedPairingCredential(issued, {
102 json: flags.json,
103 ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}),
104 });
105 yield* Console.log(output);
106 }),
107 {
108 quietLogs: flags.json,
109 },
110 ),
111 ),
112);
113
114const pairingListCommand = Command.make("list", {
115 ...authLocationFlags,
116 json: jsonFlag,
117}).pipe(
118 Command.withDescription("List active client pairing tokens without revealing their secrets."),
119 Command.withHandler((flags) =>
120 runWithAuthControlPlane(
121 flags,
122 (authControlPlane) =>
123 Effect.gen(function* () {
124 const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });
125 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
126 }),
127 {
128 quietLogs: flags.json,
129 },
130 ),
131 ),
132);
133
134const pairingRevokeCommand = Command.make("revoke", {
135 ...authLocationFlags,
136 id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")),
137}).pipe(
138 Command.withDescription("Revoke an active client pairing token."),
139 Command.withHandler((flags) =>
140 runWithAuthControlPlane(flags, (authControlPlane) =>
141 Effect.gen(function* () {
142 const revoked = yield* authControlPlane.revokePairingLink(flags.id);
143 yield* Console.log(
144 revoked
145 ? `Revoked pairing credential ${flags.id}.\n`
146 : `No active pairing credential found for ${flags.id}.\n`,
147 );
148 }),
149 ),
150 ),
151);
152
153const pairingCommand = Command.make("pairing").pipe(
154 Command.withDescription("Manage one-time client pairing tokens."),
155 Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]),
156);
157
158const sessionIssueCommand = Command.make("issue", {
159 ...authLocationFlags,
160 ttl: ttlFlag,
161 role: sessionRoleFlag,
162 label: labelFlag,
163 subject: subjectFlag,
164 tokenOnly: tokenOnlyFlag,
165 json: jsonFlag,
166}).pipe(
167 Command.withDescription("Issue a bearer session token for headless or remote clients."),
168 Command.withHandler((flags) =>
169 runWithAuthControlPlane(
170 flags,
171 (authControlPlane) =>
172 Effect.gen(function* () {
173 const issued = yield* authControlPlane.issueSession({
174 role: flags.role,
175 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
176 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
177 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
178 });
179 yield* Console.log(
180 formatIssuedSession(issued, {
181 json: flags.json,
182 tokenOnly: flags.tokenOnly,
183 }),
184 );
185 }),
186 {
187 quietLogs: flags.json || flags.tokenOnly,
188 },
189 ),
190 ),
191);
192
193const sessionListCommand = Command.make("list", {
194 ...authLocationFlags,
195 json: jsonFlag,
196}).pipe(
197 Command.withDescription("List active sessions without revealing bearer tokens."),
198 Command.withHandler((flags) =>
199 runWithAuthControlPlane(
200 flags,
201 (authControlPlane) =>
202 Effect.gen(function* () {
203 const sessions = yield* authControlPlane.listSessions();
204 yield* Console.log(formatSessionList(sessions, { json: flags.json }));
205 }),
206 {
207 quietLogs: flags.json,
208 },
209 ),
210 ),
211);
212
213const sessionRevokeCommand = Command.make("revoke", {
214 ...authLocationFlags,
215 sessionId: Argument.string("session-id").pipe(
216 Argument.withDescription("Session id to revoke."),
217 Argument.withSchema(AuthSessionId),
218 ),
219}).pipe(
220 Command.withDescription("Revoke an active session."),
221 Command.withHandler((flags) =>
222 runWithAuthControlPlane(flags, (authControlPlane) =>
223 Effect.gen(function* () {
224 const revoked = yield* authControlPlane.revokeSession(flags.sessionId);
225 yield* Console.log(
226 revoked
227 ? `Revoked session ${flags.sessionId}.\n`
228 : `No active session found for ${flags.sessionId}.\n`,
229 );
230 }),
231 ),
232 ),
233);
234
235const sessionCommand = Command.make("session").pipe(
236 Command.withDescription("Manage bearer sessions."),
237 Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
238);
239
240export const authCommand = Command.make("auth").pipe(
241 Command.withDescription("Manage the local auth control plane for headless deployments."),
242 Command.withSubcommands([pairingCommand, sessionCommand]),
243);
apps/server/src/cli/config.test.ts created+564
...@@ -0,0 +1,564 @@
1import os from "node:os";
2
3import { assert, expect, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
5
6import { NetService } from "@t3tools/shared/Net";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { deriveServerPaths } from "../config.ts";
9import { resolveServerConfig } from "./config.ts";
10
11it.layer(NodeServices.layer)("cli config resolution", (it) => {
12 const defaultObservabilityConfig = {
13 traceMinLevel: "Info",
14 traceTimingEnabled: true,
15 traceBatchWindowMs: 200,
16 traceMaxBytes: 10 * 1024 * 1024,
17 traceMaxFiles: 10,
18 otlpTracesUrl: undefined,
19 otlpMetricsUrl: undefined,
20 otlpExportIntervalMs: 10_000,
21 otlpServiceName: "t3-server",
22 } as const;
23
24 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
25 const fs = yield* FileSystem.FileSystem;
26 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
27 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
28 const { fd } = yield* fs.open(filePath, { flag: "r" });
29 return fd;
30 });
31
32 it.effect("falls back to effect/config values when flags are omitted", () =>
33 Effect.gen(function* () {
34 const { join } = yield* Path.Path;
35 const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
36 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
37 const resolved = yield* resolveServerConfig(
38 {
39 mode: Option.none(),
40 port: Option.none(),
41 host: Option.none(),
42 baseDir: Option.none(),
43 cwd: Option.none(),
44 devUrl: Option.none(),
45 noBrowser: Option.none(),
46 bootstrapFd: Option.none(),
47 autoBootstrapProjectFromCwd: Option.none(),
48 logWebSocketEvents: Option.none(),
49 tailscaleServeEnabled: Option.none(),
50 tailscaleServePort: Option.none(),
51 },
52 Option.none(),
53 ).pipe(
54 Effect.provide(
55 Layer.mergeAll(
56 ConfigProvider.layer(
57 ConfigProvider.fromEnv({
58 env: {
59 T3CODE_LOG_LEVEL: "Warn",
60 T3CODE_MODE: "desktop",
61 T3CODE_PORT: "4001",
62 T3CODE_HOST: "0.0.0.0",
63 T3CODE_HOME: baseDir,
64 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
65 T3CODE_NO_BROWSER: "true",
66 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
67 T3CODE_LOG_WS_EVENTS: "true",
68 },
69 }),
70 ),
71 NetService.layer,
72 ),
73 ),
74 );
75
76 expect(resolved).toEqual({
77 logLevel: "Warn",
78 ...defaultObservabilityConfig,
79 mode: "desktop",
80 port: 4001,
81 cwd: process.cwd(),
82 baseDir,
83 ...derivedPaths,
84 host: "0.0.0.0",
85 staticDir: undefined,
86 devUrl: new URL("http://127.0.0.1:5173"),
87 noBrowser: true,
88 startupPresentation: "browser",
89 desktopBootstrapToken: undefined,
90 autoBootstrapProjectFromCwd: false,
91 logWebSocketEvents: true,
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
94 });
95 }),
96 );
97
98 it.effect("uses CLI flags when provided", () =>
99 Effect.gen(function* () {
100 const { join } = yield* Path.Path;
101 const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
102 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
103 const resolved = yield* resolveServerConfig(
104 {
105 mode: Option.some("web"),
106 port: Option.some(8788),
107 host: Option.some("127.0.0.1"),
108 baseDir: Option.some(baseDir),
109 cwd: Option.none(),
110 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
111 noBrowser: Option.some(true),
112 bootstrapFd: Option.none(),
113 autoBootstrapProjectFromCwd: Option.some(true),
114 logWebSocketEvents: Option.some(true),
115 tailscaleServeEnabled: Option.some(true),
116 tailscaleServePort: Option.some(8443),
117 },
118 Option.some("Debug"),
119 ).pipe(
120 Effect.provide(
121 Layer.mergeAll(
122 ConfigProvider.layer(
123 ConfigProvider.fromEnv({
124 env: {
125 T3CODE_LOG_LEVEL: "Warn",
126 T3CODE_MODE: "desktop",
127 T3CODE_PORT: "4001",
128 T3CODE_HOST: "0.0.0.0",
129 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
130 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
131 T3CODE_NO_BROWSER: "false",
132 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
133 T3CODE_LOG_WS_EVENTS: "false",
134 },
135 }),
136 ),
137 NetService.layer,
138 ),
139 ),
140 );
141
142 expect(resolved).toEqual({
143 logLevel: "Debug",
144 ...defaultObservabilityConfig,
145 mode: "web",
146 port: 8788,
147 cwd: process.cwd(),
148 baseDir,
149 ...derivedPaths,
150 host: "127.0.0.1",
151 staticDir: undefined,
152 devUrl: new URL("http://127.0.0.1:4173"),
153 noBrowser: true,
154 startupPresentation: "browser",
155 desktopBootstrapToken: undefined,
156 autoBootstrapProjectFromCwd: true,
157 logWebSocketEvents: true,
158 tailscaleServeEnabled: true,
159 tailscaleServePort: 8443,
160 });
161 }),
162 );
163
164 it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
165 Effect.gen(function* () {
166 const { join } = yield* Path.Path;
167 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
168 const fd = yield* openBootstrapFd({
169 noBrowser: true,
170 autoBootstrapProjectFromCwd: true,
171 logWebSocketEvents: true,
172 tailscaleServeEnabled: false,
173 tailscaleServePort: 443,
174 });
175 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
176
177 const resolved = yield* resolveServerConfig(
178 {
179 mode: Option.some("web"),
180 port: Option.some(8788),
181 host: Option.some("127.0.0.1"),
182 baseDir: Option.some(baseDir),
183 cwd: Option.none(),
184 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
185 noBrowser: Option.some(false),
186 bootstrapFd: Option.none(),
187 autoBootstrapProjectFromCwd: Option.some(false),
188 logWebSocketEvents: Option.some(false),
189 tailscaleServeEnabled: Option.none(),
190 tailscaleServePort: Option.none(),
191 },
192 Option.none(),
193 ).pipe(
194 Effect.provide(
195 Layer.mergeAll(
196 ConfigProvider.layer(
197 ConfigProvider.fromEnv({
198 env: {
199 T3CODE_BOOTSTRAP_FD: String(fd),
200 T3CODE_NO_BROWSER: "true",
201 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
202 T3CODE_LOG_WS_EVENTS: "true",
203 },
204 }),
205 ),
206 NetService.layer,
207 ),
208 ),
209 );
210
211 expect(resolved).toEqual({
212 logLevel: "Info",
213 ...defaultObservabilityConfig,
214 mode: "web",
215 port: 8788,
216 cwd: process.cwd(),
217 baseDir,
218 ...derivedPaths,
219 host: "127.0.0.1",
220 staticDir: undefined,
221 devUrl: new URL("http://127.0.0.1:4173"),
222 noBrowser: false,
223 startupPresentation: "browser",
224 desktopBootstrapToken: undefined,
225 autoBootstrapProjectFromCwd: false,
226 logWebSocketEvents: false,
227 tailscaleServeEnabled: false,
228 tailscaleServePort: 443,
229 });
230 }),
231 );
232
233 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
234 Effect.gen(function* () {
235 const { join } = yield* Path.Path;
236 const baseDir = "/tmp/t3-bootstrap-home";
237 const fd = yield* openBootstrapFd({
238 mode: "desktop",
239 port: 4888,
240 host: "127.0.0.2",
241 t3Home: baseDir,
242 devUrl: "http://127.0.0.1:5173",
243 noBrowser: true,
244 autoBootstrapProjectFromCwd: false,
245 logWebSocketEvents: true,
246 tailscaleServeEnabled: false,
247 tailscaleServePort: 443,
248 otlpTracesUrl: "http://localhost:4318/v1/traces",
249 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
250 });
251 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
252
253 const resolved = yield* resolveServerConfig(
254 {
255 mode: Option.none(),
256 port: Option.none(),
257 host: Option.none(),
258 baseDir: Option.none(),
259 cwd: Option.none(),
260 devUrl: Option.none(),
261 noBrowser: Option.none(),
262 bootstrapFd: Option.none(),
263 autoBootstrapProjectFromCwd: Option.none(),
264 logWebSocketEvents: Option.none(),
265 tailscaleServeEnabled: Option.none(),
266 tailscaleServePort: Option.none(),
267 },
268 Option.none(),
269 ).pipe(
270 Effect.provide(
271 Layer.mergeAll(
272 ConfigProvider.layer(
273 ConfigProvider.fromEnv({
274 env: {
275 T3CODE_BOOTSTRAP_FD: String(fd),
276 },
277 }),
278 ),
279 NetService.layer,
280 ),
281 ),
282 );
283
284 expect(resolved).toEqual({
285 logLevel: "Info",
286 ...defaultObservabilityConfig,
287 otlpTracesUrl: "http://localhost:4318/v1/traces",
288 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
289 mode: "desktop",
290 port: 4888,
291 cwd: process.cwd(),
292 baseDir,
293 ...derivedPaths,
294 host: "127.0.0.2",
295 staticDir: undefined,
296 devUrl: new URL("http://127.0.0.1:5173"),
297 noBrowser: true,
298 startupPresentation: "browser",
299 desktopBootstrapToken: undefined,
300 autoBootstrapProjectFromCwd: false,
301 logWebSocketEvents: true,
302 tailscaleServeEnabled: false,
303 tailscaleServePort: 443,
304 });
305 assert.equal(join(baseDir, "dev"), resolved.stateDir);
306 }),
307 );
308
309 it.effect("creates derived runtime directories during config resolution", () =>
310 Effect.gen(function* () {
311 const fs = yield* FileSystem.FileSystem;
312 const path = yield* Path.Path;
313 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" });
314 const customCwd = path.join(baseDir, "nested", "project");
315
316 const resolved = yield* resolveServerConfig(
317 {
318 mode: Option.some("desktop"),
319 port: Option.some(4888),
320 host: Option.none(),
321 baseDir: Option.some(baseDir),
322 cwd: Option.some(customCwd),
323 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
324 noBrowser: Option.none(),
325 bootstrapFd: Option.none(),
326 autoBootstrapProjectFromCwd: Option.none(),
327 logWebSocketEvents: Option.none(),
328 tailscaleServeEnabled: Option.none(),
329 tailscaleServePort: Option.none(),
330 },
331 Option.none(),
332 ).pipe(
333 Effect.provide(
334 Layer.mergeAll(
335 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
336 NetService.layer,
337 ),
338 ),
339 );
340
341 for (const directory of [
342 customCwd,
343 resolved.stateDir,
344 resolved.logsDir,
345 resolved.providerLogsDir,
346 resolved.terminalLogsDir,
347 resolved.attachmentsDir,
348 resolved.worktreesDir,
349 path.dirname(resolved.serverLogPath),
350 path.dirname(resolved.serverTracePath),
351 ]) {
352 expect(yield* fs.exists(directory)).toBe(true);
353 }
354 expect(resolved.cwd).toBe(path.resolve(customCwd));
355 }),
356 );
357
358 it.effect("applies flag then env precedence over bootstrap envelope values", () =>
359 Effect.gen(function* () {
360 const { join } = yield* Path.Path;
361 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
362 const fd = yield* openBootstrapFd({
363 mode: "desktop",
364 port: 4888,
365 host: "127.0.0.2",
366 t3Home: "/tmp/t3-bootstrap-home",
367 devUrl: "http://127.0.0.1:5173",
368 noBrowser: false,
369 autoBootstrapProjectFromCwd: false,
370 logWebSocketEvents: false,
371 tailscaleServeEnabled: false,
372 tailscaleServePort: 443,
373 });
374 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
375
376 const resolved = yield* resolveServerConfig(
377 {
378 mode: Option.none(),
379 port: Option.some(8788),
380 host: Option.some("127.0.0.1"),
381 baseDir: Option.none(),
382 cwd: Option.none(),
383 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
384 noBrowser: Option.none(),
385 bootstrapFd: Option.none(),
386 autoBootstrapProjectFromCwd: Option.none(),
387 logWebSocketEvents: Option.none(),
388 tailscaleServeEnabled: Option.none(),
389 tailscaleServePort: Option.none(),
390 },
391 Option.some("Debug"),
392 ).pipe(
393 Effect.provide(
394 Layer.mergeAll(
395 ConfigProvider.layer(
396 ConfigProvider.fromEnv({
397 env: {
398 T3CODE_MODE: "web",
399 T3CODE_BOOTSTRAP_FD: String(fd),
400 T3CODE_HOME: baseDir,
401 T3CODE_NO_BROWSER: "true",
402 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
403 T3CODE_LOG_WS_EVENTS: "true",
404 },
405 }),
406 ),
407 NetService.layer,
408 ),
409 ),
410 );
411
412 expect(resolved).toEqual({
413 logLevel: "Debug",
414 ...defaultObservabilityConfig,
415 mode: "web",
416 port: 8788,
417 cwd: process.cwd(),
418 baseDir,
419 ...derivedPaths,
420 host: "127.0.0.1",
421 staticDir: undefined,
422 devUrl: new URL("http://127.0.0.1:4173"),
423 noBrowser: true,
424 startupPresentation: "browser",
425 desktopBootstrapToken: undefined,
426 autoBootstrapProjectFromCwd: true,
427 logWebSocketEvents: true,
428 tailscaleServeEnabled: false,
429 tailscaleServePort: 443,
430 });
431 }),
432 );
433
434 it.effect("falls back to persisted observability settings when env vars are absent", () =>
435 Effect.gen(function* () {
436 const fs = yield* FileSystem.FileSystem;
437 const path = yield* Path.Path;
438 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
439 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
440 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
441 yield* fs.writeFileString(
442 derivedPaths.settingsPath,
443 `${JSON.stringify({
444 observability: {
445 otlpTracesUrl: "http://localhost:4318/v1/traces",
446 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
447 },
448 })}\n`,
449 );
450
451 const resolved = yield* resolveServerConfig(
452 {
453 mode: Option.some("desktop"),
454 port: Option.some(4888),
455 host: Option.none(),
456 baseDir: Option.some(baseDir),
457 cwd: Option.none(),
458 devUrl: Option.none(),
459 noBrowser: Option.none(),
460 bootstrapFd: Option.none(),
461 autoBootstrapProjectFromCwd: Option.none(),
462 logWebSocketEvents: Option.none(),
463 tailscaleServeEnabled: Option.none(),
464 tailscaleServePort: Option.none(),
465 },
466 Option.none(),
467 ).pipe(
468 Effect.provide(
469 Layer.mergeAll(
470 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
471 NetService.layer,
472 ),
473 ),
474 );
475
476 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
477 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
478 expect(resolved).toEqual({
479 logLevel: "Info",
480 ...defaultObservabilityConfig,
481 otlpTracesUrl: "http://localhost:4318/v1/traces",
482 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
483 mode: "desktop",
484 port: 4888,
485 cwd: process.cwd(),
486 baseDir,
487 ...derivedPaths,
488 host: "127.0.0.1",
489 staticDir: resolved.staticDir,
490 devUrl: undefined,
491 noBrowser: true,
492 startupPresentation: "browser",
493 desktopBootstrapToken: undefined,
494 autoBootstrapProjectFromCwd: false,
495 logWebSocketEvents: false,
496 tailscaleServeEnabled: false,
497 tailscaleServePort: 443,
498 });
499 }),
500 );
501
502 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
503 Effect.gen(function* () {
504 const { join } = yield* Path.Path;
505 const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
506 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
507
508 const resolved = yield* resolveServerConfig(
509 {
510 mode: Option.some("web"),
511 port: Option.some(3773),
512 host: Option.none(),
513 baseDir: Option.some(baseDir),
514 cwd: Option.none(),
515 devUrl: Option.none(),
516 noBrowser: Option.none(),
517 bootstrapFd: Option.none(),
518 autoBootstrapProjectFromCwd: Option.none(),
519 logWebSocketEvents: Option.none(),
520 tailscaleServeEnabled: Option.none(),
521 tailscaleServePort: Option.none(),
522 },
523 Option.none(),
524 {
525 startupPresentation: "headless",
526 },
527 ).pipe(
528 Effect.provide(
529 Layer.mergeAll(
530 ConfigProvider.layer(
531 ConfigProvider.fromEnv({
532 env: {
533 T3CODE_NO_BROWSER: "false",
534 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
535 },
536 }),
537 ),
538 NetService.layer,
539 ),
540 ),
541 );
542
543 expect(resolved).toEqual({
544 logLevel: "Info",
545 ...defaultObservabilityConfig,
546 mode: "web",
547 port: 3773,
548 cwd: process.cwd(),
549 baseDir,
550 ...derivedPaths,
551 host: undefined,
552 staticDir: resolved.staticDir,
553 devUrl: undefined,
554 noBrowser: true,
555 startupPresentation: "headless",
556 desktopBootstrapToken: undefined,
557 autoBootstrapProjectFromCwd: false,
558 logWebSocketEvents: false,
559 tailscaleServeEnabled: false,
560 tailscaleServePort: 443,
561 });
562 }),
563 );
564});
apps/server/src/cli/config.ts created+490
...@@ -0,0 +1,490 @@
1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import {
4 Config,
5 Duration,
6 Effect,
7 FileSystem,
8 LogLevel,
9 Option,
10 Path,
11 Schema,
12 SchemaIssue,
13 SchemaTransformation,
14} from "effect";
15import { Argument, Flag } from "effect/unstable/cli";
16
17import { readBootstrapEnvelope } from "../bootstrap.ts";
18import {
19 DEFAULT_PORT,
20 deriveServerPaths,
21 ensureServerDirectories,
22 resolveStaticDir,
23 RuntimeMode,
24 type ServerConfigShape,
25 type StartupPresentation,
26} from "../config.ts";
27import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
28
29export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
30
31const BootstrapEnvelopeSchema = Schema.Struct({
32 mode: Schema.optional(RuntimeMode),
33 port: Schema.optional(PortSchema),
34 host: Schema.optional(Schema.String),
35 t3Home: Schema.optional(Schema.String),
36 devUrl: Schema.optional(Schema.URLFromString),
37 noBrowser: Schema.optional(Schema.Boolean),
38 desktopBootstrapToken: Schema.optional(Schema.String),
39 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
40 logWebSocketEvents: Schema.optional(Schema.Boolean),
41 tailscaleServeEnabled: Schema.optional(Schema.Boolean),
42 tailscaleServePort: Schema.optional(PortSchema),
43 otlpTracesUrl: Schema.optional(Schema.String),
44 otlpMetricsUrl: Schema.optional(Schema.String),
45});
46
47export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
48 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
49 Flag.optional,
50);
51export const portFlag = Flag.integer("port").pipe(
52 Flag.withSchema(PortSchema),
53 Flag.withDescription("Port for the HTTP/WebSocket server."),
54 Flag.optional,
55);
56export const hostFlag = Flag.string("host").pipe(
57 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
58 Flag.optional,
59);
60export const baseDirFlag = Flag.string("base-dir").pipe(
61 Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
62 Flag.optional,
63);
64export const devUrlFlag = Flag.string("dev-url").pipe(
65 Flag.withSchema(Schema.URLFromString),
66 Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
67 Flag.optional,
68);
69export const noBrowserFlag = Flag.boolean("no-browser").pipe(
70 Flag.withDescription("Disable automatic browser opening."),
71 Flag.optional,
72);
73export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
74 Flag.withSchema(Schema.Int),
75 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
76 Flag.optional,
77);
78export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
79 Flag.withDescription(
80 "Create a project for the current working directory on startup when missing.",
81 ),
82 Flag.optional,
83);
84export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
85 Flag.withDescription(
86 "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
87 ),
88 Flag.withAlias("log-ws-events"),
89 Flag.optional,
90);
91export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe(
92 Flag.withDescription(
93 "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.",
94 ),
95 Flag.optional,
96);
97export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
98 Flag.withSchema(PortSchema),
99 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
100 Flag.optional,
101);
102
103const EnvServerConfig = Config.all({
104 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
105 traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")),
106 traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)),
107 traceFile: Config.string("T3CODE_TRACE_FILE").pipe(
108 Config.option,
109 Config.map(Option.getOrUndefined),
110 ),
111 traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
112 traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)),
113 traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)),
114 otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe(
115 Config.option,
116 Config.map(Option.getOrUndefined),
117 ),
118 otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe(
119 Config.option,
120 Config.map(Option.getOrUndefined),
121 ),
122 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
123 Config.withDefault(10_000),
124 ),
125 otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),
126 mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe(
127 Config.option,
128 Config.map(Option.getOrUndefined),
129 ),
130 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
131 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
132 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
133 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
134 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
135 Config.option,
136 Config.map(Option.getOrUndefined),
137 ),
138 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
139 Config.option,
140 Config.map(Option.getOrUndefined),
141 ),
142 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
143 Config.option,
144 Config.map(Option.getOrUndefined),
145 ),
146 logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
147 Config.option,
148 Config.map(Option.getOrUndefined),
149 ),
150 tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe(
151 Config.option,
152 Config.map(Option.getOrUndefined),
153 ),
154 tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe(
155 Config.option,
156 Config.map(Option.getOrUndefined),
157 ),
158});
159
160export interface CliServerFlags {
161 readonly mode: Option.Option<RuntimeMode>;
162 readonly port: Option.Option<number>;
163 readonly host: Option.Option<string>;
164 readonly baseDir: Option.Option<string>;
165 readonly cwd: Option.Option<string>;
166 readonly devUrl: Option.Option<URL>;
167 readonly noBrowser: Option.Option<boolean>;
168 readonly bootstrapFd: Option.Option<number>;
169 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
170 readonly logWebSocketEvents: Option.Option<boolean>;
171 readonly tailscaleServeEnabled: Option.Option<boolean>;
172 readonly tailscaleServePort: Option.Option<number>;
173}
174
175export interface CliAuthLocationFlags {
176 readonly baseDir: Option.Option<string>;
177 readonly devUrl?: Option.Option<URL>;
178}
179
180export const sharedServerLocationFlags = {
181 baseDir: baseDirFlag,
182 devUrl: devUrlFlag,
183} as const;
184
185export const projectLocationFlags = {
186 baseDir: baseDirFlag,
187} as const;
188
189export const sharedServerCommandFlags = {
190 mode: modeFlag,
191 port: portFlag,
192 host: hostFlag,
193 baseDir: baseDirFlag,
194 cwd: Argument.string("cwd").pipe(
195 Argument.withDescription(
196 "Working directory for provider sessions (defaults to the current directory).",
197 ),
198 Argument.optional,
199 ),
200 devUrl: devUrlFlag,
201 noBrowser: noBrowserFlag,
202 bootstrapFd: bootstrapFdFlag,
203 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
204 logWebSocketEvents: logWebSocketEventsFlag,
205 tailscaleServeEnabled: tailscaleServeFlag,
206 tailscaleServePort: tailscaleServePortFlag,
207} as const;
208
209export const authLocationFlags = sharedServerLocationFlags;
210
211const resolveOptionPrecedence = <Value>(
212 ...values: ReadonlyArray<Option.Option<Value>>
213): Option.Option<Value> => Option.firstSomeOf(values);
214
215const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
216 const fs = yield* FileSystem.FileSystem;
217 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
218 if (!exists) {
219 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
220 }
221
222 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
223 return parsePersistedServerObservabilitySettings(raw);
224});
225
226export const resolveServerConfig = (
227 flags: CliServerFlags,
228 cliLogLevel: Option.Option<LogLevel.LogLevel>,
229 options?: {
230 readonly startupPresentation?: StartupPresentation;
231 readonly forceAutoBootstrapProjectFromCwd?: boolean;
232 },
233) =>
234 Effect.gen(function* () {
235 const { findAvailablePort } = yield* NetService;
236 const path = yield* Path.Path;
237 const fs = yield* FileSystem.FileSystem;
238 const env = yield* EnvServerConfig;
239 const normalizedFlags = {
240 mode: flags.mode ?? Option.none(),
241 port: flags.port ?? Option.none(),
242 host: flags.host ?? Option.none(),
243 baseDir: flags.baseDir ?? Option.none(),
244 cwd: flags.cwd ?? Option.none(),
245 devUrl: flags.devUrl ?? Option.none(),
246 noBrowser: flags.noBrowser ?? Option.none(),
247 bootstrapFd: flags.bootstrapFd ?? Option.none(),
248 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
249 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
250 tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
251 tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
252 } satisfies CliServerFlags;
253 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
254 const bootstrapEnvelope =
255 bootstrapFd !== undefined
256 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
257 : Option.none();
258 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
259
260 const mode: RuntimeMode = Option.getOrElse(
261 resolveOptionPrecedence(
262 normalizedFlags.mode,
263 Option.fromUndefinedOr(env.mode),
264 Option.fromUndefinedOr(bootstrap?.mode),
265 ),
266 () => "web",
267 );
268
269 const port = yield* Option.match(
270 resolveOptionPrecedence(
271 normalizedFlags.port,
272 Option.fromUndefinedOr(env.port),
273 Option.fromUndefinedOr(bootstrap?.port),
274 ),
275 {
276 onSome: (value) => Effect.succeed(value),
277 onNone: () => {
278 if (mode === "desktop") {
279 return Effect.succeed(DEFAULT_PORT);
280 }
281 return findAvailablePort(DEFAULT_PORT);
282 },
283 },
284 );
285 const devUrl = Option.getOrElse(
286 resolveOptionPrecedence(
287 normalizedFlags.devUrl,
288 Option.fromUndefinedOr(env.devUrl),
289 Option.fromUndefinedOr(bootstrap?.devUrl),
290 ),
291 () => undefined,
292 );
293 const baseDir = yield* resolveBaseDir(
294 Option.getOrUndefined(
295 resolveOptionPrecedence(
296 normalizedFlags.baseDir,
297 Option.fromUndefinedOr(env.t3Home),
298 Option.fromUndefinedOr(bootstrap?.t3Home),
299 ),
300 ),
301 );
302 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
303 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
304 yield* fs.makeDirectory(cwd, { recursive: true });
305 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
306 yield* ensureServerDirectories(derivedPaths);
307 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
308 derivedPaths.settingsPath,
309 );
310 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
311 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
312 const startupPresentation = options?.startupPresentation ?? "browser";
313 const isHeadlessStartup = startupPresentation === "headless";
314 const noBrowser = Option.getOrElse(
315 resolveOptionPrecedence(
316 isHeadlessStartup ? Option.some(true) : Option.none(),
317 normalizedFlags.noBrowser,
318 Option.fromUndefinedOr(env.noBrowser),
319 Option.fromUndefinedOr(bootstrap?.noBrowser),
320 ),
321 () => mode === "desktop",
322 );
323 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
324 const autoBootstrapProjectFromCwd = Option.getOrElse(
325 resolveOptionPrecedence(
326 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
327 isHeadlessStartup ? Option.some(false) : Option.none(),
328 normalizedFlags.autoBootstrapProjectFromCwd,
329 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
330 Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
331 ),
332 () => mode === "web",
333 );
334 const logWebSocketEvents = Option.getOrElse(
335 resolveOptionPrecedence(
336 normalizedFlags.logWebSocketEvents,
337 Option.fromUndefinedOr(env.logWebSocketEvents),
338 Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
339 ),
340 () => Boolean(devUrl),
341 );
342 const tailscaleServeEnabled = Option.getOrElse(
343 resolveOptionPrecedence(
344 normalizedFlags.tailscaleServeEnabled,
345 Option.fromUndefinedOr(env.tailscaleServeEnabled),
346 Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled),
347 ),
348 () => false,
349 );
350 const tailscaleServePort = Option.getOrElse(
351 resolveOptionPrecedence(
352 normalizedFlags.tailscaleServePort,
353 Option.fromUndefinedOr(env.tailscaleServePort),
354 Option.fromUndefinedOr(bootstrap?.tailscaleServePort),
355 ),
356 () => 443,
357 );
358 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
359 const host = Option.getOrElse(
360 resolveOptionPrecedence(
361 normalizedFlags.host,
362 Option.fromUndefinedOr(env.host),
363 Option.fromUndefinedOr(bootstrap?.host),
364 ),
365 () => (mode === "desktop" ? "127.0.0.1" : undefined),
366 );
367 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
368
369 const config: ServerConfigShape = {
370 logLevel,
371 traceMinLevel: env.traceMinLevel,
372 traceTimingEnabled: env.traceTimingEnabled,
373 traceBatchWindowMs: env.traceBatchWindowMs,
374 traceMaxBytes: env.traceMaxBytes,
375 traceMaxFiles: env.traceMaxFiles,
376 otlpTracesUrl:
377 env.otlpTracesUrl ??
378 bootstrap?.otlpTracesUrl ??
379 persistedObservabilitySettings.otlpTracesUrl,
380 otlpMetricsUrl:
381 env.otlpMetricsUrl ??
382 bootstrap?.otlpMetricsUrl ??
383 persistedObservabilitySettings.otlpMetricsUrl,
384 otlpExportIntervalMs: env.otlpExportIntervalMs,
385 otlpServiceName: env.otlpServiceName,
386 mode,
387 port,
388 cwd,
389 baseDir,
390 ...derivedPaths,
391 serverTracePath,
392 host,
393 staticDir,
394 devUrl,
395 noBrowser,
396 startupPresentation,
397 desktopBootstrapToken,
398 autoBootstrapProjectFromCwd,
399 logWebSocketEvents,
400 tailscaleServeEnabled,
401 tailscaleServePort,
402 };
403
404 return config;
405 });
406
407export const resolveCliAuthConfig = (
408 flags: CliAuthLocationFlags,
409 cliLogLevel: Option.Option<LogLevel.LogLevel>,
410) =>
411 resolveServerConfig(
412 {
413 mode: Option.none(),
414 port: Option.none(),
415 host: Option.none(),
416 baseDir: flags.baseDir,
417 cwd: Option.none(),
418 devUrl: flags.devUrl ?? Option.none(),
419 noBrowser: Option.none(),
420 bootstrapFd: Option.none(),
421 autoBootstrapProjectFromCwd: Option.none(),
422 logWebSocketEvents: Option.none(),
423 tailscaleServeEnabled: Option.none(),
424 tailscaleServePort: Option.none(),
425 },
426 cliLogLevel,
427 );
428
429const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
430
431const parseDurationInput = (value: string): Duration.Duration | null => {
432 const trimmed = value.trim();
433 if (trimmed.length === 0) return null;
434
435 const shorthand = DurationShorthandPattern.exec(trimmed);
436 const normalizedInput = shorthand?.groups
437 ? (() => {
438 const amountText = shorthand.groups.value;
439 const unitText = shorthand.groups.unit;
440 if (typeof amountText !== "string" || typeof unitText !== "string") {
441 return null;
442 }
443
444 const amount = Number.parseInt(amountText, 10);
445 if (!Number.isFinite(amount)) return null;
446
447 switch (unitText.toLowerCase()) {
448 case "ms":
449 return `${amount} millis`;
450 case "s":
451 return `${amount} seconds`;
452 case "m":
453 return `${amount} minutes`;
454 case "h":
455 return `${amount} hours`;
456 case "d":
457 return `${amount} days`;
458 case "w":
459 return `${amount} weeks`;
460 default:
461 return null;
462 }
463 })()
464 : (trimmed as Duration.Input);
465
466 if (normalizedInput === null) return null;
467
468 const decoded = Duration.fromInput(normalizedInput as Duration.Input);
469 return Option.isSome(decoded) ? decoded.value : null;
470};
471
472export const DurationFromString = Schema.String.pipe(
473 Schema.decodeTo(
474 Schema.Duration,
475 SchemaTransformation.transformOrFail({
476 decode: (value) => {
477 const duration = parseDurationInput(value);
478 if (duration !== null) {
479 return Effect.succeed(duration);
480 }
481 return Effect.fail(
482 new SchemaIssue.InvalidValue(Option.some(value), {
483 message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
484 }),
485 );
486 },
487 encode: (duration) => Effect.succeed(Duration.format(duration)),
488 }),
489 ),
490);
apps/server/src/cli/project.ts created+434
...@@ -0,0 +1,434 @@
1import {
2 CommandId,
3 OrchestrationReadModel,
4 ProjectId,
5 type ClientOrchestrationCommand,
6} from "@t3tools/contracts";
7import {
8 Console,
9 Duration,
10 Effect,
11 Exit,
12 FileSystem,
13 Layer,
14 Option,
15 Path,
16 References,
17 Schema,
18} from "effect";
19import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
20import {
21 FetchHttpClient,
22 HttpClient,
23 HttpClientRequest,
24 HttpClientResponse,
25} from "effect/unstable/http";
26
27import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";
28import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";
29import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
30import { ServerConfig, type ServerConfigShape } from "../config.ts";
31import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
32import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
33import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
34import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
35import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts";
36import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts";
37import {
38 clearPersistedServerRuntimeState,
39 readPersistedServerRuntimeState,
40} from "../serverRuntimeState.ts";
41import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
42import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
43import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
44
45type ProjectMutationTarget = {
46 readonly id: ProjectId;
47 readonly title: string;
48 readonly workspaceRoot: string;
49};
50
51type ProjectCommandExecutionMode = "live" | "offline";
52type ProjectCliDispatchCommand = Extract<
53 ClientOrchestrationCommand,
54 { type: "project.create" | "project.meta.update" | "project.delete" }
55>;
56
57const ProjectCliRuntimeLive = Layer.mergeAll(
58 WorkspacePathsLive,
59 OrchestrationLayerLive.pipe(
60 Layer.provideMerge(RepositoryIdentityResolverLive),
61 Layer.provideMerge(SqlitePersistenceLayerLive),
62 ),
63);
64
65const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
66const OrchestrationHttpErrorResponse = Schema.Struct({
67 error: Schema.String,
68});
69
70const withProjectCliSessionToken = <A, E, R>(
71 authControlPlane: AuthControlPlaneShape,
72 run: (token: string) => Effect.Effect<A, E, R>,
73) =>
74 Effect.acquireUseRelease(
75 authControlPlane.issueSession({
76 role: "owner",
77 label: "t3 project cli",
78 }),
79 (issued) => run(issued.token),
80 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
81 );
82
83const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
84 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
85
86const runLiveServerRequest = <A, E extends Error, R>(
87 request: HttpClientRequest.HttpClientRequest,
88 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
89) =>
90 Effect.gen(function* () {
91 const httpClient = yield* HttpClient.HttpClient;
92 const response = yield* httpClient.execute(request);
93 return yield* handle(response);
94 }).pipe(withProjectCliLiveServerTimeout);
95
96const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
97 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
98
99const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
100 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
101 Effect.map((body) => body.error),
102 Effect.catch(() => Effect.succeed(null)),
103 Effect.map((body) => {
104 if (typeof body === "string" && body.trim().length > 0) {
105 return body;
106 }
107 return `Server request failed with status ${response.status}.`;
108 }),
109 );
110
111const normalizeWorkspaceRootForProjectCommand = Effect.fn(
112 "normalizeWorkspaceRootForProjectCommand",
113)(function* (workspaceRoot: string) {
114 const workspacePaths = yield* WorkspacePaths;
115 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
116});
117
118const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
119 workspaceRoot: string,
120 explicitTitle?: string,
121) {
122 if (explicitTitle !== undefined) {
123 const trimmed = explicitTitle.trim();
124 if (trimmed.length > 0) {
125 return trimmed;
126 }
127 return yield* Effect.fail(new Error("Project title cannot be empty."));
128 }
129
130 const path = yield* Path.Path;
131 const basename = path.basename(workspaceRoot).trim();
132 return basename.length > 0 ? basename : "project";
133});
134
135const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
136 readonly snapshot: OrchestrationReadModel;
137 readonly identifier: string;
138}) {
139 const trimmedIdentifier = input.identifier.trim();
140 if (trimmedIdentifier.length === 0) {
141 return yield* Effect.fail(new Error("Project identifier cannot be empty."));
142 }
143
144 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
145 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
146 if (exactIdMatch) {
147 return {
148 id: exactIdMatch.id,
149 title: exactIdMatch.title,
150 workspaceRoot: exactIdMatch.workspaceRoot,
151 } satisfies ProjectMutationTarget;
152 }
153
154 const normalizedWorkspaceRootResult = yield* Effect.exit(
155 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
156 );
157 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
158 ? normalizedWorkspaceRootResult.value
159 : null;
160
161 const exactWorkspaceMatch =
162 normalizedWorkspaceRoot === null
163 ? undefined
164 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
165
166 const resolved = exactWorkspaceMatch;
167 if (!resolved) {
168 return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
169 }
170
171 return {
172 id: resolved.id,
173 title: resolved.title,
174 workspaceRoot: resolved.workspaceRoot,
175 } satisfies ProjectMutationTarget;
176});
177
178const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
179 runLiveServerRequest(
180 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
181 HttpClientRequest.acceptJson,
182 HttpClientRequest.bearerToken(bearerToken),
183 ),
184 HttpClientResponse.matchStatus({
185 "2xx": decodeOrchestrationReadModelResponse,
186 orElse: (response) =>
187 readErrorMessageFromResponse(response).pipe(
188 Effect.flatMap((message) => Effect.fail(new Error(message))),
189 ),
190 }),
191 );
192
193const dispatchLiveOrchestrationCommand = (
194 origin: string,
195 bearerToken: string,
196 command: ProjectCliDispatchCommand,
197) =>
198 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
199 HttpClientRequest.acceptJson,
200 HttpClientRequest.bearerToken(bearerToken),
201 HttpClientRequest.bodyJson(command),
202 Effect.flatMap((request) =>
203 runLiveServerRequest(
204 request,
205 HttpClientResponse.matchStatus({
206 "2xx": () => Effect.void,
207 orElse: (response) =>
208 readErrorMessageFromResponse(response).pipe(
209 Effect.flatMap((message) => Effect.fail(new Error(message))),
210 ),
211 }),
212 ),
213 ),
214 );
215
216const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
217 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
218 return yield* projectionSnapshotQuery.getSnapshot();
219});
220
221const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
222 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
223 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
224 if (Option.isNone(runtimeState)) {
225 return Option.none<{ readonly origin: string }>();
226 }
227
228 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
229 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
230 Effect.as({
231 origin: runtimeState.value.origin,
232 }),
233 ),
234 );
235
236 const attempted = yield* Effect.exit(attempt);
237 if (Exit.isSuccess(attempted)) {
238 return Option.some(attempted.value);
239 }
240
241 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
242 return Option.none<{ readonly origin: string }>();
243 },
244);
245
246const runProjectMutation = Effect.fn("runProjectMutation")(function* (
247 flags: CliAuthLocationFlags,
248 run: (input: {
249 readonly snapshot: OrchestrationReadModel;
250 readonly dispatch: (
251 command: ProjectCliDispatchCommand,
252 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
253 readonly mode: ProjectCommandExecutionMode;
254 }) => Effect.Effect<
255 string,
256 Error,
257 FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
258 >,
259) {
260 const logLevel = yield* GlobalFlag.LogLevel;
261 const config = yield* resolveCliAuthConfig(flags, logLevel);
262 const minimumLogLevel = config.logLevel;
263
264 return yield* Effect.gen(function* () {
265 const authControlPlane = yield* AuthControlPlane;
266 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
267
268 if (Option.isSome(liveMode)) {
269 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
270 Effect.gen(function* () {
271 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
272 const output = yield* run({
273 snapshot,
274 dispatch: (command) =>
275 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
276 mode: "live",
277 });
278 yield* Console.log(output);
279 }),
280 );
281 }
282
283 const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
284 Layer.provide(Layer.succeed(ServerConfig, config)),
285 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
286 );
287
288 return yield* Effect.gen(function* () {
289 const snapshot = yield* getOfflineSnapshot();
290 const orchestrationEngine = yield* OrchestrationEngineService;
291 const output = yield* run({
292 snapshot,
293 dispatch: (command) => orchestrationEngine.dispatch(command),
294 mode: "offline",
295 });
296 yield* Console.log(output);
297 }).pipe(Effect.provide(offlineRuntimeLayer));
298 }).pipe(
299 Effect.provide(
300 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
301 Layer.provideMerge(FetchHttpClient.layer),
302 Layer.provide(Layer.succeed(ServerConfig, config)),
303 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
304 ),
305 ),
306 );
307});
308
309const projectAddCommand = Command.make("add", {
310 ...projectLocationFlags,
311 workspaceRoot: Argument.string("path").pipe(
312 Argument.withDescription("Workspace root to add as a project."),
313 ),
314 title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
315}).pipe(
316 Command.withDescription("Add a project."),
317 Command.withHandler((flags) =>
318 runProjectMutation(
319 flags,
320 Effect.fn("projectAddMutation")(function* ({
321 snapshot,
322 dispatch,
323 }: {
324 readonly snapshot: OrchestrationReadModel;
325 readonly dispatch: (
326 command: ProjectCliDispatchCommand,
327 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
328 }) {
329 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
330 const existingProject = snapshot.projects.find(
331 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
332 );
333 if (existingProject) {
334 return yield* Effect.fail(
335 new Error(`An active project already exists for '${workspaceRoot}'.`),
336 );
337 }
338
339 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
340 const projectId = ProjectId.make(crypto.randomUUID());
341 yield* dispatch({
342 type: "project.create",
343 commandId: CommandId.make(crypto.randomUUID()),
344 projectId,
345 title,
346 workspaceRoot,
347 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
348 createdAt: new Date().toISOString(),
349 });
350 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
351 }),
352 ),
353 ),
354);
355
356const projectRemoveCommand = Command.make("remove", {
357 ...projectLocationFlags,
358 project: Argument.string("project").pipe(
359 Argument.withDescription("Project id or workspace root to remove."),
360 ),
361}).pipe(
362 Command.withDescription("Remove a project."),
363 Command.withHandler((flags) =>
364 runProjectMutation(
365 flags,
366 Effect.fn("projectRemoveMutation")(function* ({
367 snapshot,
368 dispatch,
369 }: {
370 readonly snapshot: OrchestrationReadModel;
371 readonly dispatch: (
372 command: ProjectCliDispatchCommand,
373 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
374 }) {
375 const project = yield* findActiveProjectTarget({
376 snapshot,
377 identifier: flags.project,
378 });
379 yield* dispatch({
380 type: "project.delete",
381 commandId: CommandId.make(crypto.randomUUID()),
382 projectId: project.id,
383 });
384 return `Removed project ${project.id} (${project.title}).`;
385 }),
386 ),
387 ),
388);
389
390const projectRenameCommand = Command.make("rename", {
391 ...projectLocationFlags,
392 project: Argument.string("project").pipe(
393 Argument.withDescription("Project id or workspace root to rename."),
394 ),
395 title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
396}).pipe(
397 Command.withDescription("Rename a project."),
398 Command.withHandler((flags) =>
399 runProjectMutation(
400 flags,
401 Effect.fn("projectRenameMutation")(function* ({
402 snapshot,
403 dispatch,
404 }: {
405 readonly snapshot: OrchestrationReadModel;
406 readonly dispatch: (
407 command: ProjectCliDispatchCommand,
408 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
409 }) {
410 const project = yield* findActiveProjectTarget({
411 snapshot,
412 identifier: flags.project,
413 });
414 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
415 if (nextTitle === project.title) {
416 return `Project ${project.id} is already named ${nextTitle}.`;
417 }
418
419 yield* dispatch({
420 type: "project.meta.update",
421 commandId: CommandId.make(crypto.randomUUID()),
422 projectId: project.id,
423 title: nextTitle,
424 });
425 return `Renamed project ${project.id} to ${nextTitle}.`;
426 }),
427 ),
428 ),
429);
430
431export const projectCommand = Command.make("project").pipe(
432 Command.withDescription("Manage projects."),
433 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
434);
apps/server/src/cli/server.ts created+36
...@@ -0,0 +1,36 @@
1import { Effect } from "effect";
2import { Command, GlobalFlag } from "effect/unstable/cli";
3
4import { ServerConfig, type StartupPresentation } from "../config.ts";
5import { runServer } from "../server.ts";
6import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";
7
8export const runServerCommand = (
9 flags: CliServerFlags,
10 options?: {
11 readonly startupPresentation?: StartupPresentation;
12 readonly forceAutoBootstrapProjectFromCwd?: boolean;
13 },
14) =>
15 Effect.gen(function* () {
16 const logLevel = yield* GlobalFlag.LogLevel;
17 const config = yield* resolveServerConfig(flags, logLevel, options);
18 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
19 });
20
21export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
22 Command.withDescription("Run the T3 Code server."),
23 Command.withHandler((flags) => runServerCommand(flags)),
24);
25
26export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
27 Command.withDescription(
28 "Run the T3 Code server without opening a browser and print headless pairing details.",
29 ),
30 Command.withHandler((flags) =>
31 runServerCommand(flags, {
32 startupPresentation: "headless",
33 forceAutoBootstrapProjectFromCwd: false,
34 }),
35 ),
36);