diff --git a/apps/server/package.json b/apps/server/package.json index 7d27d39d2eb3..98fdb905afeb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -33,6 +33,7 @@ "node-pty": "^1.1.0", "stream-chain": "^4.2.5", "stream-json": "3.6.0", + "tar": "7.5.16", "yaml": "catalog:", "yauzl": "^3.4.0" }, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..95e814adc073 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -97,6 +97,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsTransfer]: AuthOrchestrationOperateScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index bf02cd90fbdf..95fc1b49e745 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -214,6 +214,7 @@ export const make = Effect.gen(function* () { serverVersion: packageJson.version, capabilities: { repositoryIdentity: true, + projectTransfer: true, connectionProbe: true, attachmentUploads: true, questionAttachments: true, diff --git a/apps/server/src/project/ProjectTransfer.ts b/apps/server/src/project/ProjectTransfer.ts new file mode 100644 index 000000000000..bc21e669f888 --- /dev/null +++ b/apps/server/src/project/ProjectTransfer.ts @@ -0,0 +1,301 @@ +import * as Path from "effect/Path"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; +import { + CommandId, + ProjectId, + ProjectTransferError, + type ProjectTransferConfiguration, + type ProjectTransferInput, + type ProjectTransferResult, +} from "@t3tools/contracts"; +import { resolveProjectScripts } from "@t3tools/shared/projectScripts"; +import { + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, +} from "@t3tools/shared/serverSettings"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { ProjectTransferFiles } from "./ProjectTransferFiles.ts"; + +const isProjectTransferError = Schema.is(ProjectTransferError); + +interface Transfer { + directory: string; + file: string; + byteLength: number; + configuration: ProjectTransferConfiguration; + destination?: string; + mode: "clone" | "copy"; + projectId: ProjectId; + createdAt: string; + unpacked: boolean; + registered: boolean; +} + +export const makeProjectTransfer = Effect.fn("makeProjectTransfer")(function* () { + const path = yield* Path.Path; + const projects = yield* ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngineService; + const settings = yield* ServerSettingsService; + const git = yield* GitVcsDriver; + const files = new ProjectTransferFiles(); + const transfers = new Map(); + const semaphore = yield* Semaphore.make(1); + const io = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (error) => + new ProjectTransferError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + const release = Effect.fn("ProjectTransfer.release")(function* (id: string) { + const transfer = transfers.get(id); + if (!transfer) return; + if (transfer.destination && !transfer.registered) + yield* io(() => files.remove(transfer.destination!)); + yield* io(() => files.remove(transfer.directory)); + transfers.delete(id); + }); + yield* Effect.addFinalizer(() => + Effect.forEach([...transfers.keys()], release).pipe(Effect.ignore), + ); + + const handle = Effect.fn("ProjectTransfer.handle")( + function* (input: ProjectTransferInput): Effect.fn.Return { + if (input.operation === "release") { + yield* release(input.transferId); + return { operation: "release" }; + } + if (input.operation === "prepare") { + const found = yield* projects.getProjectShellById(input.projectId); + if (Option.isNone(found)) + return yield* new ProjectTransferError({ + message: "The source project no longer exists.", + }); + const project = found.value; + const currentSettings = yield* settings.getSettings; + const isGit = yield* git + .execute({ + operation: "project.transfer.detect", + cwd: project.workspaceRoot, + args: ["rev-parse", "--is-inside-work-tree"], + timeoutMs: 10_000, + }) + .pipe( + Effect.map((result) => result.stdout.trim() === "true"), + Effect.orElseSucceed(() => false), + ); + const remote = yield* git + .execute({ + operation: "project.transfer.remote", + cwd: project.workspaceRoot, + args: ["remote", "get-url", "origin"], + timeoutMs: 10_000, + }) + .pipe( + Effect.map((result) => result.stdout.trim() || null), + Effect.catch(() => Effect.succeed(null)), + ); + if (input.mode === "clone" && !remote) + return yield* new ProjectTransferError({ + message: "This project has no origin remote. Choose a one-time copy.", + }); + const configuration: ProjectTransferConfiguration = { + project: { + ...project, + defaultModelSelection: + project.defaultModelSelection ?? currentSettings.defaultModelSelection, + defaultThreadEnvMode: + project.defaultThreadEnvMode ?? currentSettings.defaultThreadEnvMode, + autoPull: resolveProjectAutoPull(currentSettings, project.id, project.autoPull), + scripts: resolveProjectScripts(currentSettings, project), + }, + agentBrowserAccess: resolveProjectAgentBrowserAccess(currentSettings, project.id), + remoteUrl: remote, + }; + const directory = yield* io(() => files.temporaryDirectory()); + const transferId = files.id(); + const transfer: Transfer = { + directory, + file: path.join(directory, "snapshot.tar"), + byteLength: 0, + configuration, + mode: input.mode, + projectId: ProjectId.make(files.id()), + createdAt: DateTime.formatIso(yield* DateTime.now), + unpacked: false, + registered: false, + }; + transfers.set(transferId, transfer); + yield* Effect.gen(function* () { + if (input.mode === "copy") { + const ignored = new Set(); + if (!input.includeIgnored && isGit) { + const result = yield* git.execute({ + operation: "project.transfer.ignored", + cwd: project.workspaceRoot, + args: [ + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + "-z", + ], + maxOutputBytes: 32 * 1024 * 1024, + }); + if (result.stdoutTruncated) + return yield* new ProjectTransferError({ + message: + "Too many ignored files to list. Include ignored files or use a fresh clone.", + }); + for (const name of result.stdout.split("\0")) + if (name) ignored.add(name.replace(/\/$/, "")); + } + let snapshotRoot = project.workspaceRoot; + if (yield* io(() => files.isLinkedCheckout(project.workspaceRoot))) { + // A worktree's .git file points outside its root. Materialize an independent + // repository, then restore the source index so staged work stays staged. + snapshotRoot = path.join(directory, "checkout"); + yield* git.execute({ + operation: "project.transfer.materialize", + cwd: directory, + args: [ + "clone", + "--no-hardlinks", + "--no-checkout", + "--", + project.workspaceRoot, + snapshotRoot, + ], + timeoutMs: 600_000, + }); + const index = yield* git.execute({ + operation: "project.transfer.index", + cwd: project.workspaceRoot, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + }); + yield* io(() => + files.overlayCheckout(project.workspaceRoot, snapshotRoot, index.stdout.trim()), + ); + yield* git.execute({ + operation: "project.transfer.origin", + cwd: snapshotRoot, + args: remote + ? ["remote", "set-url", "origin", remote] + : ["remote", "remove", "origin"], + }); + } + const archive = yield* io(() => files.pack(snapshotRoot, directory, ignored)); + transfer.byteLength = archive.byteLength; + } + }).pipe(Effect.onError(() => release(transferId).pipe(Effect.ignore))); + return { operation: "prepare", transferId, configuration, byteLength: transfer.byteLength }; + } + if (input.operation === "begin") { + if (input.mode === "clone" && !input.configuration.remoteUrl) + return yield* new ProjectTransferError({ + message: "A fresh clone requires a repository remote.", + }); + const directory = yield* io(() => files.temporaryDirectory()); + const destination = yield* io(() => files.reserve(input.destinationPath)).pipe( + Effect.onError(() => io(() => files.remove(directory)).pipe(Effect.ignore)), + ); + const transferId = files.id(); + transfers.set(transferId, { + directory, + file: path.join(directory, "snapshot.tar"), + destination, + mode: input.mode, + configuration: input.configuration, + byteLength: input.byteLength, + projectId: ProjectId.make(files.id()), + createdAt: DateTime.formatIso(yield* DateTime.now), + unpacked: false, + registered: false, + }); + return { operation: "begin", transferId }; + } + const transfer = transfers.get(input.transferId); + if (!transfer) + return yield* new ProjectTransferError({ + message: "This transfer expired. Start the copy again.", + }); + if (input.operation === "read") { + if (transfer.destination) + return yield* new ProjectTransferError({ message: "This is not a source snapshot." }); + return { + operation: "read", + data: yield* io(() => files.read(transfer.file, input.offset, transfer.byteLength)), + }; + } + if (!transfer.destination) + return yield* new ProjectTransferError({ message: "This is not a destination transfer." }); + if (input.operation === "write") { + if (transfer.unpacked) + return yield* new ProjectTransferError({ message: "This copy is already complete." }); + yield* io(() => files.write(transfer.file, input.offset, input.data, transfer.byteLength)); + return { operation: "write" }; + } + const cwd = transfer.destination; + if (!transfer.unpacked) { + if (transfer.mode === "clone") { + yield* git.execute({ + operation: "project.transfer.clone", + cwd, + args: ["clone", "--", transfer.configuration.remoteUrl!, "."], + timeoutMs: 600_000, + maxOutputBytes: 256 * 1024, + }); + } else { + yield* io(() => files.unpack(transfer.file, cwd, transfer.byteLength)); + } + transfer.unpacked = true; + } + const project = transfer.configuration.project; + // Once a durable project may exist, cancellation must never delete its checkout. + transfer.registered = true; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`${input.transferId}:create`), + projectId: transfer.projectId, + workspaceRoot: cwd, + title: project.title, + createdAt: transfer.createdAt, + }); + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make(`${input.transferId}:configure`), + projectId: transfer.projectId, + defaultModelSelection: project.defaultModelSelection, + defaultThreadEnvMode: project.defaultThreadEnvMode ?? null, + autoPull: project.autoPull ?? false, + projectIcon: project.projectIcon ?? null, + scripts: project.scripts, + }); + yield* settings.updateSettings({ + projectScriptOverrides: { [transfer.projectId]: [...project.scripts] }, + projectAutoPullOverrides: { [transfer.projectId]: project.autoPull ?? false }, + projectAgentBrowserAccessOverrides: { + [transfer.projectId]: transfer.configuration.agentBrowserAccess, + }, + }); + return { operation: "finish", projectId: transfer.projectId, cwd }; + }, + Effect.mapError((error) => + isProjectTransferError(error) + ? error + : new ProjectTransferError({ + message: error instanceof Error ? error.message : String(error), + }), + ), + ); + return (input: ProjectTransferInput) => handle(input).pipe(semaphore.withPermits(1)); +}); diff --git a/apps/server/src/project/ProjectTransferFiles.test.ts b/apps/server/src/project/ProjectTransferFiles.test.ts new file mode 100644 index 000000000000..6f78d76b1e4d --- /dev/null +++ b/apps/server/src/project/ProjectTransferFiles.test.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises the filesystem transfer boundary with disposable checkouts. +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as tar from "tar"; +import { PROJECT_TRANSFER_CHUNK_BYTES } from "@t3tools/contracts"; +import { ProjectTransferFiles } from "./ProjectTransferFiles.ts"; + +let root: string; +const files = new ProjectTransferFiles(); +beforeEach(async () => { + root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-copy-test-")); +}); +afterEach(async () => { + await NodeFSP.rm(root, { recursive: true, force: true }); +}); +const git = (cwd: string, args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }); + +async function copy(source: string, ignored = new Set()) { + const archive = NodePath.join(root, "archive"); + await NodeFSP.mkdir(archive); + const packed = await files.pack(source, archive, ignored); + const received = NodePath.join(root, "received.tar"); + for (let offset = 0; offset < packed.byteLength; offset += PROJECT_TRANSFER_CHUNK_BYTES) { + await files.write( + received, + offset, + await files.read(packed.file, offset, packed.byteLength), + packed.byteLength, + ); + } + const destination = await files.reserve(NodePath.join(root, "destination")); + await files.unpack(received, destination, packed.byteLength); + return destination; +} + +describe("project snapshot transfer", () => { + it("preserves Git commits, staged and unstaged changes, ignored and binary files, executable bits and internal links", async () => { + const source = NodePath.join(root, "source"); + await NodeFSP.mkdir(source); + git(source, ["init", "-b", "main"]); + git(source, ["config", "user.name", "Transfer Test"]); + git(source, ["config", "user.email", "transfer@example.test"]); + await NodeFSP.writeFile(NodePath.join(source, "tracked"), "committed\n"); + await NodeFSP.writeFile(NodePath.join(source, ".gitignore"), ".env\n"); + git(source, ["add", "."]); + git(source, ["commit", "-m", "initial"]); + await NodeFSP.writeFile(NodePath.join(source, "tracked"), "staged\n"); + git(source, ["add", "tracked"]); + await NodeFSP.writeFile(NodePath.join(source, "tracked"), "unstaged\n"); + await NodeFSP.writeFile(NodePath.join(source, ".env"), "EXAMPLE=test\n"); + await NodeFSP.writeFile(NodePath.join(source, "binary"), Buffer.alloc(700_000, 0xab)); + await NodeFSP.writeFile(NodePath.join(source, "run.sh"), "#!/bin/sh\necho ready\n", { + mode: 0o755, + }); + await NodeFSP.symlink("tracked", NodePath.join(source, "link")); + const destination = await copy(source); + expect(git(destination, ["rev-parse", "HEAD"])).toBe(git(source, ["rev-parse", "HEAD"])); + expect(git(destination, ["diff", "--cached"])).toBe(git(source, ["diff", "--cached"])); + expect(git(destination, ["diff"])).toBe(git(source, ["diff"])); + expect(await NodeFSP.readFile(NodePath.join(destination, ".env"), "utf8")).toBe( + "EXAMPLE=test\n", + ); + expect(await NodeFSP.readFile(NodePath.join(destination, "binary"))).toEqual( + Buffer.alloc(700_000, 0xab), + ); + expect((await NodeFSP.stat(NodePath.join(destination, "run.sh"))).mode & 0o111).toBe(0o111); + expect(await NodeFSP.readlink(NodePath.join(destination, "link"))).toBe("tracked"); + }); + + it("copies non-Git projects and empty directories while excluding selected ignored directories", async () => { + const source = NodePath.join(root, "source"); + await NodeFSP.mkdir(NodePath.join(source, "empty"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(source, "node_modules")); + await NodeFSP.writeFile(NodePath.join(source, "node_modules", "package"), "large"); + await NodeFSP.writeFile(NodePath.join(source, "notes"), "notes"); + const destination = await copy(source, new Set(["node_modules"])); + expect((await NodeFSP.stat(NodePath.join(destination, "empty"))).isDirectory()).toBe(true); + expect(await NodeFSP.readdir(destination)).toEqual(["empty", "notes"]); + }); + + it("refuses existing destinations without changing any files", async () => { + const destination = NodePath.join(root, "existing"); + await NodeFSP.mkdir(destination); + await NodeFSP.writeFile(NodePath.join(destination, "keep"), "original"); + await expect(files.reserve(destination)).rejects.toThrow(); + expect(await NodeFSP.readFile(NodePath.join(destination, "keep"), "utf8")).toBe("original"); + }); + + it("rejects incomplete and out-of-order uploads", async () => { + const file = NodePath.join(root, "upload.tar"); + await files.write(file, 0, Buffer.from("abc").toString("base64"), 6); + await expect(files.write(file, 0, Buffer.from("def").toString("base64"), 6)).rejects.toThrow( + "offset", + ); + await expect(files.unpack(file, root, 6)).rejects.toThrow("incomplete"); + }); + + it("refuses links escaping the project before extraction", async () => { + const source = NodePath.join(root, "source"); + await NodeFSP.mkdir(source); + await NodeFSP.symlink("../../outside", NodePath.join(source, "escape")); + const archive = NodePath.join(root, "unsafe.tar"); + await tar.create({ cwd: source, file: archive }, ["."]); + await expect(files.validate(archive)).rejects.toThrow("outside"); + }); + + it("refuses linked worktrees rather than copying a pointer to the source machine", async () => { + const source = NodePath.join(root, "source"); + await NodeFSP.mkdir(source); + await NodeFSP.writeFile(NodePath.join(source, ".git"), "gitdir: /somewhere/else"); + await expect(files.pack(source, root, new Set())).rejects.toThrow("external Git directory"); + }); +}); diff --git a/apps/server/src/project/ProjectTransferFiles.ts b/apps/server/src/project/ProjectTransferFiles.ts new file mode 100644 index 000000000000..54427e737d16 --- /dev/null +++ b/apps/server/src/project/ProjectTransferFiles.ts @@ -0,0 +1,186 @@ +// @effect-diagnostics nodeBuiltinImport:off - tar streams and file handles form the Node filesystem boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as tar from "tar"; +import { PROJECT_TRANSFER_CHUNK_BYTES, PROJECT_TRANSFER_MAX_BYTES } from "@t3tools/contracts"; + +/** Disk-backed snapshots keep transfer memory bounded even when the client is on a relay. */ +export class ProjectTransferFiles { + async temporaryDirectory() { + return NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-project-transfer-")); + } + + async isLinkedCheckout(root: string) { + const entry = await NodeFSP.lstat(NodePath.join(root, ".git")).catch(() => null); + return entry?.isFile() ?? false; + } + + async overlayCheckout(root: string, destination: string, indexPath: string) { + await NodeFSP.cp(root, destination, { + recursive: true, + verbatimSymlinks: true, + filter: (source) => source !== NodePath.join(root, ".git"), + }); + await NodeFSP.copyFile(indexPath, NodePath.join(destination, ".git", "index")); + } + + async pack(root: string, directory: string, ignored: ReadonlySet) { + const git = await NodeFSP.lstat(NodePath.join(root, ".git")).catch(() => null); + if (git && !git.isDirectory()) { + throw new Error( + "This checkout uses an external Git directory. Choose a fresh clone, or copy the repository's main checkout.", + ); + } + // Absolute/shared object paths would make an apparently successful copy depend on the source. + if (await NodeFSP.stat(NodePath.join(root, ".git/objects/info/alternates")).catch(() => null)) { + throw new Error( + "This repository borrows Git objects from another directory. Choose a fresh clone.", + ); + } + const file = NodePath.join(directory, "snapshot.tar"); + let total = 0; + await tar.create( + { + cwd: root, + file, + portable: true, + strict: true, + follow: false, + filter: (entry, stat) => { + const relative = entry.replace(/^\.\//, "").replace(/\/$/, ""); + if (relative === ".git/worktrees" || relative.startsWith(".git/worktrees/")) return false; + if (ignored.has(relative)) return false; + total += stat.size; + return total <= PROJECT_TRANSFER_MAX_BYTES; + }, + }, + ["."], + ); + if (total > PROJECT_TRANSFER_MAX_BYTES) + throw new Error("Project copy exceeds the 10 GB limit."); + await this.validate(file); + const byteLength = (await NodeFSP.stat(file)).size; + if (byteLength > PROJECT_TRANSFER_MAX_BYTES) + throw new Error("Project copy exceeds the 10 GB limit."); + return { file, byteLength }; + } + + async reserve(destination: string) { + const expanded = destination.startsWith("~/") + ? NodePath.join(NodeOS.homedir(), destination.slice(2)) + : destination; + if (!NodePath.isAbsolute(expanded)) throw new Error("Choose an absolute destination folder."); + const parent = await NodeFSP.realpath(NodePath.dirname(expanded)); + const cwd = NodePath.join(parent, NodePath.basename(expanded)); + // mkdir is the reservation: even an empty existing checkout must never be overwritten. + await NodeFSP.mkdir(cwd, { mode: 0o700 }); + return cwd; + } + + async read(file: string, offset: number, byteLength: number) { + if (offset >= byteLength) throw new Error("Snapshot offset is outside the transfer."); + const handle = await NodeFSP.open(file, "r"); + try { + const buffer = Buffer.alloc(Math.min(PROJECT_TRANSFER_CHUNK_BYTES, byteLength - offset)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); + if (bytesRead !== buffer.length) throw new Error("Snapshot changed while copying."); + return buffer.toString("base64"); + } finally { + await handle.close(); + } + } + + async write(file: string, offset: number, data: string, byteLength: number) { + const buffer = Buffer.from(data, "base64"); + if ( + !buffer.length || + buffer.length > PROJECT_TRANSFER_CHUNK_BYTES || + buffer.toString("base64") !== data || + offset + buffer.length > byteLength + ) { + throw new Error("Invalid project transfer chunk."); + } + const handle = await NodeFSP.open(file, "a"); + try { + if ((await handle.stat()).size !== offset) + throw new Error("Transfer offset changed. Restart the copy."); + await handle.writeFile(buffer); + } finally { + await handle.close(); + } + return buffer.length; + } + + async validate(file: string) { + let total = 0; + let count = 0; + const entries = new Set(); + const links = new Set(); + const paths: string[] = []; + let problem: string | undefined; + const safe = (value: string) => + !NodePath.posix.isAbsolute(value) && + !NodePath.win32.isAbsolute(value) && + !value.includes("\\") && + !value.split("/").includes(".."); + await tar.list({ + file, + strict: true, + onReadEntry: (entry) => { + const name = entry.path.replace(/^\.\//, "").replace(/\/$/, ""); + count++; + total += entry.size; + if (total > PROJECT_TRANSFER_MAX_BYTES || count > 500_000) { + problem = "Project copy exceeds the transfer limit."; + return; + } + if (!safe(name) || entries.has(name)) + problem = "The snapshot contains an unsafe or duplicate NodePath."; + if (!["File", "Directory", "SymbolicLink", "Link"].includes(entry.type)) + problem = "The snapshot contains unsupported special files."; + if (entry.type === "SymbolicLink" || entry.type === "Link") { + const resolved = + entry.type === "Link" + ? (entry.linkpath ?? "") + : NodePath.posix.join(NodePath.posix.dirname(name), entry.linkpath ?? ""); + if ( + !safe(resolved) || + NodePath.posix.isAbsolute(entry.linkpath ?? "") || + NodePath.win32.isAbsolute(entry.linkpath ?? "") + ) + problem = + "The snapshot contains a link outside the project. Remove that link or choose a fresh clone."; + links.add(name); + } + entries.add(name); + paths.push(name); + if (total > PROJECT_TRANSFER_MAX_BYTES || count > 500_000) + problem = "Project copy exceeds the transfer limit."; + }, + }); + for (const name of paths) { + let parent = NodePath.posix.dirname(name); + while (parent !== ".") { + if (links.has(parent)) problem = "The snapshot writes through a symbolic link."; + parent = NodePath.posix.dirname(parent); + } + } + if (problem) throw new Error(problem); + } + + async unpack(file: string, cwd: string, byteLength: number) { + if ((await NodeFSP.stat(file)).size !== byteLength) + throw new Error("The project transfer is incomplete."); + await this.validate(file); + await tar.extract({ file, cwd, strict: true, preservePaths: false, noChmod: false }); + } + + async remove(directory: string) { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + id() { + return NodeCrypto.randomUUID(); + } +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..e7f5345a33ff 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { makeProjectTransfer } from "./project/ProjectTransfer.ts"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -534,6 +535,7 @@ const makeWsRpcLayer = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const projectTransfer = yield* makeProjectTransfer(); const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -2253,6 +2255,7 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.projectsTransfer]: (input) => projectTransfer(input), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, diff --git a/apps/web/src/components/DirectoryPicker.tsx b/apps/web/src/components/DirectoryPicker.tsx new file mode 100644 index 000000000000..4fe192637237 --- /dev/null +++ b/apps/web/src/components/DirectoryPicker.tsx @@ -0,0 +1,173 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useState } from "react"; +import { ArrowLeftIcon, CornerLeftUpIcon, FolderIcon } from "lucide-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; +import { ensureBrowseDirectoryPath, hasTrailingPathSeparator } from "../lib/projectPaths"; +import { filesystemEnvironment } from "../state/filesystem"; +import { useEnvironmentQuery } from "../state/query"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { CommandPaletteContent } from "./CommandPaletteContent"; +import { CommandPaletteResults } from "./CommandPaletteResults"; +import { buildBrowseGroups } from "./CommandPalette.logic"; +import { CommandDialog, CommandDialogPopup } from "./ui/command"; +import { Button } from "./ui/button"; + +/** Browse an environment's filesystem with the same controls as Add project. */ +export function DirectoryPicker({ + environmentId, + platform, + initialPath, + label, + onSelect, + onClose, +}: { + environmentId: EnvironmentId; + platform: string; + initialPath: string; + label: string; + onSelect: (path: string) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(ensureBrowseDirectoryPath(initialPath)); + const [highlighted, setHighlighted] = useState(null); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const path = getFilesystemBrowsePath(query, platform); + const result = useEnvironmentQuery( + path.isBrowsing + ? filesystemEnvironment.browse({ + environmentId, + input: { partialPath: path.directoryPath }, + }) + : null, + ); + const { visibleEntries, exactEntry } = filterFilesystemBrowseEntries( + result.data?.entries ?? [], + path.filterQuery, + ); + const selectedPath = hasTrailingPathSeparator(query) + ? result.data?.parentPath + : exactEntry?.fullPath; + const canSelect = Boolean(selectedPath && !result.isPending && !result.error && path.isBrowsing); + const select = () => { + if (canSelect && selectedPath) onSelect(selectedPath); + }; + const navigate = (next: string) => { + setHighlighted(null); + setQuery(ensureBrowseDirectoryPath(next)); + }; + const up = () => { + if (path.parentPath) navigate(path.parentPath); + }; + const groups = buildBrowseGroups({ + browseEntries: visibleEntries, + browseQuery: query, + canBrowseUp: path.canBrowseUp, + directoryIcon: , + upIcon: , + browseUp: up, + browseTo: (name) => { + const entry = visibleEntries.find((item) => item.name === name); + if (entry) navigate(entry.fullPath); + }, + }); + return ( + { + if (!open) onClose(); + }} + > + + setHighlighted(typeof value === "string" ? value : null)} + footerActionLabel="Select" + showBackHint + inputProps={{ + "aria-label": label, + placeholder: "~/", + className: "*:data-[slot=autocomplete-input]:pe-32!", + wrapperClassName: "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto", + startAddon: ( + + ), + onKeyDown: (event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } else if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey || !highlighted) + ) { + event.preventDefault(); + event.stopPropagation(); + select(); + } else if ( + event.key === "Backspace" && + event.currentTarget.selectionStart === 0 && + event.currentTarget.selectionEnd === 0 + ) { + event.preventDefault(); + up(); + } + }, + }} + inputAccessory={ + + } + > + {result.error ? ( +

+ {result.error} +

+ ) : result.isPending ? ( +

+ Loading directories… +

+ ) : ( + group.items.length > 0) ? groups : []} + highlightedItemValue={highlighted} + isActionsOnly + keybindings={keybindings} + emptyStateMessage={ + path.isBrowsing + ? "No matching directories." + : "Enter an absolute path or start with ~/." + } + onExecuteItem={(item) => { + if (item.kind === "action") void item.run(); + }} + /> + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..bc7c7428c61b 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { ProjectTransferDialog } from "./ProjectTransferDialog"; import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, @@ -220,24 +221,29 @@ export function ProjectSettingsPanel({ ); } - if (members.length === 0) - return ( -

- This project has no checkout on this machine. -

- ); - const scopedGroup = { - ...selected, - memberProjects: members, - environmentId: members[0]!.environmentId, - id: members[0]!.id, - }; + const scopedGroup = + members.length > 0 + ? { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + } + : null; return ( - + + + {scopedGroup ? ( + + ) : null} + ); } @@ -921,7 +927,7 @@ function ProjectDetail({ return ( <> - + <> - + (destinationId ?? ""); + const target = destinationId ?? pickedTarget; + const [destinationPath, setDestinationPath] = useState(""); + const [browsing, setBrowsing] = useState(false); + const [mode, setMode] = useState( + sources[0]?.repositoryIdentity ? "clone" : "copy", + ); + const [includeIgnored, setIncludeIgnored] = useState(true); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const controller = useRef(null); + const transfer = useAtomCommand(projectEnvironment.transfer, "copy project"); + const source = sources.find((item) => item.physicalProjectKey === sourceKey) ?? sources[0]; + const busy = progress !== null; + const supported = (id: EnvironmentId) => { + const environment = environments.find((item) => item.environmentId === id); + return ( + environment?.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.projectTransfer === true + ); + }; + const destinations = projectTransferTargets(environments, sources); + const canCopy = + sources.some((item) => supported(item.environmentId)) && + (destinationId + ? destinations.some((item) => item.environmentId === destinationId) + : destinations.length > 0); + const ready = + source && + target && + source.environmentId !== target && + supported(source.environmentId) && + destinations.some((item) => item.environmentId === target); + const machineLabel = (id: EnvironmentId) => { + const environment = environments.find((item) => item.environmentId === id); + if (!environment) return "Machine"; + return environments.some( + (item) => item.environmentId !== id && item.label === environment.label, + ) + ? `${environment.label} · ${environment.displayUrl ?? id}` + : environment.label; + }; + + async function start() { + if (!source || !target || !ready || controller.current) return; + const abort = new AbortController(); + controller.current = abort; + setError(null); + try { + const result = await copyProjectToEnvironment({ + sourceEnvironmentId: source.environmentId, + destinationEnvironmentId: target, + projectId: source.id, + destinationPath: destinationPath.trim(), + mode, + includeIgnored, + signal: abort.signal, + onProgress: setProgress, + request: async (environmentId, input) => { + const result = await transfer({ environmentId, input }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + return result.value; + }, + }); + toastManager.add({ type: "success", title: "Project copied", description: result.cwd }); + setOpen(false); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError( + abort.signal.aborted + ? "Copy cancelled." + : message.includes("EEXIST") + ? "That destination folder already exists. Choose a new folder and try again." + : message, + ); + } finally { + controller.current = null; + setProgress(null); + } + } + + return ( + <> + {(canCopy || destinationId) && ( + + { + setError(null); + setOpen(true); + }} + > + {destinationId ? "Copy from another machine" : "Copy to another machine"} + + ) : undefined + } + /> + + )} + { + if (!busy) setOpen(value); + }} + > + + + Copy project to another machine + + Create a separate checkout with this project's settings and actions. The source stays + intact. + + + +
+ + +

{source?.workspaceRoot}

+
+
+ + + {target && !ready && ( +

+ Connect both machines using a version of T3 Code that supports project copying. +

+ )} +
+
+ +
+ setDestinationPath(event.target.value)} + /> + +
+

+ Browse for a parent folder, then name the new folder in the path above. +

+
+
+ Checkout + setMode(value as ProjectTransferMode)} + > + + + + {mode === "copy" && ( + + )} +
+

+ Project settings and actions are included. Conversations and provider credentials stay + on the source. +

+ {progress && ( +

+ {progress} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + + + + {browsing && target && ( + item.environmentId === target)?.serverConfig + ?.environment.platform.os ?? "" + } + initialPath={ + destinationPath.trim() ? getBrowseDirectoryPath(destinationPath.trim()) : "~/" + } + label={`Choose parent folder on ${machineLabel(target)}`} + onClose={() => setBrowsing(false)} + onSelect={(parentPath) => { + const name = + getBrowseLeafPathSegment(destinationPath.trim()) || + getBrowseLeafPathSegment(source?.workspaceRoot ?? "") || + "project"; + setDestinationPath(getCloneDestinationPath(parentPath, name)); + setBrowsing(false); + }} + /> + )} +
+
+ + ); +} diff --git a/apps/web/src/components/settings/projectTransferTargets.test.ts b/apps/web/src/components/settings/projectTransferTargets.test.ts new file mode 100644 index 000000000000..31c146b09e81 --- /dev/null +++ b/apps/web/src/components/settings/projectTransferTargets.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; +import { projectTransferTargets } from "./projectTransferTargets"; + +const machine = (id: string, phase = "connected", projectTransfer = true) => ({ + environmentId: EnvironmentId.make(id), + connection: { phase }, + serverConfig: { environment: { capabilities: { projectTransfer } } }, +}); +const source = machine("source"); +const destination = machine("destination"); + +describe("projectTransferTargets", () => { + it("offers no destination for a single machine or when every machine has the project", () => { + expect(projectTransferTargets([source], [source])).toEqual([]); + expect(projectTransferTargets([source, destination], [source, destination])).toEqual([]); + }); + it("offers only machines missing the project, even with multiple source checkouts", () => { + const third = machine("third"); + expect( + projectTransferTargets([source, destination, third], [source, source, destination]), + ).toEqual([third]); + }); + it("excludes offline and older servers", () => { + expect( + projectTransferTargets( + [ + source, + machine("offline", "disconnected"), + machine("old", "connected", false), + destination, + ], + [source], + ), + ).toEqual([destination]); + }); + it("removes the destination as soon as its checkout is registered", () => { + expect(projectTransferTargets([source, destination], [source])).toEqual([destination]); + expect(projectTransferTargets([source, destination], [source, destination])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/settings/projectTransferTargets.ts b/apps/web/src/components/settings/projectTransferTargets.ts new file mode 100644 index 000000000000..a9cf6262c6e5 --- /dev/null +++ b/apps/web/src/components/settings/projectTransferTargets.ts @@ -0,0 +1,21 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +type TransferEnvironment = { + environmentId: EnvironmentId; + connection: { phase: string }; + serverConfig?: { environment: { capabilities: { projectTransfer?: boolean } } } | null; +}; + +/** Only connected, compatible machines without a checkout in this project group. */ +export function projectTransferTargets( + environments: readonly T[], + checkouts: readonly { environmentId: EnvironmentId }[], +): T[] { + const occupied = new Set(checkouts.map((checkout) => checkout.environmentId)); + return environments.filter( + (environment) => + !occupied.has(environment.environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.projectTransfer === true, + ); +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c76c18544df2..85eb5966b73a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -33,3 +33,21 @@ upstream. T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## Copy a project to another machine + +In the web or desktop app, select a project in **Settings → Projects**. The copy action is available +when another connected machine has no checkout of that project and both servers support project copying. +Choose a source checkout, a destination machine, and a new folder. Browse the destination machine to +choose an existing parent folder, or enter the new folder path directly. + +**Fresh checkout** clones the repository's default branch from its origin remote using the destination's +Git credentials. **One-time copy** transfers the current files, Git history, staged and unstaged work. +It supports folders without Git too. Ignored files, including `.env` and installed dependencies, are +included unless you turn that option off. Dependencies may need reinstalling on a different OS. +Pause edits while the snapshot is prepared; copies are limited to 10 GB and links must stay inside the project. + +Both options copy the project name, icon, model and workspace defaults, automatic-pull preference, +browser-access preference, and actions. Provider credentials and conversations remain on their original +machine. Configure any missing provider instances on the destination. The source remains intact, +existing destination folders are never overwritten, and subsequent changes do not sync automatically. diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..e981e335f42f 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -55,6 +55,12 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.projectId]), }; return { + transfer: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:transfer", + tag: WS_METHODS.projectsTransfer, + scheduler: fileScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:search-entries", tag: WS_METHODS.projectsSearchEntries, @@ -104,3 +110,5 @@ export function createProjectEnvironmentAtoms( }), }; } + +export { copyProjectToEnvironment } from "./projectTransfer.ts"; diff --git a/packages/client-runtime/src/state/projectTransfer.test.ts b/packages/client-runtime/src/state/projectTransfer.test.ts new file mode 100644 index 000000000000..e6ae4adaf985 --- /dev/null +++ b/packages/client-runtime/src/state/projectTransfer.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + EnvironmentId, + ProjectId, + PROJECT_TRANSFER_CHUNK_BYTES, + type ProjectTransferConfiguration, +} from "@t3tools/contracts"; +import { copyProjectToEnvironment, type ProjectTransferRequest } from "./projectTransfer.ts"; + +const source = EnvironmentId.make("source"); +const destination = EnvironmentId.make("destination"); +const configuration: ProjectTransferConfiguration = { + project: { + id: ProjectId.make("project"), + title: "Example", + workspaceRoot: "/source", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-08T00:00:00.000Z", + updatedAt: "2026-09-08T00:00:00.000Z", + }, + agentBrowserAccess: false, + remoteUrl: "https://example.test/repo.git", +}; +const base = { + sourceEnvironmentId: source, + destinationEnvironmentId: destination, + projectId: configuration.project.id, + destinationPath: "/destination", + mode: "copy" as const, + includeIgnored: true, + onProgress: () => {}, +}; + +describe("cross-environment project copying", () => { + it("brokers bounded chunks and preserves configuration without sharing machine credentials", async () => { + const reads: number[] = []; + const writes: number[] = []; + const released: string[] = []; + const request: ProjectTransferRequest = async (environment, input) => { + switch (input.operation) { + case "prepare": + expect(environment).toBe(source); + return { + operation: "prepare", + transferId: "export", + configuration, + byteLength: PROJECT_TRANSFER_CHUNK_BYTES + 1, + }; + case "begin": + expect(environment).toBe(destination); + expect(input.configuration).toBe(configuration); + return { operation: "begin", transferId: "import" }; + case "read": + expect(environment).toBe(source); + reads.push(input.offset); + return { operation: "read", data: "chunk" }; + case "write": + expect(environment).toBe(destination); + writes.push(input.offset); + return { operation: "write" }; + case "finish": + expect(writes).toEqual([0, PROJECT_TRANSFER_CHUNK_BYTES]); + return { operation: "finish", projectId: ProjectId.make("copied"), cwd: "/destination" }; + case "release": + released.push(input.transferId); + return { operation: "release" }; + } + }; + const result = await copyProjectToEnvironment({ ...base, request }); + expect(result.projectId).toBe("copied"); + expect(reads).toEqual(writes); + expect(released.sort()).toEqual(["export", "import"]); + }); + + it("cleans both ends after a failed upload without replacing the original error", async () => { + const released: string[] = []; + const request: ProjectTransferRequest = async (_, input) => { + if (input.operation === "prepare") + return { operation: "prepare", transferId: "export", configuration, byteLength: 1 }; + if (input.operation === "begin") return { operation: "begin", transferId: "import" }; + if (input.operation === "read") return { operation: "read", data: "chunk" }; + if (input.operation === "release") { + released.push(input.transferId); + throw new Error("cleanup offline"); + } + throw new Error("destination disconnected"); + }; + await expect(copyProjectToEnvironment({ ...base, request })).rejects.toThrow( + "destination disconnected", + ); + expect(released.sort()).toEqual(["export", "import"]); + }); + + it("cancellation after snapshot preparation releases it without creating a destination", async () => { + const controller = new AbortController(); + const operations: string[] = []; + const request: ProjectTransferRequest = async (_, input) => { + operations.push(input.operation); + if (input.operation === "prepare") { + controller.abort(new Error("cancelled")); + return { operation: "prepare", transferId: "export", configuration, byteLength: 1 }; + } + if (input.operation === "release") return { operation: "release" }; + throw new Error("unexpected destination operation"); + }; + await expect( + copyProjectToEnvironment({ ...base, request, signal: controller.signal }), + ).rejects.toThrow("cancelled"); + expect(operations).toEqual(["prepare", "release"]); + }); +}); diff --git a/packages/client-runtime/src/state/projectTransfer.ts b/packages/client-runtime/src/state/projectTransfer.ts new file mode 100644 index 000000000000..1cf0bcd0e3d4 --- /dev/null +++ b/packages/client-runtime/src/state/projectTransfer.ts @@ -0,0 +1,105 @@ +import { + PROJECT_TRANSFER_CHUNK_BYTES, + type EnvironmentId, + type ProjectId, + type ProjectTransferInput, + type ProjectTransferResult, + type ProjectTransferMode, +} from "@t3tools/contracts"; + +export type ProjectTransferRequest = ( + environmentId: EnvironmentId, + input: ProjectTransferInput, +) => Promise; + +/** The connected client brokers the bytes; neither server needs the other's credentials or address. */ +export async function copyProjectToEnvironment(input: { + sourceEnvironmentId: EnvironmentId; + destinationEnvironmentId: EnvironmentId; + projectId: ProjectId; + destinationPath: string; + mode: ProjectTransferMode; + includeIgnored: boolean; + request: ProjectTransferRequest; + signal?: AbortSignal; + onProgress: (message: string) => void; +}) { + let sourceTransfer: string | undefined; + let destinationTransfer: string | undefined; + const checkCancelled = () => input.signal?.throwIfAborted(); + try { + checkCancelled(); + input.onProgress( + input.mode === "copy" ? "Preparing the project snapshot…" : "Reading project settings…", + ); + const prepared = await input.request(input.sourceEnvironmentId, { + operation: "prepare", + projectId: input.projectId, + mode: input.mode, + includeIgnored: input.includeIgnored, + }); + if (prepared.operation !== "prepare") throw new Error("Unexpected snapshot response."); + sourceTransfer = prepared.transferId; + checkCancelled(); + const begun = await input.request(input.destinationEnvironmentId, { + operation: "begin", + destinationPath: input.destinationPath, + configuration: prepared.configuration, + mode: input.mode, + byteLength: prepared.byteLength, + }); + if (begun.operation !== "begin") throw new Error("Unexpected destination response."); + destinationTransfer = begun.transferId; + for (let offset = 0; offset < prepared.byteLength; offset += PROJECT_TRANSFER_CHUNK_BYTES) { + checkCancelled(); + const chunk = await input.request(input.sourceEnvironmentId, { + operation: "read", + transferId: sourceTransfer, + offset, + }); + if (chunk.operation !== "read") throw new Error("Unexpected snapshot chunk."); + checkCancelled(); + await input.request(input.destinationEnvironmentId, { + operation: "write", + transferId: destinationTransfer, + offset, + data: chunk.data, + }); + input.onProgress( + `Copying files… ${Math.min(100, Math.round(((offset + PROJECT_TRANSFER_CHUNK_BYTES) / prepared.byteLength) * 100))}%`, + ); + } + checkCancelled(); + input.onProgress( + input.mode === "clone" + ? "Cloning the repository and applying settings…" + : "Restoring files and applying settings…", + ); + const result = await input.request(input.destinationEnvironmentId, { + operation: "finish", + transferId: destinationTransfer, + }); + if (result.operation !== "finish") throw new Error("Unexpected project result."); + return result; + } finally { + // Best-effort cleanup must not mask the transfer's original error or its successful result. + await Promise.allSettled([ + ...(sourceTransfer + ? [ + input.request(input.sourceEnvironmentId, { + operation: "release", + transferId: sourceTransfer, + }), + ] + : []), + ...(destinationTransfer + ? [ + input.request(input.destinationEnvironmentId, { + operation: "release", + transferId: destinationTransfer, + }), + ] + : []), + ]); + } +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 14062354047e..bf247c7a6dd9 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -76,6 +76,7 @@ export const ServerSelfUpdateCapability = Schema.Literals([ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ + projectTransfer: Schema.optionalKey(Schema.Boolean), repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 74a1b4939f1a..d9d7f7febf61 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -39,3 +39,5 @@ export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./rpc.ts"; + +export * from "./projectTransfer.ts"; diff --git a/packages/contracts/src/projectTransfer.ts b/packages/contracts/src/projectTransfer.ts new file mode 100644 index 000000000000..3ae1e1c82736 --- /dev/null +++ b/packages/contracts/src/projectTransfer.ts @@ -0,0 +1,70 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { OrchestrationProjectShell } from "./orchestration.ts"; + +export const PROJECT_TRANSFER_CHUNK_BYTES = 256 * 1024; +export const PROJECT_TRANSFER_MAX_BYTES = 10 * 1024 * 1024 * 1024; +export const ProjectTransferMode = Schema.Literals(["clone", "copy"]); +export type ProjectTransferMode = typeof ProjectTransferMode.Type; + +export const ProjectTransferConfiguration = Schema.Struct({ + project: OrchestrationProjectShell, + agentBrowserAccess: Schema.Boolean, + remoteUrl: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ProjectTransferConfiguration = typeof ProjectTransferConfiguration.Type; + +const TransferId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export const ProjectTransferInput = Schema.Union([ + Schema.Struct({ + operation: Schema.Literal("prepare"), + projectId: ProjectId, + mode: ProjectTransferMode, + includeIgnored: Schema.Boolean, + }), + Schema.Struct({ + operation: Schema.Literal("read"), + transferId: TransferId, + offset: NonNegativeInt, + }), + Schema.Struct({ + operation: Schema.Literal("begin"), + destinationPath: TrimmedNonEmptyString, + configuration: ProjectTransferConfiguration, + mode: ProjectTransferMode, + byteLength: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROJECT_TRANSFER_MAX_BYTES)), + }), + Schema.Struct({ + operation: Schema.Literal("write"), + transferId: TransferId, + offset: NonNegativeInt, + data: Schema.String.check(Schema.isMaxLength(4 * Math.ceil(PROJECT_TRANSFER_CHUNK_BYTES / 3))), + }), + Schema.Struct({ operation: Schema.Literal("finish"), transferId: TransferId }), + Schema.Struct({ operation: Schema.Literal("release"), transferId: TransferId }), +]); +export type ProjectTransferInput = typeof ProjectTransferInput.Type; + +export const ProjectTransferResult = Schema.Union([ + Schema.Struct({ + operation: Schema.Literal("prepare"), + transferId: TransferId, + configuration: ProjectTransferConfiguration, + byteLength: NonNegativeInt, + }), + Schema.Struct({ operation: Schema.Literal("read"), data: Schema.String }), + Schema.Struct({ operation: Schema.Literal("begin"), transferId: TransferId }), + Schema.Struct({ operation: Schema.Literal("write") }), + Schema.Struct({ + operation: Schema.Literal("finish"), + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + }), + Schema.Struct({ operation: Schema.Literal("release") }), +]); +export type ProjectTransferResult = typeof ProjectTransferResult.Type; + +export class ProjectTransferError extends Schema.TaggedError()( + "ProjectTransferError", + { message: Schema.String }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..5e4abf016322 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,8 @@ +import { + ProjectTransferInput, + ProjectTransferResult, + ProjectTransferError, +} from "./projectTransfer.ts"; import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; @@ -237,6 +242,7 @@ import { VcsError } from "./vcs.ts"; export const WS_METHODS = { // Project registry methods + projectsTransfer: "projects.transfer", projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", @@ -781,6 +787,12 @@ const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPub error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); +const WsProjectsTransferRpc = Rpc.make(WS_METHODS.projectsTransfer, { + payload: ProjectTransferInput, + success: ProjectTransferResult, + error: Schema.Union([ProjectTransferError, EnvironmentAuthorizationError]), +}); + const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -1242,6 +1254,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsProjectsTransferRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96a35631c361..a0d446c786fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -524,6 +524,9 @@ importers: stream-json: specifier: 3.6.0 version: 3.6.0 + tar: + specifier: 7.5.16 + version: 7.5.16 yaml: specifier: ^2.9.0 version: 2.9.0