diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 70441702e016..d00ba9e82a60 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -9,12 +9,14 @@ import { describe("DesktopEarlyElectronStartup", () => { const joinPath = NodePath.posix.join; + const noDirectories = () => false; it("reads the persisted linux password-store preference before Electron is ready", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: (path) => { assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet6" }); @@ -29,6 +31,7 @@ describe("DesktopEarlyElectronStartup", () => { env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: () => `{ // manually edited setting "linuxPasswordStore": "gnome-libsecret", @@ -43,6 +46,7 @@ describe("DesktopEarlyElectronStartup", () => { env: {}, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: () => { throw new Error("missing"); }, @@ -56,6 +60,7 @@ describe("DesktopEarlyElectronStartup", () => { env: { T3CODE_HOME: "/" }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: (path) => { assert.equal(path, "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/userdata/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet6" }); @@ -74,6 +79,7 @@ describe("DesktopEarlyElectronStartup", () => { }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: (path) => { assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "auto" }); @@ -88,15 +94,16 @@ describe("DesktopEarlyElectronStartup", () => { }); }); - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + it("keeps implicit development state under the Linux XDG default when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.local/share/t3code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet" }); }, }); @@ -112,8 +119,9 @@ describe("DesktopEarlyElectronStartup", () => { }, homeDirectory: "/home/user", joinPath, + directoryExists: noDirectories, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.local/share/t3code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); }, }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 95922bdfdca8..3d6921ce86ca 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -19,6 +19,7 @@ interface EarlyDesktopSettingsInput { readonly env: NodeJS.ProcessEnv; readonly homeDirectory: string; readonly joinPath: JoinPath; + readonly directoryExists: (path: string) => boolean; readonly readFileString: (path: string) => string; } @@ -53,12 +54,16 @@ function resolveEarlyDesktopSettingsPath(input: { readonly env: NodeJS.ProcessEnv; readonly homeDirectory: string; readonly joinPath: JoinPath; + readonly directoryExists: (path: string) => boolean; }): string { const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); const baseDir = resolveDesktopBaseDir({ homeDirectory: input.homeDirectory, joinPath: input.joinPath, + platform: "linux", t3Home, + xdgDataHome: Option.fromUndefinedOr(input.env.XDG_DATA_HOME), + directoryExists: input.directoryExists, }); const stateDir = resolveDesktopStateDir({ baseDir, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 262097ca78ea..202a80a0f8bf 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -136,6 +136,23 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("uses XDG_DATA_HOME for implicit Linux state", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + { + platform: "linux", + homeDirectory: "/home/alice", + }, + { + XDG_DATA_HOME: "/data", + }, + ); + + assert.equal(environment.baseDir, "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/data/t3code"); + assert.equal(environment.stateDir, "/data/t3code/userdata"); + }), + ); + it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9d7f00c3ee69..30afe128b4cc 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,6 +4,7 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; +import { directoryExistsSync } from "@t3tools/shared/defaultT3Home"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -160,7 +161,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( const baseDir = resolveDesktopBaseDir({ homeDirectory, joinPath: path.join, + platform: input.platform, t3Home: config.t3Home, + xdgDataHome: config.xdgDataHome, + directoryExists: directoryExistsSync, }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..53af77c9486d 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. +import { directoryExistsSync } from "@t3tools/shared/defaultT3Home"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -37,6 +38,7 @@ export const resolveEarlyLinuxElectronOptionsFromProcess = env: process.env, homeDirectory: NodeOS.homedir(), joinPath: NodePath.posix.join, + directoryExists: directoryExistsSync, readFileString: (path) => NodeFS.readFileSync(path, "utf8"), }); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092d4..bc7060598ea7 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,3 +1,4 @@ +import { defaultT3Home } from "@t3tools/shared/defaultT3Home"; import * as Option from "effect/Option"; export type JoinPath = (first: string, ...segments: string[]) => string; @@ -13,10 +14,19 @@ function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Optio export function resolveDesktopBaseDir(input: { readonly homeDirectory: string; readonly joinPath: JoinPath; + readonly platform: NodeJS.Platform; readonly t3Home: Option.Option; + readonly xdgDataHome: Option.Option; + readonly directoryExists: (path: string) => boolean; }): string { return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + defaultT3Home({ + platform: input.platform, + homeDirectory: input.homeDirectory, + xdgDataHome: Option.getOrUndefined(input.xdgDataHome), + joinPath: input.joinPath, + directoryExists: input.directoryExists, + }), ); } diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index fca5e4ac6db1..29dff58100f2 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node /** - * Rebuild an isolated dev database from a pruned snapshot of the real - * ~/.t3 database, then run this checkout's migrations against it. + * Rebuild an isolated dev database from a pruned snapshot of the default + * T3 database, then run this checkout's migrations against it. * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/state.sqlite`. @@ -26,7 +26,8 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { defaultT3HomeForHost } from "@t3tools/shared/defaultT3Home"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -54,7 +55,7 @@ export class MigrateDevDbSharedHomeError extends Schema.TaggedError/.t3` of the cwd. */ readonly baseDir?: string | undefined; - /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + /** Source database. Defaults to `/userdata/state.sqlite`. */ readonly source?: string | undefined; readonly projects: number; readonly threadsPerProject: number; @@ -360,7 +361,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); + const sharedHome = path.resolve(options.sharedHome ?? + defaultT3HomeForHost({ + platform: yield* HostProcessPlatform, + env: yield* HostProcessEnvironment, + })); const sourcePath = path.resolve( input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), ); @@ -520,7 +525,7 @@ export const migrateDevDbCommand = Command.make( ), source: Flag.string("source").pipe( Flag.optional, - Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + Flag.withDescription("Source database. Defaults to /userdata/state.sqlite."), ), }, ({ projects, threadsPerProject, baseDir, source }) => @@ -547,7 +552,7 @@ export const migrateDevDbCommand = Command.make( }), ).pipe( Command.withDescription( - "Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.", + "Rebuild the worktree dev database from a pruned snapshot of the default T3 data, then run migrations.", ), ); diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index aae040470f50..e16e16a07630 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -2,7 +2,8 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { defaultT3HomeForHost } from "@t3tools/shared/defaultT3Home"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import * as Console from "effect/Console"; import * as DateTime from "effect/DateTime"; @@ -62,7 +63,7 @@ export class SqliteStateSharedHomeMutationError extends Schema.TaggedError { const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const fileSystem = yield* FileSystem.FileSystem; - const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); - yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(worktreeCwd), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + const worktreeCwds = [ + path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"), + path.join(claudeHomePath, ".local", "share", "t3code", "worktrees", "wt-2"), + ]; + for (const [index, worktreeCwd] of worktreeCwds.entries()) { + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } const result = yield* runScan({ claudeHomePath, codexHomePath }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..f6a3703ba00b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -539,7 +539,7 @@ function shouldRetainDecodedRecord( * T3 Code runs its own agent sessions inside disposable worktrees. Their * transcripts look exactly like user sessions, but re-importing the app's own * sandboxes as projects is never right. Matches this server's configured - * worktrees directory plus the conventional `.t3/worktrees` layout, which + * worktrees directory plus the conventional default T3 home layouts, which * also catches sandboxes from other T3 homes on the same machine. Separators * are normalized (and, on Windows, case folded) so the prefix match holds * there too. Callers check both the recorded spelling and its realpath so a @@ -558,7 +558,8 @@ function isT3ManagedWorktree( const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); return ( normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || - normalized.includes("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/.t3/worktrees/") + normalized.includes("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/.t3/worktrees/") || + normalized.includes("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/t3code/worktrees/") ); } diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index c68dfbbcd9ae..623e18c70663 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -249,7 +249,7 @@ const upsertAnonymousId = Effect.gen(function* () { * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. * 1. ~/.codex/auth.json tokens.account_id * 2. ~/.claude.json userID - * 3. ~/.t3/telemetry/anonymous-id + * 3. /anonymous-id */ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( function* (homeDirectory: string) { diff --git a/docs/operations/development.md b/docs/operations/development.md index f913a8100e75..eda95da4b5d6 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -27,8 +27,10 @@ Add `--browser` to open a browser automatically. ### State and ports Linked worktrees default to their own `.t3/userdata`, even when `T3CODE_HOME` is set. -The main checkout defaults to `~/.t3/dev/userdata`. An explicit `--home-dir` wins in both cases. -Never run a development server against the live `~/.t3/userdata`. +The main checkout defaults to `/dev/userdata`. On Linux, T3 home defaults to +`${XDG_DATA_HOME:-$HOME/.local/share}/t3code`, unless only legacy `~/.t3` exists; other platforms use +`~/.t3`. An explicit `--home-dir` wins in both cases. +Never run a development server against the live `/userdata`. See [test data](../../AGENTS.md#test-data) for copying a consistent database snapshot. Read ports from the `[dev-runner]` output. Worktrees derive stable preferences from their paths, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 966eab112c6b..b56b7d8fda15 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -29,10 +29,12 @@ If you want a log message to show up in the trace file, emit it inside an active Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the server starts: production and explicitly configured homes use -`/userdata/logs/server.trace.ndjson` (so `~/.t3/userdata/...` by default, or -`/custom/path/userdata/...` with `--home-dir /custom/path`), a linked worktree dev run uses +`/userdata/logs/server.trace.ndjson` (so the Linux default is +`${XDG_DATA_HOME:-$HOME/.local/share}/t3code/userdata/...`, or +`~/.t3/userdata/...` outside Linux, or `/custom/path/userdata/...` with `--home-dir /custom/path`), +a linked worktree dev run uses `/.t3/userdata/logs/server.trace.ndjson`, and an implicit dev run outside a linked -worktree uses `~/.t3/dev/logs/server.trace.ndjson`. +worktree uses `/dev/logs/server.trace.ndjson`. Important fields common to both record types: @@ -189,7 +191,15 @@ Resolve the path for the launch mode once. Production and explicitly configured state under the base directory's `userdata` folder: ```bash -TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson" +XDG_T3_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/t3code" +if [ -n "${T3CODE_HOME:-}" ]; then + T3_HOME="$T3CODE_HOME" +elif [ "$(uname -s)" = "Linux" ] && { [ -d "$XDG_T3_HOME" ] || [ ! -d "$HOME/.t3" ]; }; then + T3_HOME="$XDG_T3_HOME" +else + T3_HOME="$HOME/.t3" +fi +TRACE_FILE="$T3_HOME/userdata/logs/server.trace.ndjson" ``` A dev server started from a linked worktree defaults to that worktree's local home: @@ -201,7 +211,7 @@ TRACE_FILE="$WORKTREE/.t3/userdata/logs/server.trace.ndjson" Only an implicit dev run outside a linked worktree uses the shared dev directory: ```bash -TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson" +TRACE_FILE="$T3_HOME/dev/logs/server.trace.ndjson" ``` Tail the selected file: diff --git a/docs/user/appearance.md b/docs/user/appearance.md index f682902586f1..6d9b2a5db6b8 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -51,10 +51,12 @@ the default and published themes. ### Publish a theme -Save a theme exported from T3 Code into `~/.t3/userdata/themes/` on the server, or the `themes` -directory under your custom state directory. The filename supplies the theme ID: `nightfall.json` -can be selected with `t3 theme set nightfall`. Keep the filename stable when updating its colors. -Do not use `system`, `light`, `dark`, or a built-in theme's ID. +Save a theme exported from T3 Code into `/userdata/themes/` on the server, or the +`themes` directory under your custom state directory. On Linux, T3 home defaults to +`${XDG_DATA_HOME:-$HOME/.local/share}/t3code`, unless only legacy `~/.t3` exists; other platforms +use `~/.t3`. The filename supplies the theme ID: `nightfall.json` can be selected with +`t3 theme set nightfall`. Keep the filename stable when updating its colors. Do not use `system`, +`light`, `dark`, or a built-in theme's ID. For an integration that generates a palette, this shorter format also works: diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4e46e97a3a56..6e796d2ca742 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -5,8 +5,9 @@ also lists the command IDs and defaults available in your version. ## Edit the configuration file -Keybindings live on the environment's machine, in -`~/.t3/userdata/keybindings.json` by default. You can edit this file directly. +Keybindings live on the environment's machine, in `/userdata/keybindings.json`. +On Linux, T3 home defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/t3code`, unless only legacy +`~/.t3` exists; other platforms use `~/.t3`. You can edit this file directly. It is a JSON array of rules: ```json diff --git a/packages/shared/package.json b/packages/shared/package.json index faf0d6f2a0f5..52752948f6b9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -283,6 +283,10 @@ "types": "./src/devHome.ts", "import": "./src/devHome.ts" }, + "./defaultT3Home": { + "types": "./src/defaultT3Home.ts", + "import": "./src/defaultT3Home.ts" + }, "./devProxy": { "types": "./src/devProxy.ts", "import": "./src/devProxy.ts" diff --git a/packages/shared/src/defaultT3Home.test.ts b/packages/shared/src/defaultT3Home.test.ts new file mode 100644 index 000000000000..984887c1bd1a --- /dev/null +++ b/packages/shared/src/defaultT3Home.test.ts @@ -0,0 +1,54 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { defaultT3Home } from "./defaultT3Home.ts"; + +const joinPath = (first: string, ...segments: string[]) => [first, ...segments].join("/"); + +const resolve = (input: { + readonly platform: NodeJS.Platform; + readonly xdgDataHome?: string | undefined; + readonly existing?: ReadonlyArray | undefined; +}) => { + const existing = new Set(input.existing ?? []); + return defaultT3Home({ + platform: input.platform, + homeDirectory: "/home/alice", + xdgDataHome: input.xdgDataHome, + joinPath, + directoryExists: (path) => existing.has(path), + }); +}; + +describe("defaultT3Home", () => { + it("uses XDG_DATA_HOME on Linux when set", () => { + assert.equal( + resolve({ platform: "linux", xdgDataHome: "/data" }), + "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/data/t3code", + ); + }); + + it("uses ~/.local/share/t3code on Linux without XDG_DATA_HOME", () => { + assert.equal(resolve({ platform: "linux" }), "/home/alice/.local/share/t3code"); + }); + + it("keeps legacy ~/.t3 when the XDG directory is absent", () => { + assert.equal( + resolve({ platform: "linux", existing: ["/home/alice/.t3"] }), + "/home/alice/.t3", + ); + }); + + it("prefers XDG when both Linux defaults exist", () => { + assert.equal( + resolve({ + platform: "linux", + existing: ["/home/alice/.local/share/t3code", "/home/alice/.t3"], + }), + "/home/alice/.local/share/t3code", + ); + }); + + it("keeps ~/.t3 on non-Linux platforms", () => { + assert.equal(resolve({ platform: "darwin" }), "/home/alice/.t3"); + }); +}); diff --git a/packages/shared/src/defaultT3Home.ts b/packages/shared/src/defaultT3Home.ts new file mode 100644 index 000000000000..b36648ab1097 --- /dev/null +++ b/packages/shared/src/defaultT3Home.ts @@ -0,0 +1,51 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface DefaultT3HomeInput { + readonly platform: NodeJS.Platform; + readonly homeDirectory: string; + readonly xdgDataHome: string | undefined; + readonly joinPath: (first: string, ...segments: string[]) => string; + readonly directoryExists: (path: string) => boolean; +} + +export function defaultT3Home(input: DefaultT3HomeInput): string { + const legacyHome = input.joinPath(input.homeDirectory, ".t3"); + if (input.platform !== "linux") { + return legacyHome; + } + + const xdgDataHome = input.xdgDataHome?.trim(); + const xdgRoot = + xdgDataHome && xdgDataHome.length > 0 + ? xdgDataHome + : input.joinPath(input.homeDirectory, ".local", "share"); + const xdgHome = input.joinPath(xdgRoot, "t3code"); + + if (input.directoryExists(xdgHome)) { + return xdgHome; + } + return input.directoryExists(legacyHome) ? legacyHome : xdgHome; +} + +export function directoryExistsSync(path: string): boolean { + try { + return NodeFS.statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function defaultT3HomeForHost(input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; +}): string { + return defaultT3Home({ + platform: input.platform, + homeDirectory: NodeOS.homedir(), + xdgDataHome: input.env.XDG_DATA_HOME, + joinPath: NodePath.join, + directoryExists: directoryExistsSync, + }); +} diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 82061c0d8c84..d49ba0821d5b 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -239,7 +239,8 @@ server.listen(0, "127.0.0.1", () => { const stop = yield* spawner.spawn( ChildProcess.make("/bin/sh", ["-s"], { cwd: fixture, - env: { T3_TEST_STATE_DIR: fixture }, + // The script resolves the T3 home from HOME before STATE_DIR is overridden. + env: { T3_TEST_STATE_DIR: fixture, HOME: fixture, PATH: "/usr/bin:/bin" }, stdin: Stream.make(new TextEncoder().encode(isolatedScript)), }), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 413497bc0525..81a99df5cce2 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -447,8 +447,14 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE exit 1 `; +const REMOTE_HOME_SCRIPT = `if [ -z "\${T3CODE_HOME:-}" ] && [ "$(uname -s 2>/dev/null || true)" = Linux ]; then + T3CODE_HOME="\${XDG_DATA_HOME:-$HOME/.local/share}/t3code" + if [ ! -d "$T3CODE_HOME" ] && [ -d "$HOME/.t3" ]; then T3CODE_HOME="$HOME/.t3"; fi +fi`; + const REMOTE_LAUNCH_SCRIPT = `set -eu @@T3_NODE_ENV_SCRIPT@@ +${REMOTE_HOME_SCRIPT} STATE_KEY="$1" STATE_DIR="\${T3CODE_HOME:-$HOME/.t3}/ssh-launch/$STATE_KEY" DEFAULT_SERVER_HOME="\${T3CODE_HOME:-$HOME/.t3}" @@ -607,6 +613,7 @@ printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGE `; const REMOTE_PAIRING_SCRIPT = `set -eu +${REMOTE_HOME_SCRIPT} STATE_DIR="\${T3CODE_HOME:-$HOME/.t3}/ssh-launch/@@T3_STATE_KEY@@" DEFAULT_SERVER_HOME="\${T3CODE_HOME:-$HOME/.t3}" RUNNER_FILE="$STATE_DIR/run-t3.sh" @@ -620,6 +627,7 @@ PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" `; const REMOTE_STOP_SCRIPT = `set -eu +${REMOTE_HOME_SCRIPT} STATE_DIR="\${T3CODE_HOME:-$HOME/.t3}/ssh-launch/@@T3_STATE_KEY@@" PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" @@ -643,6 +651,7 @@ printf '{"stopped":true}\\n' `; const REMOTE_LOG_TAIL_SCRIPT = `set -eu +${REMOTE_HOME_SCRIPT} STATE_DIR="\${T3CODE_HOME:-$HOME/.t3}/ssh-launch/@@T3_STATE_KEY@@" LOG_FILE="$STATE_DIR/server.log" if [ -f "$LOG_FILE" ]; then