diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 2f79ea9d5..f04820b3d 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -33,6 +33,7 @@ function makeSnapshot(input: { workspaceRoot: input.workspaceRoot, defaultModel: null, scripts: [], + dotenvSync: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7..44e485ad2 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -65,3 +65,19 @@ export type GitManagerServiceError = | GitCommandError | GitHubCliError | TextGenerationError; + +/** + * WorktreeDotenvSyncError - Project dotenv sync into a worktree failed. + */ +export class WorktreeDotenvSyncError extends Schema.TaggedErrorClass()( + "WorktreeDotenvSyncError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Worktree dotenv sync failed in ${this.operation}: ${this.detail}`; + } +} diff --git a/apps/server/src/git/Layers/WorktreeDotenvSync.ts b/apps/server/src/git/Layers/WorktreeDotenvSync.ts new file mode 100644 index 000000000..f1d5405bc --- /dev/null +++ b/apps/server/src/git/Layers/WorktreeDotenvSync.ts @@ -0,0 +1,121 @@ +import { normalizeDotenvSyncPaths } from "@t3tools/shared/dotenvSync"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { WorktreeDotenvSyncError } from "../Errors.ts"; +import { WorktreeDotenvSync, type WorktreeDotenvSyncShape } from "../Services/WorktreeDotenvSync.ts"; + +function toWorktreeDotenvSyncError(operation: string, detail: string, cause?: unknown) { + return new WorktreeDotenvSyncError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const makeWorktreeDotenvSync = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const syncFiles: WorktreeDotenvSyncShape["syncFiles"] = (input) => + Effect.gen(function* () { + const normalizedPathsResult = normalizeDotenvSyncPaths(input.paths); + if (normalizedPathsResult.error) { + return yield* toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + normalizedPathsResult.error, + ); + } + + const sourceRoot = path.resolve(input.cwd); + const worktreeRoot = path.resolve(input.worktreePath); + const plans = yield* Effect.forEach( + normalizedPathsResult.normalizedPaths, + (relativePath) => + Effect.gen(function* () { + const sourcePath = path.resolve(sourceRoot, relativePath); + const targetPath = path.resolve(worktreeRoot, relativePath); + const sourceRelative = path.relative(sourceRoot, sourcePath).replaceAll("\\", "/"); + const targetRelative = path.relative(worktreeRoot, targetPath).replaceAll("\\", "/"); + if ( + sourceRelative.startsWith("../") || + sourceRelative === ".." || + targetRelative.startsWith("../") || + targetRelative === ".." + ) { + return yield* toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Resolved dotenv path escapes the workspace: ${relativePath}`, + ); + } + + const sourceInfo = yield* fileSystem.stat(sourcePath).pipe( + Effect.mapError((cause) => + toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Dotenv source file does not exist: ${relativePath}`, + cause, + ), + ), + ); + if (sourceInfo.type !== "File") { + return yield* toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Dotenv source is not a file: ${relativePath}`, + ); + } + + return { + relativePath, + sourcePath, + targetPath, + }; + }), + { concurrency: 1 }, + ); + + yield* Effect.forEach( + plans, + (plan) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(plan.targetPath), { recursive: true }).pipe( + Effect.mapError((cause) => + toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Failed to prepare worktree path for ${plan.relativePath}`, + cause, + ), + ), + ); + const contents = yield* fileSystem.readFile(plan.sourcePath).pipe( + Effect.mapError((cause) => + toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Failed to read dotenv source file: ${plan.relativePath}`, + cause, + ), + ), + ); + yield* fileSystem.writeFile(plan.targetPath, contents).pipe( + Effect.mapError((cause) => + toWorktreeDotenvSyncError( + "WorktreeDotenvSync.syncFiles", + `Failed to copy dotenv file into worktree: ${plan.relativePath}`, + cause, + ), + ), + ); + }), + { concurrency: 1 }, + ); + + return { + copiedPaths: normalizedPathsResult.normalizedPaths, + }; + }); + + return { + syncFiles, + } satisfies WorktreeDotenvSyncShape; +}); + +export const WorktreeDotenvSyncLive = Layer.effect(WorktreeDotenvSync, makeWorktreeDotenvSync); diff --git a/apps/server/src/git/Services/WorktreeDotenvSync.ts b/apps/server/src/git/Services/WorktreeDotenvSync.ts new file mode 100644 index 000000000..be7d0c8b7 --- /dev/null +++ b/apps/server/src/git/Services/WorktreeDotenvSync.ts @@ -0,0 +1,16 @@ +import { type GitSyncWorktreeDotenvFilesInput, type GitSyncWorktreeDotenvFilesResult } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { WorktreeDotenvSyncError } from "../Errors.ts"; + +export interface WorktreeDotenvSyncShape { + readonly syncFiles: ( + input: GitSyncWorktreeDotenvFilesInput, + ) => Effect.Effect; +} + +export class WorktreeDotenvSync extends ServiceMap.Service< + WorktreeDotenvSync, + WorktreeDotenvSyncShape +>()("t3/git/Services/WorktreeDotenvSync") {} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 24b81d514..4453c1bf9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -366,6 +366,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { workspaceRoot: event.payload.workspaceRoot, defaultModel: event.payload.defaultModel, scripts: event.payload.scripts, + dotenvSync: event.payload.dotenvSync, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, deletedAt: null, @@ -389,6 +390,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ? { defaultModel: event.payload.defaultModel } : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), + ...(event.payload.dotenvSync !== undefined + ? { dotenvSync: event.payload.dotenvSync } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e7e9cd4e1..b5f111019 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -223,6 +223,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + dotenvSync: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5fd38a540..b6c5ab0ce 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -5,6 +5,7 @@ import { NonNegativeInt, OrchestrationCheckpointFile, OrchestrationReadModel, + ProjectDotenvSyncConfig, ProjectScript, TurnId, type OrchestrationCheckpointSummary, @@ -44,6 +45,7 @@ const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + dotenvSync: Schema.NullOr(Schema.fromJsonString(ProjectDotenvSyncConfig)), }), ); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( @@ -139,6 +141,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", scripts_json AS "scripts", + dotenv_sync_json AS "dotenvSync", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -519,6 +522,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModel: row.defaultModel, scripts: row.scripts, + dotenvSync: row.dotenvSync, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index f95e4db75..1fa8e699d 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -30,6 +30,7 @@ const readModel: OrchestrationReadModel = { workspaceRoot: "/tmp/project-a", defaultModel: "gpt-5-codex", scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, deletedAt: null, @@ -40,6 +41,7 @@ const readModel: OrchestrationReadModel = { workspaceRoot: "/tmp/project-b", defaultModel: "gpt-5-codex", scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, deletedAt: null, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a2..0ceecd0a6 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -61,6 +61,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/scripts", defaultModel: null, scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, }, @@ -115,6 +116,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModel: null, scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, }, @@ -222,6 +224,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModel: null, scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, }, @@ -301,6 +304,7 @@ describe("decider project scripts", () => { workspaceRoot: "/tmp/project", defaultModel: null, scripts: [], + dotenvSync: null, createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2218f88cf..b05f67791 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -79,6 +79,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" workspaceRoot: command.workspaceRoot, defaultModel: command.defaultModel ?? null, scripts: [], + dotenvSync: null, createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -106,6 +107,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), + ...(command.dotenvSync !== undefined ? { dotenvSync: command.dotenvSync } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c0badfe95..9d2662d02 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -183,6 +183,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModel: payload.defaultModel, scripts: payload.scripts, + dotenvSync: payload.dotenvSync, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -215,6 +216,9 @@ export function projectEvent( ? { defaultModel: payload.defaultModel } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), + ...(payload.dotenvSync !== undefined + ? { dotenvSync: payload.dotenvSync } + : {}), updatedAt: payload.updatedAt, } : project, diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 5dbc8c2d1..2404fdb3e 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -11,11 +11,14 @@ import { ProjectionProjectRepository, type ProjectionProjectRepositoryShape, } from "../Services/ProjectionProjects.ts"; -import { ProjectScript } from "@t3tools/contracts"; +import { ProjectDotenvSyncConfig, ProjectScript } from "@t3tools/contracts"; // Makes sure that the scripts are parsed from the JSON string the DB returns const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( - Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), + Struct.assign({ + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + dotenvSync: Schema.NullOr(Schema.fromJsonString(ProjectDotenvSyncConfig)), + }), ); function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { @@ -38,6 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model, scripts_json, + dotenv_sync_json, created_at, updated_at, deleted_at @@ -48,6 +52,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModel}, ${row.scripts}, + ${row.dotenvSync}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} @@ -58,6 +63,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model = excluded.default_model, scripts_json = excluded.scripts_json, + dotenv_sync_json = excluded.dotenv_sync_json, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -75,6 +81,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", scripts_json AS "scripts", + dotenv_sync_json AS "dotenvSync", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -94,6 +101,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model AS "defaultModel", scripts_json AS "scripts", + dotenv_sync_json AS "dotenvSync", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd..b4ab50e0e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_ProjectionProjectsDotenvSync.ts"; import { Effect } from "effect"; /** @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_ProjectionProjectsDotenvSync": Migration0014, }); /** diff --git a/apps/server/src/persistence/Migrations/014_ProjectionProjectsDotenvSync.ts b/apps/server/src/persistence/Migrations/014_ProjectionProjectsDotenvSync.ts new file mode 100644 index 000000000..cf628f244 --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_ProjectionProjectsDotenvSync.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN dotenv_sync_json TEXT + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 1380a9609..6db0e0eda 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,7 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { IsoDateTime, ProjectDotenvSyncConfig, ProjectId, ProjectScript } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModel: Schema.NullOr(Schema.String), scripts: Schema.Array(ProjectScript), + dotenvSync: Schema.NullOr(ProjectDotenvSyncConfig), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 05a1de149..48c2be292 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -291,9 +291,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding(session, threadId, { - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), - }); + yield* upsertSessionBinding( + session, + threadId, + input.providerOptions !== undefined + ? { providerOptions: input.providerOptions } + : {}, + ); yield* analytics.record("provider.session.started", { provider: session.provider, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b..5785d418c 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -32,6 +32,7 @@ import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; +import { WorktreeDotenvSyncLive } from "./git/Layers/WorktreeDotenvSync"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; @@ -123,6 +124,7 @@ export function makeServerRuntimeServicesLayer() { orchestrationReactorLayer, gitCoreLayer, gitManagerLayer, + WorktreeDotenvSyncLive, terminalLayer, KeybindingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index ca8435336..3e23d03f5 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { afterEach, assert, describe, it, vi } from "vitest"; -import { searchWorkspaceEntries } from "./workspaceEntries"; +import { listWorkspaceDotenvEntries, searchWorkspaceEntries } from "./workspaceEntries"; const tempDirs: string[] = []; @@ -43,6 +43,8 @@ describe("searchWorkspaceEntries", () => { writeFile(cwd, "src/index.ts"); writeFile(cwd, "README.md"); writeFile(cwd, ".git/HEAD"); + writeFile(cwd, ".turn/state/session.json"); + writeFile(cwd, "apps/web/node_modules/pkg/index.js"); writeFile(cwd, "node_modules/pkg/index.js"); const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); @@ -53,6 +55,8 @@ describe("searchWorkspaceEntries", () => { assert.include(paths, "src/components/Composer.tsx"); assert.include(paths, "README.md"); assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git"))); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".turn"))); + assert.isFalse(paths.some((entryPath) => entryPath.includes("/node_modules/"))); assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules"))); assert.isFalse(result.truncated); }); @@ -118,6 +122,33 @@ describe("searchWorkspaceEntries", () => { assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); }); + it("lists dotenv files even when they are gitignored", async () => { + const cwd = makeTempDir("t3code-workspace-dotenv-entries-"); + runGit(cwd, ["init"]); + writeFile(cwd, ".gitignore", ".env.local\napps/web/.env.local\n"); + writeFile(cwd, ".env.example", "ROOT=example"); + writeFile(cwd, ".env.local", "ROOT=local"); + writeFile(cwd, "apps/web/.env.local", "APP=local"); + writeFile(cwd, ".next/.env.local", "IGNORED=true"); + + const result = await listWorkspaceDotenvEntries({ cwd, limit: 100 }); + + assert.deepEqual(result.entries, [".env.example", ".env.local", "apps/web/.env.local"]); + assert.isFalse(result.truncated); + }); + + it("excludes nested ignored directories from dotenv scans", async () => { + const cwd = makeTempDir("t3code-workspace-nested-dotenv-ignore-"); + writeFile(cwd, "apps/web/.env.local", "APP=local"); + writeFile(cwd, "apps/web/node_modules/pkg/.env", "IGNORED=true"); + writeFile(cwd, "packages/site/.next/cache/.env.local", "IGNORED=true"); + + const result = await listWorkspaceDotenvEntries({ cwd, limit: 100 }); + + assert.deepEqual(result.entries, ["apps/web/.env.local"]); + assert.isFalse(result.truncated); + }); + it("deduplicates concurrent index builds for the same cwd", async () => { const cwd = makeTempDir("t3code-workspace-concurrent-build-"); writeFile(cwd, "src/components/Composer.tsx"); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 2c215bb6d..6dc8e0a67 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -5,6 +5,8 @@ import { runProcess } from "./processRunner"; import { ProjectEntry, + ProjectListDotenvEntriesInput, + ProjectListDotenvEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -19,6 +21,7 @@ const IGNORED_DIRECTORY_NAMES = new Set([ ".convex", "node_modules", ".next", + ".turn", ".turbo", "dist", "build", @@ -79,9 +82,9 @@ function scoreEntry(entry: ProjectEntry, query: string): number { } function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); + return relativePath + .split("/") + .some((segment) => segment.length > 0 && IGNORED_DIRECTORY_NAMES.has(segment)); } function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { @@ -429,3 +432,84 @@ export async function searchWorkspaceEntries( truncated: index.truncated || ranked.length > input.limit, }; } + +export async function listWorkspaceDotenvEntries( + input: ProjectListDotenvEntriesInput, +): Promise { + const entries: string[] = []; + let truncated = false; + let pendingDirectories: string[] = [""]; + + while (pendingDirectories.length > 0 && !truncated) { + const currentDirectories = pendingDirectories; + pendingDirectories = []; + const directoryEntries = await mapWithConcurrency( + currentDirectories, + WORKSPACE_SCAN_READDIR_CONCURRENCY, + async (relativeDir) => { + const absoluteDir = relativeDir ? path.join(input.cwd, relativeDir) : input.cwd; + try { + const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); + return { relativeDir, dirents }; + } catch (error) { + if (!relativeDir) { + throw new Error( + `Unable to scan dotenv files at '${input.cwd}': ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + return { relativeDir, dirents: null }; + } + }, + ); + + for (const directoryEntry of directoryEntries) { + if (!directoryEntry.dirents) { + continue; + } + + directoryEntry.dirents.sort((left, right) => left.name.localeCompare(right.name)); + for (const dirent of directoryEntry.dirents) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + + const relativePath = toPosixPath( + directoryEntry.relativeDir + ? path.join(directoryEntry.relativeDir, dirent.name) + : dirent.name, + ); + + if (dirent.isDirectory()) { + if (IGNORED_DIRECTORY_NAMES.has(dirent.name) || isPathInIgnoredDirectory(relativePath)) { + continue; + } + pendingDirectories.push(relativePath); + continue; + } + + if (!dirent.isFile() || !dirent.name.startsWith(".env")) { + continue; + } + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } + + entries.push(relativePath); + if (entries.length >= input.limit) { + truncated = true; + break; + } + } + + if (truncated) { + break; + } + } + } + + return { + entries, + truncated, + }; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index aee34cc88..19b0bdb21 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -47,9 +47,10 @@ import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; +import { WorktreeDotenvSync } from "./git/Services/WorktreeDotenvSync.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; -import { searchWorkspaceEntries } from "./workspaceEntries"; +import { listWorkspaceDotenvEntries, searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; @@ -214,6 +215,7 @@ export type ServerCoreRuntimeServices = export type ServerRuntimeServices = | ServerCoreRuntimeServices | GitManager + | WorktreeDotenvSync | GitCore | TerminalManager | Keybindings @@ -252,6 +254,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const availableEditors = resolveAvailableEditors(); const gitManager = yield* GitManager; + const worktreeDotenvSync = yield* WorktreeDotenvSync; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; @@ -786,6 +789,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } + case WS_METHODS.projectsListDotenvEntries: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => listWorkspaceDotenvEntries(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list dotenv files: ${String(cause)}`, + }), + }); + } + case WS_METHODS.projectsWriteFile: { const body = stripRequestTag(request.body); const target = yield* resolveWorkspaceWritePath({ @@ -847,6 +861,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* git.removeWorktree(body); } + case WS_METHODS.gitSyncWorktreeDotenvFiles: { + const body = stripRequestTag(request.body); + return yield* worktreeDotenvSync.syncFiles(body); + } + case WS_METHODS.gitCreateBranch: { const body = stripRequestTag(request.body); return yield* git.createBranch(body); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0f0250e48..5e74f4175 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -194,6 +194,7 @@ function createSnapshotForTargetUser(options: { workspaceRoot: "/repo/project", defaultModel: "gpt-5", scripts: [], + dotenvSync: null, createdAt: NOW_ISO, updatedAt: NOW_ISO, deletedAt: null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1023b37e1..1b70987f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,6 +7,7 @@ import { type CodexReasoningEffort, type MessageId, type ProjectId, + type ProjectDotenvSyncConfig, type ProjectEntry, type ProjectScript, type ModelSlug, @@ -29,6 +30,7 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; +import { normalizeDotenvSyncPath } from "@t3tools/shared/dotenvSync"; import { memo, useCallback, @@ -1532,6 +1534,83 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [queryClient], ); + const saveProjectDotenvSync = useCallback( + async (dotenvSync: ProjectDotenvSyncConfig | null) => { + if (!activeProject) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: activeProject.id, + dotenvSync, + }); + }, + [activeProject], + ); + const runProjectDotenvSync = useCallback(async () => { + if (!activeProject) { + throw new Error("Project dotenv sync is unavailable."); + } + if (!activeThread?.worktreePath) { + throw new Error("Open a worktree thread before running dotenv sync."); + } + const paths = activeProject.dotenvSync?.paths ?? []; + if (paths.length === 0) { + throw new Error("Save at least one dotenv path before syncing."); + } + + const api = readNativeApi(); + if (!api) { + throw new Error("Native API unavailable."); + } + + try { + const result = await api.git.syncWorktreeDotenvFiles({ + cwd: activeProject.cwd, + worktreePath: activeThread.worktreePath, + paths, + }); + toastManager.add({ + type: "success", + title: + result.copiedPaths.length === 1 + ? `Synced ${result.copiedPaths[0]}` + : `Synced ${result.copiedPaths.length} dotenv files`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not sync dotenv files", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + throw error; + } + }, [activeProject, activeThread?.worktreePath]); + const detectProjectDotenvSyncPaths = useCallback(async () => { + if (!activeProject) { + throw new Error("Project dotenv sync is unavailable."); + } + + const api = readNativeApi(); + if (!api) { + throw new Error("Native API unavailable."); + } + + const result = await api.projects.listDotenvEntries({ + cwd: activeProject.cwd, + limit: 200, + }); + + return result.entries + .map((entry) => normalizeDotenvSyncPath(entry).normalizedPath) + .filter((entry): entry is string => Boolean(entry)) + .toSorted((left, right) => left.localeCompare(right)); + }, [activeProject]); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { if (!activeProject) return; @@ -2558,6 +2637,7 @@ export default function ChatView({ threadId }: ChatViewProps) { let turnStartSucceeded = false; let nextThreadBranch = activeThread.branch; let nextThreadWorktreePath = activeThread.worktreePath; + const dotenvSyncPaths = activeProject.dotenvSync?.paths ?? []; await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { @@ -2581,6 +2661,20 @@ export default function ChatView({ threadId }: ChatViewProps) { // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + } else if (isLocalDraftThread) { + setDraftThreadContext(threadIdForSend, { + branch: result.worktree.branch, + worktreePath: result.worktree.path, + envMode: "worktree", + }); + } + + if (result.worktree.path && dotenvSyncPaths.length > 0) { + await api.git.syncWorktreeDotenvFiles({ + cwd: activeProject.cwd, + worktreePath: result.worktree.path, + paths: dotenvSyncPaths, + }); } } @@ -2603,41 +2697,17 @@ export default function ChatView({ threadId }: ChatViewProps) { let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - model: threadCreateModel, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } - let setupScript: ProjectScript | null = null; if (baseBranchForWorktree) { setupScript = setupProjectScript(activeProject.scripts); } if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } + const shouldRunSetupScript = isServerThread || isLocalDraftThread; if (shouldRunSetupScript) { const setupScriptOptions: Parameters[1] = { worktreePath: nextThreadWorktreePath, rememberAsLastInvoked: false, - allowLocalDraftThread: createdServerThreadForLocalDraft, + allowLocalDraftThread: isLocalDraftThread, }; if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; @@ -2646,6 +2716,23 @@ export default function ChatView({ threadId }: ChatViewProps) { } } + if (isLocalDraftThread) { + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: threadIdForSend, + projectId: activeProject.id, + title, + model: threadCreateModel, + runtimeMode, + interactionMode, + branch: nextThreadBranch, + worktreePath: nextThreadWorktreePath, + createdAt: activeThread.createdAt, + }); + createdServerThreadForLocalDraft = true; + } + // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ @@ -3463,6 +3550,9 @@ export default function ChatView({ threadId }: ChatViewProps) { isGitRepo={isGitRepo} openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} activeProjectScripts={activeProject?.scripts} + activeProjectDotenvSync={activeProject?.dotenvSync ?? null} + activeProjectRootPath={activeProject?.cwd ?? null} + activeWorktreePath={activeThread.worktreePath ?? null} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -3474,6 +3564,9 @@ export default function ChatView({ threadId }: ChatViewProps) { onRunProjectScript={(script) => { void runProjectScript(script); }} + onSaveProjectDotenvSync={saveProjectDotenvSync} + onRunProjectDotenvSync={runProjectDotenvSync} + onDetectProjectDotenvSyncPaths={detectProjectDotenvSyncPaths} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} @@ -4149,6 +4242,9 @@ interface ChatHeaderProps { isGitRepo: boolean; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; + activeProjectDotenvSync: ProjectDotenvSyncConfig | null; + activeProjectRootPath: string | null; + activeWorktreePath: string | null; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; @@ -4156,6 +4252,9 @@ interface ChatHeaderProps { gitCwd: string | null; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; + onSaveProjectDotenvSync: (dotenvSync: ProjectDotenvSyncConfig | null) => Promise; + onRunProjectDotenvSync: () => Promise; + onDetectProjectDotenvSyncPaths: () => Promise; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; @@ -4169,6 +4268,9 @@ const ChatHeader = memo(function ChatHeader({ isGitRepo, openInCwd, activeProjectScripts, + activeProjectDotenvSync, + activeProjectRootPath, + activeWorktreePath, preferredScriptId, keybindings, availableEditors, @@ -4176,6 +4278,9 @@ const ChatHeader = memo(function ChatHeader({ gitCwd, diffOpen, onRunProjectScript, + onSaveProjectDotenvSync, + onRunProjectDotenvSync, + onDetectProjectDotenvSyncPaths, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, @@ -4206,9 +4311,15 @@ const ChatHeader = memo(function ChatHeader({ {activeProjectScripts && ( void; + onSave: (dotenvSync: ProjectDotenvSyncConfig | null) => Promise | void; + onRunSync: () => Promise | void; + onDetectPaths: () => Promise | string[]; +} + +export default function ProjectDotenvSyncDialog({ + open, + dotenvSync, + projectRootPath, + activeWorktreePath, + onOpenChange, + onSave, + onRunSync, + onDetectPaths, +}: ProjectDotenvSyncDialogProps) { + const [paths, setPaths] = useState(() => [...(dotenvSync?.paths ?? [])]); + const [newPath, setNewPath] = useState(""); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [isDetecting, setIsDetecting] = useState(false); + + useEffect(() => { + if (!open) { + return; + } + setPaths([...(dotenvSync?.paths ?? [])]); + setNewPath(""); + setError(null); + setNotice(null); + }, [dotenvSync, open]); + + const hasSavedPaths = (dotenvSync?.paths.length ?? 0) > 0; + const canRunSync = Boolean(activeWorktreePath) && hasSavedPaths && !isSaving && !isSyncing; + const isDirty = useMemo(() => { + const current = dotenvSync?.paths ?? []; + if (current.length !== paths.length) { + return true; + } + return current.some((path, index) => path !== paths[index]); + }, [dotenvSync, paths]); + + const mergePaths = useCallback( + (candidates: Iterable, duplicateMode: "ignore" | "reject") => { + const nextPaths = [...paths]; + const seen = new Set(nextPaths); + + for (const candidate of candidates) { + const result = normalizeDotenvSyncPath(candidate); + if (!result.normalizedPath) { + return { nextPaths: null, addedCount: 0, error: result.error ?? "Invalid dotenv path." }; + } + if (seen.has(result.normalizedPath)) { + if (duplicateMode === "reject") { + return { + nextPaths: null, + addedCount: 0, + error: `Dotenv path already added: ${result.normalizedPath}`, + }; + } + continue; + } + seen.add(result.normalizedPath); + nextPaths.push(result.normalizedPath); + } + + const validated = normalizeDotenvSyncPaths(nextPaths); + if (validated.error) { + return { nextPaths: null, addedCount: 0, error: validated.error }; + } + + return { + nextPaths: validated.normalizedPaths, + addedCount: validated.normalizedPaths.length - paths.length, + error: null, + }; + }, + [paths], + ); + + const addPath = () => { + const result = mergePaths([newPath], "reject"); + if (!result.nextPaths) { + setError(result.error); + setNotice(null); + return; + } + setPaths(result.nextPaths); + setNewPath(""); + setError(null); + setNotice(null); + }; + + const detectPaths = async () => { + setIsDetecting(true); + setError(null); + setNotice(null); + try { + const detectedPaths = await onDetectPaths(); + const result = mergePaths(detectedPaths, "ignore"); + if (!result.nextPaths) { + setError(result.error); + return; + } + setPaths(result.nextPaths); + setNotice( + result.addedCount > 0 + ? result.addedCount === 1 + ? "Added 1 detected dotenv file." + : `Added ${result.addedCount} detected dotenv files.` + : "No new dotenv files detected in this project.", + ); + } catch (detectError) { + setError( + detectError instanceof Error ? detectError.message : "Failed to detect dotenv files.", + ); + } finally { + setIsDetecting(false); + } + }; + + const save = async () => { + setIsSaving(true); + setError(null); + setNotice(null); + try { + await onSave(paths.length > 0 ? { paths } : null); + onOpenChange(false); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Failed to save dotenv sync."); + } finally { + setIsSaving(false); + } + }; + + const runSync = async () => { + if (!canRunSync) { + return; + } + setIsSyncing(true); + setError(null); + setNotice(null); + try { + await onRunSync(); + } catch (syncError) { + setError(syncError instanceof Error ? syncError.message : "Failed to sync dotenv files."); + } finally { + setIsSyncing(false); + } + }; + + return ( + + + + Dotenv Sync + + Copy selected dotenv files from the project root into newly created worktrees. + + + +
+ {projectRootPath ? ( +
+

Scanning project root

+

{projectRootPath}

+
+ ) : null} + +
+ +
+ { + setNewPath(event.target.value); + if (error) { + setError(null); + } + if (notice) { + setNotice(null); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + addPath(); + }} + /> + + + { + void detectPaths(); + }} + > + + + } + /> + + {isDetecting ? "Detecting dotenv files..." : "Detect dotenv files in project"} + + +
+

+ Use project-relative dotenv paths like .env.local or{" "} + apps/web/.env. +

+
+ +
+
+ Configured dotenv files: {paths.length} + {paths.length > 0 ? ( + + ) : null} +
+ + {paths.length > 0 ? ( +
+ {paths.map((path) => ( +
+ {path} + +
+ ))} +
+ ) : ( +
+ No dotenv files configured. +
+ )} +
+ + {activeWorktreePath ? ( +
+
+
+

Sync current worktree

+

+ {activeWorktreePath} +

+
+ +
+ {!hasSavedPaths ? ( +

+ Save at least one dotenv path to enable manual sync. +

+ ) : null} +
+ ) : null} + + {error ?

{error}

: null} + {!error && notice ?

{notice}

: null} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 437b3e78e..11d6ea53f 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,4 +1,5 @@ import type { + ProjectDotenvSyncConfig, ProjectScript, ProjectScriptIcon, ResolvedKeybindingsConfig, @@ -53,6 +54,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; +import ProjectDotenvSyncDialog from "./ProjectDotenvSyncDialog"; const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, @@ -88,9 +90,15 @@ export interface NewProjectScriptInput { interface ProjectScriptsControlProps { scripts: ProjectScript[]; + dotenvSync: ProjectDotenvSyncConfig | null; + projectRootPath: string | null; + activeWorktreePath: string | null; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; + onSaveDotenvSync: (dotenvSync: ProjectDotenvSyncConfig | null) => Promise | void; + onRunDotenvSync: () => Promise | void; + onDetectDotenvPaths: () => Promise | string[]; onAddScript: (input: NewProjectScriptInput) => Promise | void; onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; onDeleteScript: (scriptId: string) => Promise | void; @@ -149,9 +157,15 @@ function keybindingFromEvent(event: KeyboardEvent): string | n export default function ProjectScriptsControl({ scripts, + dotenvSync, + projectRootPath, + activeWorktreePath, keybindings, preferredScriptId = null, onRunScript, + onSaveDotenvSync, + onRunDotenvSync, + onDetectDotenvPaths, onAddScript, onUpdateScript, onDeleteScript, @@ -167,6 +181,7 @@ export default function ProjectScriptsControl({ const [keybinding, setKeybinding] = useState(""); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [dotenvDialogOpen, setDotenvDialogOpen] = useState(false); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -336,16 +351,40 @@ export default function ProjectScriptsControl({ Add action + setDotenvDialogOpen(true)}> + + Dotenv sync... + ) : ( - + + + + + } + > + + + + + + Add action + + setDotenvDialogOpen(true)}> + + Dotenv sync... + + + + )} + + ); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d..1a04c0687 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -43,6 +43,7 @@ function makeState(thread: Thread): AppState { model: "gpt-5-codex", expanded: true, scripts: [], + dotenvSync: null, }, ], threads: [thread], @@ -87,6 +88,7 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche updatedAt: "2026-02-27T00:00:00.000Z", deletedAt: null, scripts: [], + dotenvSync: null, }, ], threads: [thread], @@ -105,6 +107,7 @@ function makeReadModelProject( updatedAt: "2026-02-27T00:00:00.000Z", deletedAt: null, scripts: [], + dotenvSync: null, ...overrides, }; } @@ -162,6 +165,7 @@ describe("store pure functions", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, expanded: true, scripts: [], + dotenvSync: null, }, { id: project2, @@ -170,6 +174,7 @@ describe("store pure functions", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, expanded: true, scripts: [], + dotenvSync: null, }, { id: project3, @@ -178,6 +183,7 @@ describe("store pure functions", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, expanded: true, scripts: [], + dotenvSync: null, }, ], threads: [], @@ -217,6 +223,7 @@ describe("store read model sync", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, expanded: true, scripts: [], + dotenvSync: null, }, { id: project1, @@ -225,6 +232,7 @@ describe("store read model sync", () => { model: DEFAULT_MODEL_BY_PROVIDER.codex, expanded: true, scripts: [], + dotenvSync: null, }, ], threads: [], diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 0dbd025aa..5d2b07d22 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -145,6 +145,7 @@ function mapProjectsFromReadModel( ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), scripts: project.scripts.map((script) => ({ ...script })), + dotenvSync: project.dotenvSync ? { paths: [...project.dotenvSync.paths] } : null, } satisfies Project; }); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d5fff1299..82bc7d7b2 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -3,6 +3,7 @@ import type { OrchestrationProposedPlanId, OrchestrationSessionStatus, OrchestrationThreadActivity, + ProjectDotenvSyncConfig, ProjectScript as ContractProjectScript, ThreadId, ProjectId, @@ -81,6 +82,7 @@ export interface Project { model: string; expanded: boolean; scripts: ProjectScript[]; + dotenvSync: ProjectDotenvSyncConfig | null; } export interface Thread { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 91e6a6110..0eceb3992 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi { }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), + listDotenvEntries: (input) => transport.request(WS_METHODS.projectsListDotenvEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, shell: { @@ -167,6 +168,8 @@ export function createWsNativeApi(): NativeApi { listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), + syncWorktreeDotenvFiles: (input) => + transport.request(WS_METHODS.gitSyncWorktreeDotenvFiles, input), createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), init: (input) => transport.request(WS_METHODS.gitInit, input), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 80ede248e..5210ed778 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ProjectDotenvSyncPath } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -76,6 +77,13 @@ export const GitRemoveWorktreeInput = Schema.Struct({ }); export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; +export const GitSyncWorktreeDotenvFilesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + worktreePath: TrimmedNonEmptyStringSchema, + paths: Schema.Array(ProjectDotenvSyncPath), +}); +export type GitSyncWorktreeDotenvFilesInput = typeof GitSyncWorktreeDotenvFilesInput.Type; + export const GitCreateBranchInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, @@ -136,6 +144,11 @@ export const GitCreateWorktreeResult = Schema.Struct({ }); export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; +export const GitSyncWorktreeDotenvFilesResult = Schema.Struct({ + copiedPaths: Schema.Array(ProjectDotenvSyncPath), +}); +export type GitSyncWorktreeDotenvFilesResult = typeof GitSyncWorktreeDotenvFilesResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db9bab415..9be4537f0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -9,12 +9,16 @@ import type { GitPullInput, GitPullResult, GitRemoveWorktreeInput, + GitSyncWorktreeDotenvFilesInput, + GitSyncWorktreeDotenvFilesResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStatusInput, GitStatusResult, } from "./git"; import type { + ProjectListDotenvEntriesInput, + ProjectListDotenvEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -120,6 +124,9 @@ export interface NativeApi { }; projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; + listDotenvEntries: ( + input: ProjectListDotenvEntriesInput, + ) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; shell: { @@ -131,6 +138,9 @@ export interface NativeApi { listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; + syncWorktreeDotenvFiles: ( + input: GitSyncWorktreeDotenvFilesInput, + ) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index ecc59f0b9..5bd396730 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -129,12 +129,85 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +const PROJECT_DOTENV_SYNC_MAX_PATHS = 16; +const PROJECT_DOTENV_SYNC_PATH_MAX_LENGTH = 512; + +function normalizeDotenvSyncPathForSchema(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > PROJECT_DOTENV_SYNC_PATH_MAX_LENGTH) { + return null; + } + + const normalized = trimmed.replaceAll("\\", "/"); + if (normalized.startsWith("/") || normalized.startsWith("//") || /^[a-zA-Z]:\//.test(normalized)) { + return null; + } + + const resolved: string[] = []; + for (const rawSegment of normalized.split("/")) { + const segment = rawSegment.trim(); + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return null; + } + resolved.push(segment); + } + + const collapsed = resolved.join("/"); + if (collapsed.length === 0) { + return null; + } + + const fileName = collapsed.split("/").at(-1) ?? ""; + if (!fileName.startsWith(".env")) { + return null; + } + + return collapsed; +} + +export const ProjectDotenvSyncPath = TrimmedNonEmptyString.check( + Schema.isMaxLength(PROJECT_DOTENV_SYNC_PATH_MAX_LENGTH), + Schema.makeFilter( + (value) => + normalizeDotenvSyncPathForSchema(value) === value + ? true + : new SchemaIssue.InvalidValue(Option.some(value), { + message: + "Dotenv sync paths must be normalized relative project paths pointing to .env files.", + }), + { identifier: "ProjectDotenvSyncPath" }, + ), +); +export type ProjectDotenvSyncPath = typeof ProjectDotenvSyncPath.Type; + +export const ProjectDotenvSyncConfig = Schema.Struct({ + paths: Schema.Array(ProjectDotenvSyncPath).check( + Schema.makeFilter((paths) => paths.length <= PROJECT_DOTENV_SYNC_MAX_PATHS, { + identifier: "ProjectDotenvSyncPathsLength", + }), + Schema.makeFilter( + (paths) => + new Set(paths).size === paths.length + ? true + : new SchemaIssue.InvalidValue(Option.some(paths), { + message: "Dotenv sync paths must be unique.", + }), + { identifier: "ProjectDotenvSyncPathsUnique" }, + ), + ), +}); +export type ProjectDotenvSyncConfig = typeof ProjectDotenvSyncConfig.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, defaultModel: Schema.NullOr(TrimmedNonEmptyString), scripts: Schema.Array(ProjectScript), + dotenvSync: Schema.NullOr(ProjectDotenvSyncConfig).pipe(Schema.withDecodingDefault(() => null)), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -301,6 +374,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModel: Schema.optional(TrimmedNonEmptyString), scripts: Schema.optional(Schema.Array(ProjectScript)), + dotenvSync: Schema.optional(Schema.NullOr(ProjectDotenvSyncConfig)), }); const ProjectDeleteCommand = Schema.Struct({ @@ -593,6 +667,7 @@ export const ProjectCreatedPayload = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, defaultModel: Schema.NullOr(TrimmedNonEmptyString), scripts: Schema.Array(ProjectScript), + dotenvSync: Schema.NullOr(ProjectDotenvSyncConfig).pipe(Schema.withDecodingDefault(() => null)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -603,6 +678,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), + dotenvSync: Schema.optional(Schema.NullOr(ProjectDotenvSyncConfig)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 4d1450bac..e3a7826c7 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_DOTENV_ENTRIES_MAX_LIMIT = 200; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; export const ProjectSearchEntriesInput = Schema.Struct({ @@ -26,6 +27,18 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectListDotenvEntriesInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_DOTENV_ENTRIES_MAX_LIMIT)), +}); +export type ProjectListDotenvEntriesInput = typeof ProjectListDotenvEntriesInput.Type; + +export const ProjectListDotenvEntriesResult = Schema.Struct({ + entries: Schema.Array(TrimmedNonEmptyString), + truncated: Schema.Boolean, +}); +export type ProjectListDotenvEntriesResult = typeof ProjectListDotenvEntriesResult.Type; + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check( diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 1100b4f9d..6966add41 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -17,6 +17,7 @@ import { GitListBranchesInput, GitPullInput, GitRemoveWorktreeInput, + GitSyncWorktreeDotenvFilesInput, GitRunStackedActionInput, GitStatusInput, } from "./git"; @@ -29,7 +30,11 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { + ProjectListDotenvEntriesInput, + ProjectSearchEntriesInput, + ProjectWriteFileInput, +} from "./project"; import { OpenInEditorInput } from "./editor"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -40,6 +45,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", + projectsListDotenvEntries: "projects.listDotenvEntries", projectsWriteFile: "projects.writeFile", // Shell methods @@ -52,6 +58,7 @@ export const WS_METHODS = { gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", gitRemoveWorktree: "git.removeWorktree", + gitSyncWorktreeDotenvFiles: "git.syncWorktreeDotenvFiles", gitCreateBranch: "git.createBranch", gitCheckout: "git.checkout", gitInit: "git.init", @@ -102,6 +109,7 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), + tagRequestBody(WS_METHODS.projectsListDotenvEntries, ProjectListDotenvEntriesInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), // Shell methods @@ -114,6 +122,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), + tagRequestBody(WS_METHODS.gitSyncWorktreeDotenvFiles, GitSyncWorktreeDotenvFilesInput), tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), tagRequestBody(WS_METHODS.gitInit, GitInitInput), diff --git a/packages/shared/package.json b/packages/shared/package.json index b1a94c760..14ecf0eed 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,10 @@ "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" + }, + "./dotenvSync": { + "types": "./src/dotenvSync.ts", + "import": "./src/dotenvSync.ts" } }, "scripts": { diff --git a/packages/shared/src/dotenvSync.ts b/packages/shared/src/dotenvSync.ts new file mode 100644 index 000000000..e0cdacf89 --- /dev/null +++ b/packages/shared/src/dotenvSync.ts @@ -0,0 +1,102 @@ +const MAX_DOTENV_SYNC_PATHS = 16; +const MAX_DOTENV_SYNC_PATH_LENGTH = 512; + +function normalizeSeparators(value: string): string { + return value.replaceAll("\\", "/"); +} + +function collapseRelativeSegments(value: string): string | null { + const normalized = normalizeSeparators(value); + const segments = normalized.split("/"); + const resolved: string[] = []; + + for (const rawSegment of segments) { + const segment = rawSegment.trim(); + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return null; + } + resolved.push(segment); + } + + return resolved.join("/"); +} + +export interface NormalizeDotenvSyncPathResult { + normalizedPath: string | null; + error: string | null; +} + +export function normalizeDotenvSyncPath(input: string): NormalizeDotenvSyncPathResult { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return { normalizedPath: null, error: "Path is required." }; + } + if (trimmed.length > MAX_DOTENV_SYNC_PATH_LENGTH) { + return { + normalizedPath: null, + error: `Path must be ${MAX_DOTENV_SYNC_PATH_LENGTH} characters or less.`, + }; + } + + const normalized = normalizeSeparators(trimmed); + if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//")) { + return { normalizedPath: null, error: "Path must be relative to the project root." }; + } + + const collapsed = collapseRelativeSegments(normalized); + if (!collapsed) { + return { normalizedPath: null, error: "Path must stay within the project root." }; + } + + const fileName = collapsed.split("/").at(-1) ?? ""; + if (!fileName.startsWith(".env")) { + return { normalizedPath: null, error: "Path must point to a dotenv file." }; + } + + return { normalizedPath: collapsed, error: null }; +} + +export function normalizeDotenvSyncPaths( + paths: Iterable, +): NormalizeDotenvSyncPathResult & { normalizedPaths: string[] } { + const normalizedPaths: string[] = []; + const seen = new Set(); + + for (const candidate of paths) { + const result = normalizeDotenvSyncPath(candidate ?? ""); + if (!result.normalizedPath) { + return { + normalizedPath: null, + normalizedPaths: [], + error: result.error, + }; + } + if (seen.has(result.normalizedPath)) { + return { + normalizedPath: null, + normalizedPaths: [], + error: `Duplicate dotenv path: ${result.normalizedPath}`, + }; + } + seen.add(result.normalizedPath); + normalizedPaths.push(result.normalizedPath); + if (normalizedPaths.length > MAX_DOTENV_SYNC_PATHS) { + return { + normalizedPath: null, + normalizedPaths: [], + error: `You can sync up to ${MAX_DOTENV_SYNC_PATHS} dotenv files.`, + }; + } + } + + return { + normalizedPath: normalizedPaths[0] ?? null, + normalizedPaths, + error: null, + }; +} + +export { MAX_DOTENV_SYNC_PATH_LENGTH, MAX_DOTENV_SYNC_PATHS };