authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-19 11:57:58+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-19 11:57:58+02:00
log4f11740510d9040475936a24b08f60852bf5fdc4
tree80b8970a50da6c1692e9873128a29071d7b37693
parent1735e27d9e5106bbb35d5b1dd10363604a54b69e
signature Signed by PGP key B5690EEEBB952194

Add isolated app testing workflow (#4121)

Co-authored-by: codex <codex@users.noreply.github.com>

15 files changed, 711 insertions(+), 87 deletions(-)

.agents/skills/test-t3-app/SKILL.md created+69
......@@ -0,0 +1,69 @@
1---
2name: test-t3-app
3description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite.
4---
5
6# Test T3 App
7
8## Start an isolated web environment
9
101. Run commands from the repository root.
112. Choose a base directory that belongs only to the current worktree or test:
12 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
13 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
143. Start the full web stack with `vp run dev --home-dir <base-dir>`.
154. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
16
17Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.
18
19The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
20
21## Authenticate the browser on the first navigation
22
231. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`.
242. Use the controlled in-app browser or browser-automation surface available to the agent. Do not use a system-browser launch command during automated testing.
253. Open that complete URL exactly once as the controlled browser's first navigation. Preserve the fragment and token verbatim.
264. Wait for the pairing exchange and redirect to finish before navigating elsewhere.
275. Continue in the same browser context so its stored bearer session remains available.
28
29Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it.
30
31## Recover a consumed or expired pairing token
32
33Create another token against the same database and web URL as the running dev server:
34
35```bash
36T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
37 --base-dir <base-dir> \
38 --dev-url <web-url> \
39 --base-url <web-url> \
40 --ttl 15m \
41 --label agent-ui-test
42```
43
44Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port.
45
46Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
47
48## Inspect or seed SQLite state
49
50Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database.
51
52- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks.
53- Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory.
54- Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness.
55- Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions.
56
57The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation.
58
59## Finish the test
60
61Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous.
62
63## Troubleshoot predictably
64
65- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL.
66- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`.
67- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL.
68- If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything.
69- If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`.
.agents/skills/test-t3-app/agents/openai.yaml created+4
......@@ -0,0 +1,4 @@
1interface:
2 display_name: "T3 App Testing"
3 short_description: "Launch and seed isolated T3 test environments"
4 default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser."
.agents/skills/test-t3-app/references/sqlite-fixtures.md created+58
......@@ -0,0 +1,58 @@
1# SQLite fixtures
2
3Load this reference only when inspecting or seeding local T3 state directly.
4
5## Select the correct database
6
7When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata` and the database path is `<base-dir>/userdata/state.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.
8
9Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection.
10
11## Use the helper
12
13List tables:
14
15```bash
16node apps/server/scripts/t3-sqlite-state.ts query \
17 --base-dir <base-dir> \
18 --sql "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"
19```
20
21Inspect current columns before writing a fixture:
22
23```bash
24node apps/server/scripts/t3-sqlite-state.ts query \
25 --base-dir <base-dir> \
26 --sql "PRAGMA table_info(projection_threads)"
27```
28
29Apply a SQL fixture from a file:
30
31```bash
32node apps/server/scripts/t3-sqlite-state.ts exec \
33 --base-dir <base-dir> \
34 --file /tmp/t3-seed.sql
35```
36
37Use one statement per invocation for both `query` and `exec`; the helper wraps writes in a transaction and prints the backup path after a successful mutation. Use a single insert with multiple value rows when a fixture needs several records.
38
39## Seed projection data carefully
40
41The web UI primarily reads these projection tables:
42
43- `projection_projects`
44- `projection_threads`
45- `projection_thread_messages`
46- `projection_thread_activities`
47- `projection_thread_sessions`
48- `projection_turns`
49- `projection_pending_approvals`
50- `projection_thread_proposed_plans`
51
52Inspect `PRAGMA table_info(<table>)` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent.
53
54For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current.
55
56Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works.
57
58Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`.
AGENTS.md+9-24
......@@ -2,27 +2,14 @@
22
33## Task Completion Requirements
44
5- `vp check` and `vp run typecheck` must pass before considering tasks completed.
6 - If changing native mobile code, `vp run lint:mobile` must also pass.
7- Use `vp test` for the built-in Vite+ test command and `vp run test` when you specifically need the `test` package script.
8
9## Project Snapshot
10
11T3 Code is a minimal web GUI for using coding agents like Codex and Claude.
12
13This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
14
15## Core Priorities
16
171. Performance first.
182. Reliability first.
193. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
20
21If a tradeoff is required, choose correctness and robustness over short-term convenience.
22
23## Maintainability
24
25Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
5- Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step.
6 - Use `vp test run <test-files>` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script.
7 - Backend changes must include and run focused tests for the changed behavior.
8 - Run targeted formatting, lint, and type checks for the affected scope when available.
9- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite.
10- After frontend feature development or any user-visible frontend behavior change, the primary agent must use the `test-t3-app` skill once after integrating the work. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser.
11 - Subagents must not independently launch dev servers or repeat integrated browser verification unless their delegated task explicitly requires it.
12 - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.
2613
2714## Package Roles
2815
......@@ -35,7 +22,6 @@ Long term maintainability is a core priority. If you add new functionality, firs
3522## Reference Repos
3623
3724- Open-source Codex repo: https://github.com/openai/codex
38- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
3925
4026Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.
4127
......@@ -47,8 +33,7 @@ agents.
4733- Prefer examples and patterns from the vendored source code over generated guesses or web search results.
4834- Do not edit files under `.repos/` unless explicitly asked.
4935- Do not import from `.repos/`; application code must continue importing from normal package dependencies.
50- Manage vendored subtrees with `bun run sync:repos`; use `bun run sync:repos --repo <id>` to sync one
51 configured repository.
36- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo <id>` to sync one configured repository.
5237- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so
5338 `.repos/` matches the installed dependency version.
5439- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for
apps/desktop/src/app/DesktopEnvironment.test.ts+24-8
......@@ -53,13 +53,16 @@ describe("DesktopEnvironment", () => {
5353 assert.equal(environment.isDevelopment, true);
5454 assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");
5555 assert.equal(environment.baseDir, "/tmp/t3");
56 assert.equal(environment.stateDir, "/tmp/t3/dev");
57 assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json");
58 assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json");
59 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
60 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
61 assert.equal(environment.logDir, "/tmp/t3/dev/logs");
62 assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts");
56 assert.equal(environment.stateDir, "/tmp/t3/userdata");
57 assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json");
58 assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json");
59 assert.equal(
60 environment.savedEnvironmentRegistryPath,
61 "/tmp/t3/userdata/saved-environments.json",
62 );
63 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
64 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
65 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
6366 assert.equal(environment.rootDir, "/repo");
6467 assert.equal(environment.appRoot, "/repo");
6568 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
......@@ -78,7 +81,7 @@ describe("DesktopEnvironment", () => {
7881 }),
7982 );
8083
81 it.effect("derives production state paths under userdata", () =>
84 it.effect("stores production state under userdata in an explicit home", () =>
8285 Effect.gen(function* () {
8386 const environment = yield* makeEnvironment(
8487 {},
......@@ -95,6 +98,19 @@ describe("DesktopEnvironment", () => {
9598 }),
9699 );
97100
101 it.effect("keeps implicit development state separate from production state", () =>
102 Effect.gen(function* () {
103 const development = yield* makeEnvironment(
104 {},
105 { VITE_DEV_SERVER_URL: "http://localhost:5173" },
106 );
107 const production = yield* makeEnvironment();
108
109 assert.equal(development.stateDir, "/Users/alice/.t3/dev");
110 assert.equal(production.stateDir, "/Users/alice/.t3/userdata");
111 }),
112 );
113
98114 it.effect("uses a configured app user model id override", () =>
99115 Effect.gen(function* () {
100116 const environment = yield* makeEnvironment(
apps/desktop/src/app/DesktopEnvironment.ts+6-2
......@@ -147,7 +147,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
147147 : input.platform === "darwin"
148148 ? path.join(homeDirectory, "Library", "Application Support")
149149 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
150 const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3"));
150 const configuredBaseDir = config.t3Home;
151 const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3"));
151152 const rootDir = path.resolve(input.dirname, "../../..");
152153 const appRoot = input.isPackaged ? input.appPath : rootDir;
153154 const branding = resolveDesktopAppBranding({
......@@ -155,7 +156,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
155156 appVersion: input.appVersion,
156157 });
157158 const displayName = branding.displayName;
158 const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata");
159 const stateDir = path.join(
160 baseDir,
161 isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",
162 );
159163 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
160164 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
161165 const resourcesPath = input.resourcesPath;
apps/server/scripts/t3-sqlite-state.test.ts created+119
......@@ -0,0 +1,119 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as SqlClient from "effect/unstable/sql/SqlClient";
7
8import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
9import { runSqliteState } from "./t3-sqlite-state.ts";
10
11const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (
12 baseDir: string,
13) {
14 const fs = yield* FileSystem.FileSystem;
15 const path = yield* Path.Path;
16 const stateDir = path.join(baseDir, "userdata");
17 const databasePath = path.join(stateDir, "state.sqlite");
18 yield* fs.makeDirectory(stateDir, { recursive: true });
19 yield* Effect.gen(function* () {
20 const sql = yield* SqlClient.SqlClient;
21 yield* sql`CREATE TABLE fixtures (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`;
22 yield* sql`INSERT INTO fixtures (id, label) VALUES (1, 'existing')`;
23 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath })));
24});
25
26it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {
27 it.effect("reports each invalid SQL source with a specific error", () =>
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-input-" });
31
32 const multipleSources = yield* runSqliteState({
33 operation: "query",
34 baseDir,
35 sql: "SELECT 1",
36 file: "fixture.sql",
37 }).pipe(Effect.flip);
38 assert.equal(multipleSources._tag, "SqliteStateMultipleSqlSourcesError");
39 assert.equal(multipleSources.message, "Provide only one of --sql or --file.");
40
41 const missingSource = yield* runSqliteState({ operation: "query", baseDir }).pipe(
42 Effect.flip,
43 );
44 assert.equal(missingSource._tag, "SqliteStateMissingSqlSourceError");
45 assert.equal(missingSource.message, "Provide one of --sql or --file.");
46
47 const emptySql = yield* runSqliteState({
48 operation: "query",
49 baseDir,
50 sql: " ",
51 }).pipe(Effect.flip);
52 assert.equal(emptySql._tag, "SqliteStateEmptySqlError");
53 assert.equal(emptySql.message, "SQL input is empty.");
54 }),
55 );
56
57 it.effect("queries an isolated database through Effect SQL", () =>
58 Effect.gen(function* () {
59 const fs = yield* FileSystem.FileSystem;
60 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-query-" });
61 yield* createFixtureDatabase(baseDir);
62
63 const result = yield* runSqliteState({
64 operation: "query",
65 baseDir,
66 sql: "SELECT id, label FROM fixtures",
67 });
68
69 assert.equal(result.operation, "query");
70 if (result.operation === "query") {
71 assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]);
72 }
73 }),
74 );
75
76 it.effect("backs up isolated state before writes and refuses the shared home", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" });
81 yield* createFixtureDatabase(baseDir);
82
83 const mutation = yield* runSqliteState({
84 operation: "exec",
85 baseDir,
86 sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')",
87 });
88 assert.equal(mutation.operation, "exec");
89 if (mutation.operation === "exec") {
90 assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600);
91 }
92
93 const error = yield* runSqliteState(
94 {
95 operation: "exec",
96 baseDir,
97 sql: "DELETE FROM fixtures",
98 },
99 { sharedHome: baseDir },
100 ).pipe(Effect.flip);
101 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");
102
103 const aliasParent = yield* fs.makeTempDirectoryScoped({
104 prefix: "t3-sqlite-state-alias-",
105 });
106 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");
107 yield* fs.symlink(baseDir, aliasBaseDir);
108 const aliasError = yield* runSqliteState(
109 {
110 operation: "exec",
111 baseDir: aliasBaseDir,
112 sql: "DELETE FROM fixtures",
113 },
114 { sharedHome: baseDir },
115 ).pipe(Effect.flip);
116 assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");
117 }),
118 );
119});
apps/server/scripts/t3-sqlite-state.ts created+285
......@@ -0,0 +1,285 @@
1#!/usr/bin/env node
2
3// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
4import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeOS from "node:os";
7import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
8import * as Console from "effect/Console";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";
15import * as SqlClient from "effect/unstable/sql/SqlClient";
16import { Argument, Command, Flag } from "effect/unstable/cli";
17
18import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
19
20export const SqliteStateOperation = Schema.Literals(["query", "exec"]);
21export type SqliteStateOperation = typeof SqliteStateOperation.Type;
22
23export class SqliteStateMultipleSqlSourcesError extends Schema.TaggedErrorClass<SqliteStateMultipleSqlSourcesError>()(
24 "SqliteStateMultipleSqlSourcesError",
25 {},
26) {
27 override get message(): string {
28 return "Provide only one of --sql or --file.";
29 }
30}
31
32export class SqliteStateMissingSqlSourceError extends Schema.TaggedErrorClass<SqliteStateMissingSqlSourceError>()(
33 "SqliteStateMissingSqlSourceError",
34 {},
35) {
36 override get message(): string {
37 return "Provide one of --sql or --file.";
38 }
39}
40
41export class SqliteStateEmptySqlError extends Schema.TaggedErrorClass<SqliteStateEmptySqlError>()(
42 "SqliteStateEmptySqlError",
43 {},
44) {
45 override get message(): string {
46 return "SQL input is empty.";
47 }
48}
49
50export class SqliteStateDatabaseMissingError extends Schema.TaggedErrorClass<SqliteStateDatabaseMissingError>()(
51 "SqliteStateDatabaseMissingError",
52 {
53 databasePath: Schema.String,
54 },
55) {
56 override get message(): string {
57 return `Database does not exist at '${this.databasePath}'. Start T3 once to run migrations.`;
58 }
59}
60
61export class SqliteStateSharedHomeMutationError extends Schema.TaggedErrorClass<SqliteStateSharedHomeMutationError>()(
62 "SqliteStateSharedHomeMutationError",
63 {},
64) {
65 override get message(): string {
66 return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir.";
67 }
68}
69
70export class SqliteStateSqlFileError extends Schema.TaggedErrorClass<SqliteStateSqlFileError>()(
71 "SqliteStateSqlFileError",
72 {
73 filePath: Schema.String,
74 cause: Schema.Defect(),
75 },
76) {
77 override get message(): string {
78 return `Failed to read SQL from '${this.filePath}'.`;
79 }
80}
81
82export class SqliteStateDatabaseError extends Schema.TaggedErrorClass<SqliteStateDatabaseError>()(
83 "SqliteStateDatabaseError",
84 {
85 operation: SqliteStateOperation,
86 databasePath: Schema.String,
87 cause: Schema.Defect(),
88 },
89) {
90 override get message(): string {
91 return `Failed to ${this.operation} SQLite database at '${this.databasePath}'.`;
92 }
93}
94
95const SqliteStateValue = Schema.Union([
96 Schema.Null,
97 Schema.String,
98 Schema.Number,
99 Schema.Array(Schema.Number),
100]);
101const SqliteStateRow = Schema.Record(Schema.String, SqliteStateValue);
102const SqliteStateQueryResult = Schema.Struct({
103 operation: Schema.Literal("query"),
104 database: Schema.String,
105 rows: Schema.Array(SqliteStateRow),
106});
107const SqliteStateExecResult = Schema.Struct({
108 operation: Schema.Literal("exec"),
109 database: Schema.String,
110 backup: Schema.String,
111});
112const SqliteStateResult = Schema.Union([SqliteStateQueryResult, SqliteStateExecResult]);
113const encodeSqliteStateResult = Schema.encodeEffect(fromJsonStringPretty(SqliteStateResult));
114
115export type SqliteStateResult = typeof SqliteStateResult.Type;
116
117type RawSqliteValue = null | string | number | bigint | Uint8Array;
118type RawSqliteRow = Readonly<Record<string, RawSqliteValue>>;
119
120export interface RunSqliteStateInput {
121 readonly operation: SqliteStateOperation;
122 readonly baseDir: string;
123 readonly sql?: string | undefined;
124 readonly file?: string | undefined;
125}
126
127export interface RunSqliteStateOptions {
128 readonly sharedHome?: string | undefined;
129}
130
131const resolveSqlSource = Effect.fn("resolveSqliteStateSqlSource")(function* (
132 sql: string | undefined,
133 file: string | undefined,
134) {
135 if (sql !== undefined && file !== undefined) {
136 return yield* new SqliteStateMultipleSqlSourcesError();
137 }
138 if (sql === undefined && file === undefined) {
139 return yield* new SqliteStateMissingSqlSourceError();
140 }
141
142 const fs = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 let source: string;
145 if (sql !== undefined) {
146 source = sql;
147 } else {
148 const filePath = path.resolve(file as string);
149 source = yield* fs
150 .readFileString(filePath)
151 .pipe(Effect.mapError((cause) => new SqliteStateSqlFileError({ filePath, cause })));
152 }
153
154 const trimmed = source.trim();
155 if (trimmed.length === 0) {
156 return yield* new SqliteStateEmptySqlError();
157 }
158 return trimmed;
159});
160
161function normalizeSqliteValue(value: RawSqliteValue): typeof SqliteStateValue.Type {
162 if (typeof value === "bigint") {
163 const numericValue = Number(value);
164 return Number.isSafeInteger(numericValue) ? numericValue : value.toString();
165 }
166 if (value instanceof Uint8Array) {
167 return Array.from(value);
168 }
169 return value;
170}
171
172function normalizeSqliteRow(row: RawSqliteRow): typeof SqliteStateRow.Type {
173 return Object.fromEntries(
174 Object.entries(row).map(([key, value]) => [key, normalizeSqliteValue(value)]),
175 );
176}
177
178export const runSqliteState = Effect.fn("runSqliteState")(function* (
179 input: RunSqliteStateInput,
180 options: RunSqliteStateOptions = {},
181) {
182 const fs = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const baseDir = path.resolve(input.baseDir);
185 const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
186 const databasePath = path.join(baseDir, "userdata", "state.sqlite");
187 const source = yield* resolveSqlSource(input.sql, input.file);
188
189 if (!(yield* fs.exists(databasePath))) {
190 return yield* new SqliteStateDatabaseMissingError({ databasePath });
191 }
192 if (input.operation === "exec") {
193 const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
194 fs.realPath(baseDir),
195 fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
196 ]);
197 if (canonicalBaseDir === canonicalSharedHome) {
198 return yield* new SqliteStateSharedHomeMutationError();
199 }
200 }
201
202 const program = Effect.gen(function* () {
203 const sql = yield* SqlClient.SqlClient;
204 yield* sql.unsafe("PRAGMA busy_timeout = 5000").unprepared;
205
206 if (input.operation === "query") {
207 const rows = yield* sql.unsafe<RawSqliteRow>(source).unprepared.pipe(
208 Effect.provideService(SqlClient.SafeIntegers, true),
209 Effect.map((rows) => rows.map(normalizeSqliteRow)),
210 );
211 return {
212 operation: "query",
213 database: databasePath,
214 rows,
215 } as const;
216 }
217
218 const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-");
219 const backupPath = `${databasePath}.backup-${timestamp}`;
220 yield* sql`VACUUM INTO ${backupPath}`;
221 yield* fs.chmod(backupPath, 0o600);
222 yield* sql.withTransaction(sql.unsafe(source).unprepared);
223
224 return {
225 operation: "exec",
226 database: databasePath,
227 backup: backupPath,
228 } as const;
229 });
230
231 return yield* program.pipe(
232 Effect.provide(
233 NodeSqliteClient.layer({
234 filename: databasePath,
235 readonly: input.operation === "query",
236 }),
237 ),
238 Effect.mapError(
239 (cause) =>
240 new SqliteStateDatabaseError({
241 operation: input.operation,
242 databasePath,
243 cause,
244 }),
245 ),
246 );
247});
248
249export const t3SqliteStateCommand = Command.make(
250 "t3-sqlite-state",
251 {
252 operation: Argument.choice("operation", SqliteStateOperation.literals).pipe(
253 Argument.withDescription("Run a read-only query or a backed-up fixture mutation."),
254 ),
255 baseDir: Flag.string("base-dir").pipe(
256 Flag.withDescription("Explicit T3 base directory containing userdata/state.sqlite."),
257 ),
258 sql: Flag.string("sql").pipe(
259 Flag.optional,
260 Flag.withDescription("SQL source supplied directly on the command line."),
261 ),
262 file: Flag.string("file").pipe(
263 Flag.optional,
264 Flag.withDescription("Path to a SQL source file."),
265 ),
266 },
267 ({ operation, baseDir, sql, file }) =>
268 runSqliteState({
269 operation,
270 baseDir,
271 sql: Option.getOrUndefined(sql),
272 file: Option.getOrUndefined(file),
273 }).pipe(Effect.flatMap(encodeSqliteStateResult), Effect.flatMap(Console.log)),
274).pipe(
275 Command.withDescription(
276 "Inspect or seed an isolated T3 SQLite database with automatic backups for writes.",
277 ),
278);
279
280if (import.meta.main) {
281 Command.run(t3SqliteStateCommand, { version: "0.0.0" }).pipe(
282 Effect.provide(NodeServices.layer),
283 NodeRuntime.runMain,
284 );
285}
apps/server/src/cli/config.test.ts+23-6
......@@ -18,6 +18,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
1818import { deriveServerPaths } from "../config.ts";
1919import { resolveServerConfig } from "./config.ts";
2020
21const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
22 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
23
2124const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
2225
2326const makeDesktopBootstrap = (
......@@ -60,7 +63,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
6063 Effect.gen(function* () {
6164 const { join } = yield* Path.Path;
6265 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base");
63 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
66 const derivedPaths = yield* deriveExplicitServerPaths(
67 baseDir,
68 new URL("http://127.0.0.1:5173"),
69 );
6470 const resolved = yield* resolveServerConfig(
6571 {
6672 mode: Option.none(),
......@@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
119125 tailscaleServeEnabled: false,
120126 tailscaleServePort: 443,
121127 });
128 assert.equal(resolved.stateDir, join(baseDir, "userdata"));
122129 }),
123130 );
124131
......@@ -126,7 +133,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
126133 Effect.gen(function* () {
127134 const { join } = yield* Path.Path;
128135 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base");
129 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
136 const derivedPaths = yield* deriveExplicitServerPaths(
137 baseDir,
138 new URL("http://127.0.0.1:4173"),
139 );
130140 const resolved = yield* resolveServerConfig(
131141 {
132142 mode: Option.some("web"),
......@@ -185,6 +195,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
185195 tailscaleServeEnabled: true,
186196 tailscaleServePort: 8443,
187197 });
198 assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite"));
188199 }),
189200 );
190201
......@@ -199,7 +210,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
199210 tailscaleServePort: 443,
200211 }),
201212 );
202 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
213 const derivedPaths = yield* deriveExplicitServerPaths(
214 baseDir,
215 new URL("http://127.0.0.1:4173"),
216 );
203217
204218 const resolved = yield* resolveServerConfig(
205219 {
......@@ -396,7 +410,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
396410 tailscaleServePort: 443,
397411 }),
398412 );
399 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
413 const derivedPaths = yield* deriveExplicitServerPaths(
414 baseDir,
415 new URL("http://127.0.0.1:4173"),
416 );
400417
401418 const resolved = yield* resolveServerConfig(
402419 {
......@@ -461,7 +478,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
461478 const fs = yield* FileSystem.FileSystem;
462479 const path = yield* Path.Path;
463480 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
464 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
481 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
465482 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
466483 yield* fs.writeFileString(
467484 derivedPaths.settingsPath,
......@@ -529,7 +546,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
529546 Effect.gen(function* () {
530547 const { join } = yield* Path.Path;
531548 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-headless-base");
532 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
549 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
533550
534551 const resolved = yield* resolveServerConfig(
535552 {
apps/server/src/cli/config.ts+11-7
......@@ -31,7 +31,9 @@ export const hostFlag = Flag.string("host").pipe(
3131 Flag.optional,
3232);
3333export const baseDirFlag = Flag.string("base-dir").pipe(
34 Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
34 Flag.withDescription(
35 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
36 ),
3537 Flag.optional,
3638);
3739export const devUrlFlag = Flag.string("dev-url").pipe(
......@@ -259,19 +261,21 @@ export const resolveServerConfig = (
259261 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
260262 () => undefined,
261263 );
264 const explicitBaseDir = resolveOptionPrecedence(
265 normalizedFlags.baseDir,
266 Option.fromUndefinedOr(env.t3Home),
267 ).pipe(Option.filter((value) => value.trim().length > 0));
262268 const baseDir = yield* resolveBaseDir(
263269 Option.getOrUndefined(
264 resolveOptionPrecedence(
265 normalizedFlags.baseDir,
266 Option.fromUndefinedOr(env.t3Home),
267 Option.fromUndefinedOr(bootstrap?.t3Home),
268 ),
270 resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)),
269271 ),
270272 );
271273 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
272274 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
273275 yield* fs.makeDirectory(cwd, { recursive: true });
274 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl);
276 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
277 baseDirIsExplicit: Option.isSome(explicitBaseDir),
278 });
275279 yield* ServerConfig.ensureServerDirectories(derivedPaths);
276280 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
277281 derivedPaths.settingsPath,
apps/server/src/config.ts+9-1
......@@ -45,6 +45,10 @@ export interface ServerDerivedPaths {
4545 readonly secretsDir: string;
4646}
4747
48export interface DeriveServerPathsOptions {
49 readonly baseDirIsExplicit?: boolean;
50}
51
4852/**
4953 * ServerConfig - Service tag for server runtime configuration.
5054 */
......@@ -91,9 +95,13 @@ export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerCo
9195export const deriveServerPaths = Effect.fn(function* (
9296 baseDir: ServerConfig["Service"]["baseDir"],
9397 devUrl: ServerConfig["Service"]["devUrl"],
98 options: DeriveServerPathsOptions = {},
9499): Effect.fn.Return<ServerDerivedPaths, never, Path.Path> {
95100 const { join } = yield* Path.Path;
96 const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata");
101 const stateDir = join(
102 baseDir,
103 devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata",
104 );
97105 const dbPath = join(stateDir, "state.sqlite");
98106 const attachmentsDir = join(stateDir, "attachments");
99107 const logsDir = join(stateDir, "logs");
docs/operations/observability.md+17-9
......@@ -165,16 +165,24 @@ The backend reads observability config at process start. If you change OTLP env
165165
166166The trace file is the fastest way to inspect raw span data.
167167
168Resolve the production or explicitly configured trace file once. Runtime state lives under the
169base directory's `userdata` folder:
170
171```bash
172TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson"
173```
174
168175Tail it:
169176
170177```bash
171tail -f "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
178tail -f "$TRACE_FILE"
172179```
173180
174In monorepo dev, use:
181For an implicit monorepo dev server, use:
175182
176183```bash
177tail -f ./dev/logs/server.trace.ndjson
184TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson"
185tail -f "$TRACE_FILE"
178186```
179187
180188Show failed spans:
......@@ -185,7 +193,7 @@ jq -c 'select(.exit._tag != "Success") | {
185193 durationMs,
186194 exit,
187195 attributes
188}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
196}' "$TRACE_FILE"
189197```
190198
191199Show slow spans:
......@@ -196,7 +204,7 @@ jq -c 'select(.durationMs > 1000) | {
196204 durationMs,
197205 traceId,
198206 spanId
199}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
207}' "$TRACE_FILE"
200208```
201209
202210Inspect embedded log events:
......@@ -213,7 +221,7 @@ jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | {
213221 level: .attributes["effect.logLevel"]
214222 }
215223 ]
216}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
224}' "$TRACE_FILE"
217225```
218226
219227Follow one trace:
......@@ -224,7 +232,7 @@ jq -r 'select(.traceId == "TRACE_ID_HERE") | [
224232 .spanId,
225233 (.parentSpanId // "-"),
226234 .durationMs
227] | @tsv' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
235] | @tsv' "$TRACE_FILE"
228236```
229237
230238Filter orchestration commands:
......@@ -235,7 +243,7 @@ jq -c 'select(.attributes["orchestration.command_type"] != null) | {
235243 durationMs,
236244 commandType: .attributes["orchestration.command_type"],
237245 aggregateKind: .attributes["orchestration.aggregate_kind"]
238}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
246}' "$TRACE_FILE"
239247```
240248
241249Filter git activity:
......@@ -250,7 +258,7 @@ jq -c 'select(.attributes["git.operation"] != null) | {
250258 .events[]
251259 | select(.name == "git.hook.started" or .name == "git.hook.finished")
252260 ]
253}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"
261}' "$TRACE_FILE"
254262```
255263
256264### Use Tempo When You Need A Real Trace Viewer
docs/reference/scripts.md+5-3
......@@ -3,13 +3,15 @@
33- `vp run dev` — Starts contracts, server, and web in watch mode.
44- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.
55- `vp run dev:web` — Starts just the Vite dev server for the web app.
6- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate.
7- Override server CLI-equivalent flags from root dev commands with `--`, for example:
8 `vp run dev -- --home-dir ~/.t3-2`
6- Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data.
7- Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional.
8- Pass dev-runner flags directly after the root task name, for example:
9 `vp run dev --home-dir /tmp/t3code-dev`
910- `vp run start` — Runs the production server (serves built web app as static files).
1011- `vp run build` — Builds contracts, web app, and server.
1112- `vp run typecheck` — Strict TypeScript checks for all packages.
1213- `vp run test` — Runs workspace tests.
14- `node apps/server/scripts/t3-sqlite-state.ts <query|exec> --base-dir <path> ...` — Inspects or seeds an isolated T3 SQLite database; writes create a private backup first.
1315- `vp run dist:desktop:artifact -- --platform <mac|linux|win> --target <target> --arch <arch>` — Builds a desktop artifact for a specific platform/target/arch.
1416- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
1517- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
scripts/dev-runner.test.ts+51-12
......@@ -1,5 +1,4 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeOS from "node:os";
32import * as NetService from "@t3tools/shared/Net";
43import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
54import { assert, describe, it } from "@effect/vitest";
......@@ -52,7 +51,7 @@ function mockProcess(exit: number | PlatformError.PlatformError) {
5251const devServerInput = {
5352 mode: "dev:server",
5453 t3Home: "/tmp/t3code-dev-runner",
55 noBrowser: undefined,
54 browser: undefined,
5655 autoBootstrapProjectFromCwd: undefined,
5756 logWebSocketEvents: undefined,
5857 host: undefined,
......@@ -127,16 +126,56 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
127126 });
128127
129128 describe("createDevRunnerEnv", () => {
130 it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () =>
129 it.effect("leaves the shared home implicit and disables browser auto-open", () =>
130 Effect.gen(function* () {
131 const env = yield* createDevRunnerEnv({
132 mode: "dev",
133 baseEnv: {},
134 serverOffset: 0,
135 webOffset: 0,
136 t3Home: undefined,
137 browser: undefined,
138 autoBootstrapProjectFromCwd: undefined,
139 logWebSocketEvents: undefined,
140 host: undefined,
141 port: undefined,
142 devUrl: undefined,
143 });
144
145 assert.equal(env.T3CODE_HOME, undefined);
146 assert.equal(env.T3CODE_NO_BROWSER, "1");
147 }),
148 );
149
150 it.effect("allows browser auto-open to be explicitly enabled", () =>
131151 Effect.gen(function* () {
132 const path = yield* Path.Path;
133152 const env = yield* createDevRunnerEnv({
134153 mode: "dev",
135154 baseEnv: {},
136155 serverOffset: 0,
137156 webOffset: 0,
138157 t3Home: undefined,
139 noBrowser: undefined,
158 browser: true,
159 autoBootstrapProjectFromCwd: undefined,
160 logWebSocketEvents: undefined,
161 host: undefined,
162 port: undefined,
163 devUrl: undefined,
164 });
165
166 assert.equal(env.T3CODE_NO_BROWSER, "0");
167 }),
168 );
169
170 it.effect("requires the browser flag even when the environment enables auto-open", () =>
171 Effect.gen(function* () {
172 const env = yield* createDevRunnerEnv({
173 mode: "dev",
174 baseEnv: { T3CODE_NO_BROWSER: "0" },
175 serverOffset: 0,
176 webOffset: 0,
177 t3Home: undefined,
178 browser: false,
140179 autoBootstrapProjectFromCwd: undefined,
141180 logWebSocketEvents: undefined,
142181 host: undefined,
......@@ -144,7 +183,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
144183 devUrl: undefined,
145184 });
146185
147 assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3"));
186 assert.equal(env.T3CODE_NO_BROWSER, "1");
148187 }),
149188 );
150189
......@@ -157,7 +196,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
157196 serverOffset: 0,
158197 webOffset: 0,
159198 t3Home: "/tmp/custom-t3",
160 noBrowser: true,
199 browser: false,
161200 autoBootstrapProjectFromCwd: false,
162201 logWebSocketEvents: true,
163202 host: "0.0.0.0",
......@@ -187,7 +226,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
187226 serverOffset: 0,
188227 webOffset: 0,
189228 t3Home: undefined,
190 noBrowser: undefined,
229 browser: undefined,
191230 autoBootstrapProjectFromCwd: undefined,
192231 logWebSocketEvents: undefined,
193232 host: undefined,
......@@ -210,7 +249,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
210249 serverOffset: 0,
211250 webOffset: 0,
212251 t3Home: undefined,
213 noBrowser: undefined,
252 browser: undefined,
214253 autoBootstrapProjectFromCwd: undefined,
215254 logWebSocketEvents: false,
216255 host: undefined,
......@@ -231,7 +270,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
231270 serverOffset: 0,
232271 webOffset: 0,
233272 t3Home: "/tmp/my-t3",
234 noBrowser: undefined,
273 browser: undefined,
235274 autoBootstrapProjectFromCwd: undefined,
236275 logWebSocketEvents: undefined,
237276 host: undefined,
......@@ -259,7 +298,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
259298 serverOffset: 0,
260299 webOffset: 0,
261300 t3Home: "/tmp/my-t3",
262 noBrowser: true,
301 browser: true,
263302 autoBootstrapProjectFromCwd: undefined,
264303 logWebSocketEvents: undefined,
265304 host: "127.0.0.1",
......@@ -288,7 +327,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
288327 serverOffset: 0,
289328 webOffset: 0,
290329 t3Home: undefined,
291 noBrowser: undefined,
330 browser: undefined,
292331 autoBootstrapProjectFromCwd: undefined,
293332 logWebSocketEvents: undefined,
294333 host: undefined,
scripts/dev-runner.ts+21-15
......@@ -221,7 +221,7 @@ interface CreateDevRunnerEnvInput {
221221 readonly serverOffset: number;
222222 readonly webOffset: number;
223223 readonly t3Home: string | undefined;
224 readonly noBrowser: boolean | undefined;
224 readonly browser: boolean | undefined;
225225 readonly autoBootstrapProjectFromCwd: boolean | undefined;
226226 readonly logWebSocketEvents: boolean | undefined;
227227 readonly host: string | undefined;
......@@ -235,7 +235,7 @@ export function createDevRunnerEnv({
235235 serverOffset,
236236 webOffset,
237237 t3Home,
238 noBrowser,
238 browser,
239239 autoBootstrapProjectFromCwd,
240240 logWebSocketEvents,
241241 host,
......@@ -245,7 +245,8 @@ export function createDevRunnerEnv({
245245 return Effect.gen(function* () {
246246 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
247247 const webPort = BASE_WEB_PORT + webOffset;
248 const resolvedBaseDir = yield* resolveBaseDir(t3Home);
248 const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined;
249 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
249250 const isDesktopMode = mode === "dev:desktop";
250251
251252 const output: NodeJS.ProcessEnv = {
......@@ -254,9 +255,14 @@ export function createDevRunnerEnv({
254255 VITE_DEV_SERVER_URL:
255256 devUrl?.toString() ??
256257 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,
257 T3CODE_HOME: resolvedBaseDir,
258258 };
259259
260 if (configuredBaseDir !== undefined) {
261 output.T3CODE_HOME = resolvedBaseDir;
262 } else {
263 delete output.T3CODE_HOME;
264 }
265
260266 if (!isDesktopMode) {
261267 output.T3CODE_PORT = String(serverPort);
262268 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
......@@ -274,10 +280,8 @@ export function createDevRunnerEnv({
274280 output.T3CODE_HOST = host;
275281 }
276282
277 if (!isDesktopMode && noBrowser !== undefined) {
278 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
279 } else if (!isDesktopMode) {
280 delete output.T3CODE_NO_BROWSER;
283 if (!isDesktopMode) {
284 output.T3CODE_NO_BROWSER = browser === true ? "0" : "1";
281285 }
282286
283287 if (autoBootstrapProjectFromCwd !== undefined) {
......@@ -469,7 +473,7 @@ export function resolveModePortOffsets<R = NetService.NetService>({
469473interface DevRunnerCliInput {
470474 readonly mode: DevMode;
471475 readonly t3Home: string | undefined;
472 readonly noBrowser: boolean | undefined;
476 readonly browser: boolean | undefined;
473477 readonly autoBootstrapProjectFromCwd: boolean | undefined;
474478 readonly logWebSocketEvents: boolean | undefined;
475479 readonly host: string | undefined;
......@@ -507,7 +511,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
507511 serverOffset,
508512 webOffset,
509513 t3Home: input.t3Home,
510 noBrowser: input.noBrowser,
514 browser: input.browser,
511515 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
512516 logWebSocketEvents: input.logWebSocketEvents,
513517 host: input.host,
......@@ -519,9 +523,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
519523 serverOffset !== offset || webOffset !== offset
520524 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
521525 : "";
526 const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME);
522527
523528 yield* Effect.logInfo(
524 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${String(env.T3CODE_HOME)}`,
529 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
525530 );
526531
527532 if (input.dryRun) {
......@@ -586,12 +591,13 @@ const devRunnerCli = Command.make("dev-runner", {
586591 Argument.withDescription("Development mode to run."),
587592 ),
588593 t3Home: Flag.string("home-dir").pipe(
589 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
594 Flag.withDescription(
595 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
596 ),
590597 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
591598 ),
592 noBrowser: Flag.boolean("no-browser").pipe(
593 Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."),
594 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")),
599 browser: Flag.boolean("browser").pipe(
600 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),
595601 ),
596602 autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
597603 Flag.withDescription(