From 71a90ae70e3ff880396363809bd9bfcfd520f30c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 23:40:17 -0700 Subject: [PATCH 1/6] fix(web): hotkey settings record plain keys and Tab (#14548) Co-authored-by: Claude Opus 5.5 (1M context) --- .../KeybindingsSettings.logic.test.ts | 25 +++++++++++++++++++ .../settings/KeybindingsSettings.logic.ts | 4 +-- .../settings/KeybindingsSettings.tsx | 6 +++-- .../settings/useSnapShotShortcutRecorder.tsx | 2 ++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 791ebccfa932..15561e06be83 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -113,6 +113,31 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["k", "KeyK", "k"], + ["Tab", "Tab", "tab"], + ["F5", "F5", "f5"], + ])("captures %s without a modifier", (key, code, expected) => { + const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected); + }); + + it("waits for a key when only a modifier is pressed", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Meta", + code: "MetaLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it.each([ ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a1f445b5db82..4f41e3755382 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -358,6 +358,7 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } +/** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, @@ -375,9 +376,6 @@ export function keybindingFromKeyboardEvent( } if (event.altKey) parts.push("alt"); if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } parts.push(keyToken); return parts.join("+"); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 9d768d7fd41c..bf14e709dda1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -768,7 +768,8 @@ function useKeybindingRowEditor({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); @@ -1083,7 +1084,8 @@ function useNewKeybindingDraft({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx index 8f377842b68d..50fd3b338210 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx @@ -106,6 +106,8 @@ export function useSnapShotShortcutRecorder({ } return; } + // A global shortcut without a modifier would take that key from every app. + if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) return; const input = keybindingFromKeyboardEvent(event, navigator.platform); if (!input) return; const next = parseKeybindingShortcut(input); From 41a8239849d7e6523c08edb363efdeccfb150be5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:01:39 -0700 Subject: [PATCH 2/6] fix(release): Windows CLI smoke test no longer fails on temp dir cleanup (#14553) Co-authored-by: Claude Opus 5.5 (1M context) --- scripts/smoke-cli-archive.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index cce14d5654f1..0af024c0ec92 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -15,6 +15,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/unstable/cli"; @@ -73,7 +74,18 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-smoke-" }); + const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" }); + // Windows can keep t3.exe locked (EBUSY) for a moment after the server + // exits. A leftover scratch directory on a CI runner is harmless, so + // cleanup retries briefly and never fails a smoke test that passed. + yield* Effect.addFinalizer(() => + fs.remove(scratch, { recursive: true }).pipe( + Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }), + Effect.catch((error) => + Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`), + ), + ), + ); // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU // tar; use the bsdtar Windows ships, which reads both formats. From 148e6deea046658639aae9fef5b349781cec39d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 1 Oct 2026 00:15:28 -0700 Subject: [PATCH 3/6] feat: start a new project from just a name (#14527) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/Stack.tsx | 5 + .../features/projects/AddProjectNewRoute.tsx | 12 + .../features/projects/AddProjectScreen.tsx | 261 +++++++++++- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/project/NewProject.test.ts | 140 ++++++ apps/server/src/project/NewProject.ts | 179 ++++++++ apps/server/src/server.test.ts | 59 +++ apps/server/src/ws.ts | 50 +++ apps/web/src/components/CommandPalette.tsx | 399 ++++++++++++++++-- .../src/components/CommandPaletteResults.tsx | 2 +- .../src/components/chat/DraftHeroHeadline.tsx | 2 +- .../sidebar/SidebarThreadHeader.tsx | 2 +- apps/web/src/hooks/useNewProject.ts | 147 +++++++ docs/user/source-control.md | 10 +- .../client-runtime/src/operations/projects.ts | 31 ++ .../src/state/projectCommands.ts | 7 + packages/contracts/src/project.ts | 14 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 5 + packages/shared/src/path.test.ts | 14 + packages/shared/src/path.ts | 21 + 21 files changed, 1334 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/projects/AddProjectNewRoute.tsx create mode 100644 apps/server/src/project/NewProject.test.ts create mode 100644 apps/server/src/project/NewProject.ts create mode 100644 apps/web/src/hooks/useNewProject.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7f508f486ce3..4baeadb94238 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewR import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute"; import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; +import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; @@ -459,6 +460,10 @@ const NewTaskSheetStack = createNativeStackNavigator({ screen: AddProjectLocalRoute, linking: "add-project/local", }), + AddProjectNew: createNativeStackScreen({ + screen: AddProjectNewRoute, + linking: "add-project/new", + }), }, }); diff --git a/apps/mobile/src/features/projects/AddProjectNewRoute.tsx b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx new file mode 100644 index 000000000000..8bbd26f2e114 --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectNewRoute.tsx @@ -0,0 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { AddProjectNewScreen } from "./AddProjectScreen"; + +type AddProjectNewRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectNewRoute({ + route, +}: StaticScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5b38cfa7bdd8..7cdcd0d33493 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -15,6 +15,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -61,6 +64,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -77,6 +81,8 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; + /** Folder for projects started from just a name; null on servers without it. */ + readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; readonly connectionErrorTraceId: string | null; @@ -399,6 +405,7 @@ function useEnvironmentOptions(): ReadonlyArray { platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, @@ -578,6 +585,28 @@ export function AddProjectSourceScreen() { {selectedEnvironment ? ( <> + {selectedEnvironment.newProjectsRoot !== null ? ( + + } + isFirst + onPress={() => + navigation.dispatch( + StackActions.push("AddProjectNew", { + environmentId: selectedEnvironment.environmentId, + }), + ) + } + /> + ) : null} } - isFirst + isFirst={selectedEnvironment.newProjectsRoot === null} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -898,6 +927,236 @@ function FolderBrowser(props: { ); } +/** + * New project: a name, then the server makes the folder, README, icon, and + * first commit. Optionally publishes it to GitHub as a private repository. + */ +export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const navigation = useNavigation(); + // Starts on the machine picked in Add project; the rows below switch it. + const environmentOptions = useEnvironmentOptions().filter( + (option) => option.newProjectsRoot !== null, + ); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + () => stringParam(props.environmentId) as EnvironmentId | null, + ); + const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const discoveryState = useEnvironmentQuery( + environment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: environment.environmentId, + input: {}, + }), + ); + const githubTarget = getNewProjectGitHubTarget(discoveryState.data); + const [name, setName] = useState(""); + const [publishesToGitHub, setPublishesToGitHub] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const trimmedName = name.trim(); + const pathPreview = + environment?.newProjectsRoot != null + ? getNewProjectPathPreview(environment.newProjectsRoot, trimmedName) + : null; + + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showMachines = + environmentOptions.length > 1 || (environment === null && environmentOptions.length > 0); + const machineRows = showMachines ? ( + + {environmentOptions.map((option, index) => { + const selected = option.environmentId === environment?.environmentId; + return ( + + } + selected={selected} + // The create in flight keeps the machine it started on. + disabled={isSubmitting} + isFirst={index === 0} + right={ + selected ? ( + + ) : null + } + onPress={() => setSelectedEnvironmentId(option.environmentId)} + /> + ); + })} + + ) : null; + + // State lags a render behind, so a double tap could start a second create. + const submittingRef = useRef(false); + const submit = async () => { + if (!environment || trimmedName.length === 0 || submittingRef.current) return; + submittingRef.current = true; + setError(null); + setIsSubmitting(true); + try { + const result = await createNew({ + environmentId: environment.environmentId, + input: { name: trimmedName }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + return; + } + const { projectId, workspaceRoot, commitError } = result.value; + if (commitError !== undefined) { + Alert.alert("Created without a first commit", commitError); + } + if (publishesToGitHub && githubTarget !== null) { + void publishRepository({ + environmentId: environment.environmentId, + input: { + cwd: workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), + visibility: "private", + }, + }).then((publishResult) => { + if (AsyncResult.isFailure(publishResult)) { + Alert.alert( + "Could not create the GitHub repository", + errorMessage(Cause.squash(publishResult.cause)), + ); + } + }); + } + // The draft screen resolves its project from the client store, so it + // must not open before the create event has arrived. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + // The project exists, so clearing the name keeps Create from making a `-2` copy. + setName(""); + setError( + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return; + } + openNewTaskDraft(navigation, { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }); + } finally { + submittingRef.current = false; + setIsSubmitting(false); + } + }; + + return ( + + {error ? : null} + {environment ? ( + <> + void submit()} + /> + {pathPreview !== null ? ( + + {trimmedName.length > 0 + ? `Creates ${pathPreview}` + : `Goes in ${environment.newProjectsRoot}`} + {showMachines ? ` on ${environment.label}` : null} + + ) : null} + {machineRows} + {githubTarget !== null ? ( + + 0 && pathPreview !== null + ? getNewProjectGitHubRepository(githubTarget, pathPreview) + : githubTarget.account + } + icon={ + + } + isFirst + right={ + + } + onPress={() => setPublishesToGitHub((publishes) => !publishes)} + /> + + ) : null} + void submit()} + loading={isSubmitting} + /> + + + } + isFirst + // New project opens from Add project, so going back shows the + // other sources. A deep link has nothing behind it. + onPress={() => + navigation.canGoBack() + ? navigation.goBack() + : navigation.dispatch(StackActions.replace("AddProject")) + } + /> + + + ) : environmentOptions.length > 0 ? ( + machineRows + ) : ( + + )} + + ); +} + export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 61f70aac2836..27fde35b9e90 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -116,6 +116,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/project/NewProject.test.ts b/apps/server/src/project/NewProject.test.ts new file mode 100644 index 000000000000..3286aa784c29 --- /dev/null +++ b/apps/server/src/project/NewProject.test.ts @@ -0,0 +1,140 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { createNewProjectFolder } from "./NewProject.ts"; + +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-new-project-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const GIT_ENV_KEYS = [ + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "EMAIL", +] as const; + +// Git reads the developer's own config (signing, default branch, identity) +// unless the test replaces it, so each test runs against an empty one. +const withGitEnv = ( + env: Partial>, + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const saved = GIT_ENV_KEYS.map((key) => [key, process.env[key]] as const); + for (const key of GIT_ENV_KEYS) delete process.env[key]; + Object.assign(process.env, { + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, + }); + return saved; + }), + () => effect, + (saved) => + Effect.sync(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }), + ); + }); + +const gitOutput = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "NewProject.test", cwd, args }); + return result.stdout.trim(); + }); + +it.layer(TestLayer)("createNewProjectFolder", (it) => { + it.effect("makes the folder, starter files, and first commit, and suffixes a taken name", () => + Effect.scoped( + withGitEnv( + { + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@test.com", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }), + "projects", + ); + + const first = yield* createNewProjectFolder({ root, name: "Pinball Stats" }); + const second = yield* createNewProjectFolder({ root, name: "pinball stats" }); + + assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); + assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + assert.isUndefined(first.commitError); + const readme = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "README.md"), + ); + assert.include(readme, "# Pinball Stats"); + assert.include(readme, `src="assets/icon.svg"`); + const icon = yield* fileSystem.readFileString( + path.join(first.workspaceRoot, "assets", "icon.svg"), + ); + assert.include(icon, ">PS"); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["log", "--format=%s"]), + "Initial commit", + ); + assert.equal( + yield* gitOutput(first.workspaceRoot, ["rev-parse", "--abbrev-ref", "HEAD"]), + "main", + ); + assert.equal(yield* gitOutput(first.workspaceRoot, ["status", "--porcelain"]), ""); + }), + ), + ), + ); + + it.effect("keeps the folder and reports why when Git cannot commit", () => + Effect.scoped( + // No identity anywhere, and Git may not guess one from the host name. + withGitEnv( + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.useConfigOnly", + GIT_CONFIG_VALUE_0: "true", + }, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-projects-" }); + + const result = yield* createNewProjectFolder({ root, name: "No Identity" }); + + assert.include(result.commitError ?? "", "no name or email"); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, "README.md"))); + assert.isTrue(yield* fileSystem.exists(path.join(result.workspaceRoot, ".git"))); + }), + ), + ), + ); +}); diff --git a/apps/server/src/project/NewProject.ts b/apps/server/src/project/NewProject.ts new file mode 100644 index 000000000000..d59be024e5e3 --- /dev/null +++ b/apps/server/src/project/NewProject.ts @@ -0,0 +1,179 @@ +/** + * NewProject - makes the folder for a project started from just a name: + * `/` with a README, an icon, and a first commit. + * + * The icon lives at `assets/icon.svg`, a path ProjectFaviconResolver already + * checks after the usual web app icons. It travels with the repository, so + * every machine that clones the project shows the same icon. + * + * @module NewProject + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { newProjectFolderName } from "@t3tools/shared/path"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +// Tailwind 600 shades: dark enough for white initials on every hue. +const ICON_BACKGROUNDS = [ + "#dc2626", + "#ea580c", + "#d97706", + "#16a34a", + "#059669", + "#0d9488", + "#0891b2", + "#0284c7", + "#2563eb", + "#4f46e5", + "#7c3aed", + "#9333ea", + "#c026d3", + "#db2777", + "#e11d48", +] as const; + +const MAX_FOLDER_ATTEMPTS = 100; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** A rounded square with the name's initials, colored by a hash of the name. */ +function newProjectIconSvg(name: string): string { + const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); + const initials = + words + .slice(0, 2) + .map((word) => Array.from(word)[0] ?? "") + .join("") + .toUpperCase() || + Array.from(name.trim())[0] || + "?"; + let hash = 0; + for (const char of name) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0; + const background = ICON_BACKGROUNDS[hash % ICON_BACKGROUNDS.length]; + return [ + ``, + ` `, + ` ${escapeXml(initials)}`, + ``, + "", + ].join("\n"); +} + +function newProjectReadme(name: string): string { + return [ + ``, + "", + `# ${name}`, + "", + "Created in [T3 Code](https://t3.codes).", + "", + ].join("\n"); +} + +// Git's own identity message runs several lines; say what to do instead. +// Otherwise its last line ("error: gpg failed to sign the data") says enough. +function describeCommitFailure(stderr: string): string { + if (/identity unknown|tell me who you are|no (name|email) was given/i.test(stderr)) { + return "Git has no name or email on this machine. Set user.name and user.email, then commit."; + } + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.at(-1) ?? "Git could not make the first commit."; +} + +/** + * Claims a fresh folder for `name` under `root` (adding `-2`, `-3`, ... when + * the name is taken), writes the starter files, and makes the first commit. + * A failed commit (no Git identity, a signing prompt) keeps the folder and + * returns why, so the project still opens. Any other failure removes the + * folder it claimed. + */ +export const createNewProjectFolder = Effect.fn("NewProject.createNewProjectFolder")( + function* (input: { readonly root: string; readonly name: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + + yield* fileSystem.makeDirectory(input.root, { recursive: true }); + // Created without `recursive`, so the create itself claims the folder and + // two requests for the same name never share one. + const folderName = newProjectFolderName(input.name); + let workspaceRoot: string | null = null; + for (let attempt = 1; workspaceRoot === null && attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { + const candidate = path.join( + input.root, + attempt === 1 ? folderName : `${folderName}-${attempt}`, + ); + const claimed = yield* fileSystem.makeDirectory(candidate).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + ); + if (claimed) workspaceRoot = candidate; + } + if (workspaceRoot === null) { + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "makeDirectory", + description: `Every folder name for "${folderName}" is taken.`, + pathOrDescriptor: input.root, + }); + } + const cwd = workspaceRoot; + + return yield* Effect.gen(function* () { + const branch = + (yield* git + .readConfigValue(cwd, "init.defaultBranch") + .pipe(Effect.orElseSucceed(() => null))) ?? "main"; + yield* git.execute({ + operation: "NewProject.init", + cwd, + args: ["init", `--initial-branch=${branch}`], + timeoutMs: 10_000, + }); + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), newProjectReadme(input.name)); + yield* fileSystem.makeDirectory(path.join(cwd, "assets")); + yield* fileSystem.writeFileString( + path.join(cwd, "assets", "icon.svg"), + newProjectIconSvg(input.name), + ); + // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + yield* git.execute({ + operation: "NewProject.add", + cwd, + args: ["add", "--force", "--", "README.md", "assets/icon.svg"], + timeoutMs: 10_000, + }); + const commitError = yield* git + .execute({ + operation: "NewProject.commit", + cwd, + args: ["commit", "--message", "Initial commit"], + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe( + Effect.map((result) => + result.exitCode === 0 ? undefined : describeCommitFailure(result.stderr), + ), + Effect.catch((error) => Effect.succeed(error.message)), + ); + return { workspaceRoot: cwd, commitError }; + }).pipe(Effect.onError(() => fileSystem.remove(cwd, { recursive: true }).pipe(Effect.ignore))); + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 930e7e7bb369..fd505400c2e5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5556,6 +5556,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a project from just a name in the projects folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created: Array<{ readonly title: string; readonly workspaceRoot: string }> = []; + const gitCalls: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ title: command.title, workspaceRoot: command.workspaceRoot }); + } + return { sequence: created.length }; + }), + }, + gitVcsDriver: { + readConfigValue: () => Effect.succeed(null), + execute: (input) => + Effect.sync(() => { + gitCalls.push(input.args.join(" ")); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const root = (yield* client[WS_METHODS.serverGetConfig]({})).newProjectsRoot ?? ""; + const result = yield* client[WS_METHODS.projectsCreateNew]({ name: "Pinball Stats" }); + + assert.equal(result.workspaceRoot, path.join(root, "pinball-stats")); + assert.isUndefined(result.commitError); + assert.deepEqual(created, [ + { title: "Pinball Stats", workspaceRoot: result.workspaceRoot }, + ]); + assert.deepEqual(gitCalls, [ + "init --initial-branch=main", + "add --force -- README.md assets/icon.svg", + "commit --message Initial commit", + ]); + assert.isTrue( + yield* fileSystem.exists(path.join(result.workspaceRoot, "assets", "icon.svg")), + ); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1bdf80208e55..f256dfaa4aa0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectId, + type ProjectCreateNewInput, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -143,6 +144,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as NewProject from "./project/NewProject.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -2018,6 +2020,49 @@ const makeWsRpcLayer = ( ); }); + // Projects started from just a name live beside Scratch and worktrees, + // away from folders the user organizes by hand. A nested repository is + // fine here (unlike Scratch) because each project gets its own `git init`. + const newProjectsRoot = path.resolve(config.baseDir, "projects"); + const createNewProject = (input: ProjectCreateNewInput) => + Effect.gen(function* () { + const folder = yield* NewProject.createNewProjectFolder({ + root: newProjectsRoot, + name: input.name, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the project folder.", + cause, + }), + ), + ); + const projectId = ProjectId.make(yield* randomUUID); + yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("project-create-new"), + projectId, + title: input.name, + workspaceRoot: folder.workspaceRoot, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + }).pipe( + // Only a rejected command means no project uses the folder. An + // interrupt can land after the command is queued, so keep it then. + Effect.tapError(() => + fileSystem.remove(folder.workspaceRoot, { recursive: true }).pipe(Effect.ignore), + ), + ); + return { + projectId, + workspaceRoot: folder.workspaceRoot, + ...(folder.commitError === undefined ? {} : { commitError: folder.commitError }), + }; + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -2082,6 +2127,7 @@ const makeWsRpcLayer = ( threadSnapshotPagination: true, reasoningMessages: true, ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), + newProjectsRoot, }; }); @@ -3252,6 +3298,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { "rpc.aggregate": "orchestration", }), + [WS_METHODS.projectsCreateNew]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateNew, createNewProject(input), { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6ea939d666bb..95f2df5928ae 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -10,6 +10,9 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectGitHubRepository, + getNewProjectGitHubTarget, + getNewProjectPathPreview, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -43,8 +46,11 @@ import * as Option from "effect/Option"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, + CheckIcon, + ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, + FolderGit2Icon, FolderIcon, FolderPlusIcon, MessageSquareDashedIcon, @@ -100,6 +106,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useNewProject } from "../hooks/useNewProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; @@ -170,6 +177,7 @@ import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -862,6 +870,17 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + // The name step of New project: while set, the palette input is the name. + const [newProjectFlow, setNewProjectFlow] = useState<{ + readonly environmentId: EnvironmentId; + /** Machine of the Add project sources view under this step; null from the palette root. */ + readonly sourcesEnvironmentId: EnvironmentId | null; + } | null>(null); + const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); + const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); + // State lags a render behind, so a repeated Enter could start a second create. + const newProjectSubmittingRef = useRef(false); + const createNewProject = useNewProject(); const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -1073,9 +1092,15 @@ function OpenCommandPaletteDialog(props: { getFilesystemBrowsePath( query, browseEnvironmentPlatform, - browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null, ), - [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + [ + browseEnvironmentId, + browseEnvironmentPlatform, + isRemoteProjectRepositoryStep, + newProjectFlow, + query, + ], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1469,8 +1494,12 @@ function OpenCommandPaletteDialog(props: { function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); + } else if (newProjectFlow?.sourcesEnvironmentId) { + // The machine switcher may have moved off the sources view's machine. + setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1531,6 +1560,28 @@ function OpenCommandPaletteDialog(props: { [pushPaletteView], ); + /** Folder that holds an environment's name-only projects, or null when it has none. */ + const newProjectsRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => + environments.find((environment) => environment.environmentId === environmentId)?.serverConfig + ?.newProjectsRoot ?? null, + [environments], + ); + + const startNewProject = useCallback( + (environmentId: EnvironmentId, sourcesEnvironmentId: EnvironmentId | null): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + setNewProjectFlow({ environmentId, sourcesEnvironmentId }); + setNewProjectPublishesToGitHub(false); + pushPaletteView({ + addonIcon: , + groups: [], + }); + }, + [pushPaletteView], + ); + const openSourceControlSettings = useCallback(() => { setOpen(false); void navigate({ to: "/settings/source-control" }); @@ -1556,6 +1607,21 @@ function OpenCommandPaletteDialog(props: { }, ]; + if (newProjectsRootFor(environmentId) !== null) { + sourceItems.unshift({ + kind: "action", + value: `action:add-project:${environmentId}:new`, + searchTerms: ["new project", "create", "empty", "repository", "git init"], + title: "New project", + description: "Start a new Git repository from a name", + icon: , + keepOpen: true, + run: async () => { + startNewProject(environmentId, environmentId); + }, + }); + } + const orderedSources: ReadonlyArray = [ "url", ...sortAddProjectProviderSources(readinessBySource), @@ -1626,7 +1692,13 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + [ + newProjectsRootFor, + openSourceControlSettings, + startAddProjectBrowse, + startAddProjectClone, + startNewProject, + ], ); const startAddProjectSourceSelection = useCallback( @@ -1665,24 +1737,36 @@ function OpenCommandPaletteDialog(props: { ], ); - const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( - (option) => ({ - kind: "action", - value: `action:add-project:environment:${option.environmentId}`, - searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], - title: option.label, - description: option.isConnected - ? option.isPrimary - ? "This device" - : option.environmentId - : option.status, - disabled: !option.isConnected, - icon: , - keepOpen: true, - run: async () => { - startAddProjectSourceSelection(option.environmentId); - }, - }), + const buildEnvironmentItem = ( + option: AddProjectEnvironmentOption, + value: string, + run: (environmentId: EnvironmentId) => void, + ): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isConnected + ? option.isPrimary + ? "This device" + : option.environmentId + : option.status, + disabled: !option.isConnected, + icon: , + keepOpen: true, + run: async () => { + run(option.environmentId); + }, + }); + const addProjectEnvironmentItems = addProjectEnvironmentOptions.map((option) => + buildEnvironmentItem( + option, + `action:add-project:environment:${option.environmentId}`, + startAddProjectSourceSelection, + ), + ); + const newProjectEnvironmentOptions = addProjectEnvironmentOptions.filter( + (option) => option.isConnected && newProjectsRootFor(option.environmentId) !== null, ); const addProjectEnvironmentGroups = useMemo( @@ -1724,12 +1808,20 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + // New project starts on this device (options list it first); the name step + // lists the other machines when there is a choice. + const openNewProjectFlow = () => { + const firstOption = newProjectEnvironmentOptions[0]; + if (firstOption) startNewProject(firstOption.environmentId, null); + }; + useLayoutEffect(() => { if (openIntent?.kind !== "search") return; browseNavigation.invalidate(); cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setLinkedThreadSearch(openIntent); setQuery(openIntent.query); @@ -1751,6 +1843,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent(); browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); setQuery(""); const currentPrefix = @@ -1949,6 +2042,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (newProjectEnvironmentOptions.length > 0) { + actionItems.push({ + kind: "action", + value: "action:new-project", + searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], + title: "New project", + icon: , + keepOpen: true, + run: async () => { + openNewProjectFlow(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", @@ -2081,6 +2188,7 @@ function OpenCommandPaletteDialog(props: { cloneLookupGeneration.current += 1; setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); + setNewProjectFlow(null); setViewStack([]); pushPaletteView({ addonIcon: , @@ -2398,6 +2506,34 @@ function OpenCommandPaletteDialog(props: { ], ); + const newProjectGitHubTarget = + newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); + const newProjectName = query.trim(); + const canSubmitNewProject = + newProjectFlow !== null && + newProjectName.length > 0 && + !isCreatingNewProject && + canCreateProjectInEnvironment(browseEnvironment?.connection.phase); + + async function submitNewProject(): Promise { + if (newProjectFlow === null || !canSubmitNewProject || newProjectSubmittingRef.current) { + return; + } + newProjectSubmittingRef.current = true; + setIsCreatingNewProject(true); + try { + const created = await createNewProject({ + environmentId: newProjectFlow.environmentId, + name: newProjectName, + github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, + }); + if (created) setOpen(false); + } finally { + newProjectSubmittingRef.current = false; + setIsCreatingNewProject(false); + } + } + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { return getAddProjectInitialQueryForEnvironment(environmentId); } @@ -2681,8 +2817,135 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; + const newProjectPathPreview = + newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); + const newProjectGitHubToggleValue = "new-project:github"; + // The name step's way out to folders and clones, for the selected machine. + // It replaces the name step (and a sources view for another machine under + // it), so Back returns to wherever New project was opened from. + const showExistingProjectSources = (environmentId: EnvironmentId) => { + const sourcesEnvironmentId = newProjectFlow?.sourcesEnvironmentId ?? null; + if (sourcesEnvironmentId === environmentId) { + popView(); + return; + } + if (sourcesEnvironmentId !== null) { + setViewStack((previousViews) => previousViews.slice(0, -1)); + } + popView(); + startAddProjectSourceSelection(environmentId); + }; + // Switching machines keeps the typed name; the GitHub option follows the + // machine because source control discovery reads addProjectEnvironmentId. + const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { + setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); + setAddProjectEnvironmentId(environmentId); + }; + const selectedNewProjectEnvironment = + newProjectFlow === null + ? undefined + : newProjectEnvironmentOptions.find( + (option) => option.environmentId === newProjectFlow.environmentId, + ); + // Shown when there is a choice, or when the selected machine went away and + // another one can take over. + const showNewProjectMachines = + newProjectFlow !== null && + (newProjectEnvironmentOptions.length > 1 || + (selectedNewProjectEnvironment === undefined && newProjectEnvironmentOptions.length > 0)); + const newProjectEnvironmentLabel = showNewProjectMachines + ? (selectedNewProjectEnvironment?.label ?? + environmentLabelById.get(newProjectFlow.environmentId) ?? + null) + : null; + const newProjectMachineGroup: CommandPaletteView["groups"][number] | null = + !showNewProjectMachines || newProjectFlow === null + ? null + : { + value: "new-project-machines", + label: "Environments", + items: newProjectEnvironmentOptions.map((option) => ({ + ...buildEnvironmentItem( + option, + `new-project:environment:${option.environmentId}`, + switchNewProjectEnvironment, + ), + // The create in flight keeps the machine it started on. + ...(isCreatingNewProject ? { disabled: true } : {}), + ...(option.environmentId === newProjectFlow.environmentId + ? { + titleTrailingContent: ( + + ), + } + : {}), + })), + }; + const newProjectExistingGroup: CommandPaletteView["groups"][number] | null = + newProjectFlow === null + ? null + : { + value: "new-project-existing", + label: "", + items: [ + { + kind: "action", + value: "new-project:existing", + searchTerms: [], + title: "Add existing project", + description: "Open a folder or clone a repository", + icon: , + titleTrailingContent: ( + + ), + keepOpen: true, + run: async () => { + showExistingProjectSources(newProjectFlow.environmentId); + }, + }, + ], + }; + const newProjectOptionGroups: CommandPaletteView["groups"] = + newProjectGitHubTarget === null || newProjectPathPreview === null + ? [] + : [ + { + value: "new-project-options", + label: "Options", + items: [ + { + kind: "action", + value: newProjectGitHubToggleValue, + searchTerms: [], + title: "Create private repository on GitHub", + description: + newProjectName.length > 0 + ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) + : (newProjectGitHubTarget.account ?? "Your GitHub account"), + icon: , + titleTrailingContent: ( + + + + ), + keepOpen: true, + run: async () => { + setNewProjectPublishesToGitHub((publishes) => !publishes); + }, + }, + ], + }, + ]; + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; - if (addProjectCloneFlow?.step === "repository") { + if (newProjectFlow !== null) { + displayedGroups = [ + ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), + ...newProjectOptionGroups, + ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), + ]; + } else if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; @@ -2691,8 +2954,10 @@ function OpenCommandPaletteDialog(props: { } const inputPlaceholder = - remoteProjectInputPlaceholder(addProjectCloneFlow) ?? - getCommandPaletteInputPlaceholder(paletteMode); + newProjectFlow !== null + ? "Project name" + : (remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode)); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = @@ -2799,6 +3064,21 @@ function OpenCommandPaletteDialog(props: { return; } + // Enter creates the project unless an item below the name is + // highlighted, in which case it runs that item. + if ( + newProjectFlow !== null && + event.key === "Enter" && + highlightedItemValue === null && + // Enter that confirms an IME composition is part of typing the name. + !event.nativeEvent.isComposing && + event.keyCode !== 229 + ) { + event.preventDefault(); + void submitNewProject(); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -2957,7 +3237,34 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + newProjectFlow !== null ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitNewProject(); + }} + /> + } + > + {isCreatingNewProject ? "Creating" : "Create"} + + Enter + + + Create (Enter) + + ) : addProjectCloneFlow?.step === "repository" ? ( + {newProjectPathPreview !== null ? ( +
+
+ + + + {newProjectName.length > 0 ? newProjectName : "New project"} + + + {newProjectName.length > 0 + ? `Creates ${newProjectPathPreview}` + : `Goes in ${newProjectsRoot}`} + {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} + + +
+
+ ) : null} {remoteProjectContext ? (
Repository
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b94707f5eca6..4be4aee17ff7 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label ? {group.label} : null} {(item) => item.disabled ? ( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3d5b7bfd311d..ac995a47a670 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -304,7 +304,7 @@ export function DraftHeroHeadline({ {projectPickerEntries.length > 0 ? : null} - New project + Add project diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e552e64c19a3..e2bd4bfb74e5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -127,7 +127,7 @@ export function SidebarThreadHeader({ {hasProjects ? ( <> {projectScope} - + diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts new file mode 100644 index 000000000000..175609cbfc42 --- /dev/null +++ b/apps/web/src/hooks/useNewProject.ts @@ -0,0 +1,147 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { projectEnvironment } from "~/state/projects"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +/** + * Starts a project from just a name. The server makes a folder under its + * `newProjectsRoot` with a README, an icon, and a first commit; this then + * opens a new thread draft in it. With `github`, it also publishes the + * repository as private, without holding up the draft. + * + * Resolves to whether the project was created. + */ +export function useNewProject() { + const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + + const publishToGitHub = useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly account: string | null; + }) => { + const result = await publishRepository({ + environmentId: input.environmentId, + input: { + cwd: input.workspaceRoot, + provider: "github", + repository: getNewProjectGitHubRepository(input, input.workspaceRoot), + visibility: "private", + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the GitHub repository", + description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Published to GitHub", + description: result.value.repository.nameWithOwner, + }), + ); + }, + [publishRepository], + ); + + return useCallback( + async (input: { + readonly environmentId: EnvironmentId; + readonly name: string; + readonly github: { readonly account: string | null } | null; + }): Promise => { + const result = await createNew({ + environmentId: input.environmentId, + input: { name: input.name }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create the project", + description: errorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + + const { projectId, workspaceRoot, commitError } = result.value; + // The folder sits in T3 Code's data directory, so always say where. + toastManager.add( + stackedThreadToast( + commitError === undefined + ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot } + : { + type: "warning", + title: `Created ${input.name} without a first commit`, + description: `${commitError} The project is in ${workspaceRoot}.`, + }, + ), + ); + if (input.github) { + void publishToGitHub({ + environmentId: input.environmentId, + workspaceRoot, + account: input.github.account, + }); + } + + const projectRef = scopeProjectRef(input.environmentId, projectId); + // Drafts key off the project's stored path, so wait for the create event + // to reach the store before opening one. + const project = await waitForProject(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`, + }), + ); + return null; + }); + if (project === null) return true; + await handleNewThread(projectRef).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to open project", + description: errorMessage(error), + }), + ); + }); + return true; + }, + [createNew, handleNewThread, publishToGitHub], + ); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 306632d9ca64..3d068a232be7 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -79,7 +79,15 @@ az extension add --name azure-devops az login ``` -## Clone or publish a project +## Start, clone, or publish a project + +To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or +**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git +repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, +an icon, and a first commit, then opens a new thread in it. The folder is named after the project, +like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also +publish it. If Git has no name or email on that machine, the project is created without the +first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting provider or paste a Git URL, then choose where to save it. The project opens right away while the diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4f..ec03ea86dce1 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -8,6 +8,7 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -250,6 +251,36 @@ export function getCloneDestinationPath( return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; } +/** + * Where `projects.createNew` will put a project named `name`. The server adds + * `-2`, `-3`, ... when that folder is taken, so this is a preview. + */ +export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { + return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); +} + +/** + * The GitHub account a new project would be published under, or null when + * GitHub is not ready on that environment. A ready GitHub with an unknown + * account still publishes; `gh` picks the signed-in user. + */ +export function getNewProjectGitHubTarget( + discovery: SourceControlDiscoveryResult | null, +): { readonly account: string | null } | null { + if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; + const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); + return { account: github ? Option.getOrNull(github.auth.account) : null }; +} + +/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ +export function getNewProjectGitHubRepository( + target: { readonly account: string | null }, + workspaceRoot: string, +): string { + const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; + return target.account ? `${target.account}/${folderName}` : folderName; +} + /** * Destination query after choosing a directory while the clone folder is * pinned in the path input. Selecting an existing directory with the pinned diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index d969a8c0d379..5c39113501fb 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -99,6 +99,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + // Makes a new folder and repository from just a name, then the project. + createNew: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-new", + tag: WS_METHODS.projectsCreateNew, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f97924b084fb..a36003eb11b1 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -288,6 +288,20 @@ export const ProjectEnsureScratchResult = Schema.Struct({ }); export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; +/** A project started from just a name, in a new folder the server makes. */ +export const ProjectCreateNewInput = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type ProjectCreateNewInput = typeof ProjectCreateNewInput.Type; + +export const ProjectCreateNewResult = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: TrimmedNonEmptyString, + /** Why the first commit failed. The project and its files exist either way. */ + commitError: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41331f0c46cf..c1b25459048e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,8 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateNewInput, + ProjectCreateNewResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -295,6 +297,7 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", projectsEnsureScratch: "projects.ensureScratch", + projectsCreateNew: "projects.createNew", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -1003,6 +1006,13 @@ const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. +const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { + payload: ProjectCreateNewInput, + success: ProjectCreateNewResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1521,6 +1531,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, + WsProjectsCreateNewRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49faaaf44712..c8983988ead1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -617,6 +617,11 @@ export const ServerConfig = Schema.Struct({ * and whose data dir is outside a Git checkout. */ scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** + * Folder that holds projects started from just a name. Present only on + * servers that answer projects.createNew. + */ + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e2..b018a4e81d8c 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + newProjectFolderName, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "./path.ts"; @@ -43,4 +44,17 @@ describe("path helpers", () => { // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); + + it("names a new project's folder from any typed name", () => { + expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); + expect(newProjectFolderName(" Café & Crème! ")).toBe("cafe-creme"); + expect(newProjectFolderName("../../etc")).toBe("etc"); + // Nothing usable left, so the server falls back to a fixed name. + expect(newProjectFolderName("🎱🎱")).toBe("project"); + expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); + // Windows cannot make folders with device names. + expect(newProjectFolderName("Con")).toBe("con-project"); + expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); + expect(newProjectFolderName("console")).toBe("console"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa8..c8626eb5e14c 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,24 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +// Windows refuses these as file names, with or without an extension. +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; + +/** + * Folder name for a project started from just a name ("Pinball Stats" becomes + * "pinball-stats"). The server uses it for `projects.createNew`, and clients + * use it to show the path before the server makes it. + */ +export function newProjectFolderName(name: string): string { + const slug = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .slice(0, 64) + .replace(/-+$/, ""); + if (slug.length === 0) return "project"; + return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug; +} From 6fd023725d36334b0c9f0f2b7b95b322a6846b0b Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:21:47 +0000 Subject: [PATCH 4/6] fix(server): reclaim leftover Antigravity _MEI temp dirs Pre-#12008 health probes unpacked agy_acp_server.exe into %TEMP%\_MEI* and were killed before PyInstaller cleaned up. Profile isolation stopped new leaks but never deleted those leftovers. On Windows driver start, sweep host TEMP/TMP once: delete only _MEI* directories that have Antigravity markers, are older than two days, and are not locked. Unmarked dirs, dirs at or under the two-day cutoff, and locked dirs are left alone. --- .../Drivers/AntigravityDriver.test.ts | 42 ++++ .../src/provider/Drivers/AntigravityDriver.ts | 18 +- .../acp/AntigravitySessionFiles.test.ts | 196 ++++++++++++++++++ .../provider/acp/AntigravitySessionFiles.ts | 81 ++++++++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/acp/AntigravitySessionFiles.test.ts diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 18f7aaffee06..fd83e59c6950 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -530,6 +530,48 @@ it.layer(testLayer)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect( + "on Windows create, removes stale Antigravity-marked _MEI leftovers from the injected host temp", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const instanceId = ProviderInstanceId.make("antigravity-host-temp-sweep"); + const hostTemp = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-antigravity-host-temp-", + }); + const stale = path.join(hostTemp, "_MEI12239"); + const unmarked = path.join(hostTemp, "_MEIother"); + yield* fs.makeDirectory(stale, { recursive: true }); + yield* fs.makeDirectory(unmarked, { recursive: true }); + yield* fs.writeFileString(path.join(stale, "agy_acp_licenses.txt"), "agy"); + yield* fs.writeFileString(path.join(unmarked, "payload.bin"), "pyinstaller"); + yield* fs.utimes(stale, 1, 1); + yield* fs.utimes(unmarked, 1, 1); + yield* TestClock.setTime(Date.UTC(2026, 8, 20)); + yield* AntigravityDriver.create({ + instanceId, + displayName: "Sweep", + enabled: false, + config: AntigravityDriver.defaultConfig(), + environment: [], + }).pipe( + Effect.provide( + Layer.mock(AntigravityInstallation)({ + managedDirectory: config.stateDir, + resolve: () => Effect.die("unused"), + acquire: () => Effect.die("unused"), + }), + ), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { TEMP: hostTemp, TMP: hostTemp }), + ); + expect(yield* fs.exists(stale)).toBe(false); + expect(yield* fs.exists(unmarked)).toBe(true); + }).pipe(Effect.scoped), + ); + it.effect("probes through installation resolution without launching a process", () => Effect.gen(function* () { const h = yield* makeHarness({ enabled: true }); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fb9c7041b5fd..590ecf84a38e 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,6 +1,6 @@ import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { NodeRuntimeUnavailableError, nodeRuntimeUnavailableMessage, @@ -43,8 +43,10 @@ import { import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { + cleanOrphanedAntigravitySystemTempDirs, removeAntigravityRuntimeTempDirs, removeAntigravitySessionFiles, + resolveAntigravityLegacySystemTempDirectories, } from "../acp/AntigravitySessionFiles.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; @@ -101,7 +103,8 @@ export const AntigravityDriver: ProviderDriver { + expect( + resolveAntigravityLegacySystemTempDirectories({ + TEMP: "C:\\Temp", + TMP: "C:\\Temp", + }), + ).toEqual(["C:\\Temp"]); + expect( + resolveAntigravityLegacySystemTempDirectories({ + TEMP: "C:\\Temp", + TMP: "C:\\Users\\user\\AppData\\Local\\Temp", + }), + ).toEqual(["C:\\Temp", "C:\\Users\\user\\AppData\\Local\\Temp"]); + expect(resolveAntigravityLegacySystemTempDirectories({ TEMP: "", TMP: undefined })).toEqual([]); +}); + +it.layer(NodeServices.layer)("cleanOrphanedAntigravitySystemTempDirs", (it) => { + const sweep = (systemTempDirectory: string) => + cleanOrphanedAntigravitySystemTempDirs({ + systemTempDirectory, + nowMs: NOW_MS, + minAgeMs: ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS, + }); + + const makeDir = Effect.fn("makeAntigravityMeiFixture")(function* ( + root: string, + name: string, + contents: ReadonlyArray<{ readonly relative: ReadonlyArray; readonly body: string }>, + stale: boolean, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join(root, name); + yield* fs.makeDirectory(directory, { recursive: true }); + for (const entry of contents) { + const filePath = path.join(directory, ...entry.relative); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, entry.body); + } + if (stale) { + yield* fs.utimes(directory, STALE_SECONDS, STALE_SECONDS); + } + return directory; + }); + + it.effect("removes stale Antigravity-marked _MEI directories and leaves everything else", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-mei-sweep-" }); + const licenses = yield* makeDir( + root, + "_MEIlicenses", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + true, + ); + const harness = yield* makeDir( + root, + "_MEIharness", + [{ relative: ["localharness"], body: "harness" }], + true, + ); + const nested = yield* makeDir( + root, + "_MEInested", + [{ relative: ["google3", "third_party", "jetski_prod", "localharness"], body: "jetski" }], + true, + ); + const young = yield* makeDir( + root, + "_MEIyoung", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + false, + ); + const google3Only = yield* makeDir( + root, + "_MEIgoogle3", + [{ relative: ["google3", "payload.bin"], body: "other" }], + true, + ); + const unmarked = yield* makeDir( + root, + "_MEIother", + [{ relative: ["payload.bin"], body: "pyinstaller" }], + true, + ); + const notMei = yield* makeDir( + root, + "scratch", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + true, + ); + + yield* sweep(root); + + expect(yield* fs.exists(licenses)).toBe(false); + expect(yield* fs.exists(harness)).toBe(false); + expect(yield* fs.exists(nested)).toBe(false); + expect(yield* fs.exists(young)).toBe(true); + expect(yield* fs.exists(google3Only)).toBe(true); + expect(yield* fs.exists(unmarked)).toBe(true); + expect(yield* fs.exists(notMei)).toBe(true); + expect(yield* fs.exists(path.join(notMei, "agy_acp_licenses.txt"))).toBe(true); + }), + ); + + it.effect("preserves a marked _MEI directory at the exact minimum-age boundary", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-antigravity-mei-boundary-", + }); + const boundary = yield* makeDir( + root, + "_MEIboundary", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + false, + ); + const older = yield* makeDir( + root, + "_MEIolder", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + false, + ); + const cutoff = new Date(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS); + const pastCutoff = new Date(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS - 1000); + yield* fs.utimes(boundary, cutoff, cutoff); + yield* fs.utimes(older, pastCutoff, pastCutoff); + + yield* sweep(root); + + expect(yield* fs.exists(boundary)).toBe(true); + expect(yield* fs.exists(older)).toBe(false); + }), + ); + + it.effect("leaves a marked directory alone when remove fails with EBUSY", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-mei-busy-" }); + const busy = yield* makeDir( + root, + "_MEIbusy", + [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], + true, + ); + const locked = FileSystem.FileSystem.of({ + ...fs, + remove: (target, options) => + target === busy + ? Effect.fail( + PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + description: "EBUSY", + }), + ) + : fs.remove(target, options), + }); + + yield* sweep(root).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + expect(yield* fs.exists(path.join(busy, "agy_acp_licenses.txt"))).toBe(true); + }), + ); + + it.effect("does not fail when the injected system temp directory is missing", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => + fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-mei-missing-" }), + ), + ); + yield* sweep(path.join(root, "gone")); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index f20640bac0a0..cec596a3d5af 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -1,5 +1,7 @@ +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -59,3 +61,82 @@ export const removeAntigravityRuntimeTempDirs = Effect.fn("removeAntigravityRunt Effect.logWarning("Could not remove leftover Antigravity runtime temp files."), ), ); + +/** Markers unique to an Antigravity PyInstaller unpack. Never treat `google3` alone as enough. */ +const ANTIGRAVITY_MEI_MARKERS = [ + ["agy_acp_licenses.txt"], + ["localharness"], + ["google3", "third_party", "jetski_prod", "localharness"], +] as const; + +/** Live unpacks stay recent. Pre-#12008 probe leftovers are days old. */ +export const ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS = 2 * 24 * 60 * 60 * 1000; + +/** Windows host TEMP/TMP only. Empty or duplicate values are dropped. */ +export const resolveAntigravityLegacySystemTempDirectories = ( + environment: NodeJS.ProcessEnv, +): ReadonlyArray => { + const directories: string[] = []; + const seen = new Set(); + for (const value of [environment.TEMP, environment.TMP]) { + if (value === undefined || value === "") continue; + if (seen.has(value)) continue; + seen.add(value); + directories.push(value); + } + return directories; +}; + +/** + * Reclaims T3-created `%TEMP%\_MEI*` leftovers from the pre-#12008 health + * probe. Call once on driver start with an injected directory. Only stale + * `_MEI*` dirs that contain Antigravity markers are removed. Unmarked dirs, + * dirs at or under the two-day cutoff, and dirs that fail with a lock are + * left alone. The real system temp is never listed from tests. + */ +export const cleanOrphanedAntigravitySystemTempDirs = Effect.fn( + "cleanOrphanedAntigravitySystemTempDirs", +)( + function* (input: { + readonly systemTempDirectory: string; + readonly nowMs?: number; + readonly minAgeMs?: number; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (input.systemTempDirectory === "" || !(yield* fs.exists(input.systemTempDirectory))) { + return; + } + const nowMs = input.nowMs ?? (yield* Clock.currentTimeMillis); + const minAgeMs = input.minAgeMs ?? ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS; + const entries = yield* fs + .readDirectory(input.systemTempDirectory) + .pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.startsWith("_MEI")) continue; + const directory = path.join(input.systemTempDirectory, entry); + const stats = yield* fs.stat(directory).pipe(Effect.option); + if (Option.isNone(stats) || stats.value.type !== "Directory") continue; + const modifiedAt = Option.match(stats.value.mtime, { + onNone: () => Option.getOrUndefined(stats.value.birthtime), + onSome: (mtime) => mtime, + }); + if (modifiedAt === undefined || nowMs - modifiedAt.getTime() <= minAgeMs) continue; + const marked = yield* hasAntigravityMeiMarker(directory); + if (!marked) continue; + yield* fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore); + } + }, + Effect.catch(() => Effect.logWarning("Could not remove leftover Antigravity system temp files.")), +); + +const hasAntigravityMeiMarker = Effect.fnUntraced(function* (directory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const marker of ANTIGRAVITY_MEI_MARKERS) { + if (yield* fs.exists(path.join(directory, ...marker))) { + return true; + } + } + return false; +}); From 223fabb5c90b7153aacb5cf3584e17b54f15dc84 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:15:19 +0000 Subject: [PATCH 5/6] fix(server): build Antigravity sweep timestamps with DateTime --- .../src/provider/acp/AntigravitySessionFiles.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts index f93c989b8db8..4d4acab8a95d 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -138,8 +139,12 @@ it.layer(NodeServices.layer)("cleanOrphanedAntigravitySystemTempDirs", (it) => { [{ relative: ["agy_acp_licenses.txt"], body: "agy" }], false, ); - const cutoff = new Date(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS); - const pastCutoff = new Date(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS - 1000); + const cutoff = DateTime.toDateUtc( + DateTime.makeUnsafe(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS), + ); + const pastCutoff = DateTime.toDateUtc( + DateTime.makeUnsafe(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS - 1000), + ); yield* fs.utimes(boundary, cutoff, cutoff); yield* fs.utimes(older, pastCutoff, pastCutoff); From d500a00face6220e8431db120066890541dcba59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 08:47:24 +0000 Subject: [PATCH 6/6] fix(server): skip live Antigravity _MEI extracts before removal A marked extract older than two days was deleted as soon as the sweep started. On Windows that recursive remove unlinks unlocked data files before a locked executable returns EBUSY, so a long-running Antigravity process could lose its unpack. The sweep now skips a directory when a live process still has it as _MEIPASS, its cwd, or an open file. On Windows, renaming the directory aside is the open-handle check, and a failed rename leaves every file in place. Verification: from the repo root, vp test run apps/server/src/provider/acp/AntigravitySessionFiles.test.ts apps/server/src/provider/Drivers/AntigravityDriver.test.ts Result: Test Files 2 passed (2), Tests 19 passed (19). The new test holds payload.bin open in a stale marked extract and checks the unopened sibling and nested files stay byte-for-byte intact while an unreferenced orphan is removed. vp run typecheck (apps/server) exited 0. Checked on Linux x64 with Node 24.13.1. Not run against a Windows %TEMP% directory or a real agy_acp_server.exe. Co-authored-by: maco --- .../src/provider/Drivers/AntigravityDriver.ts | 3 +- .../acp/AntigravitySessionFiles.test.ts | 86 +++++++ .../provider/acp/AntigravitySessionFiles.ts | 234 +++++++++++++++++- 3 files changed, 317 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 590ecf84a38e..7b32daad910e 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -134,7 +134,8 @@ export const AntigravityDriver: ProviderDriver {}, 1_000_000);", +].join(""); + +/** Keeps one file open and publishes PyInstaller's `_MEIPASS`. The sweep must see it before removal. */ +const holdLiveExtract = (directory: string, filePath: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const nodePath = yield* HostProcessExecutablePath; + const handle = yield* spawner.spawn( + ChildProcess.make(nodePath, ["-e", LIVE_EXTRACT_HOLDER], { + env: { _MEIPASS: directory, T3_HOLD_FILE: filePath }, + extendEnv: true, + stdin: "ignore", + stderr: "ignore", + }), + ); + const ready = yield* handle.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter((entry) => entry.trim() === "ready"), + Stream.runHead, + ); + if (Option.isNone(ready)) { + return yield* Effect.die("Live Antigravity extract holder exited before opening the file."); + } + }).pipe(Effect.timeout("5 seconds")); it("dedupes TEMP and TMP and ignores empty host temp values", () => { expect( @@ -155,6 +190,57 @@ it.layer(NodeServices.layer)("cleanOrphanedAntigravitySystemTempDirs", (it) => { }), ); + it.effect("preserves the contents of a marked _MEI directory a live process still has open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-mei-live-" }); + const live = yield* makeDir( + root, + "_MEIlive", + [ + { relative: ["agy_acp_licenses.txt"], body: "agy-live-license" }, + { relative: ["payload.bin"], body: "open-handle" }, + { relative: ["notes.txt"], body: "untouched-sibling" }, + { + relative: ["google3", "third_party", "jetski_prod", "localharness"], + body: "nested-harness", + }, + ], + false, + ); + const orphan = yield* makeDir( + root, + "_MEIorphan", + [{ relative: ["agy_acp_licenses.txt"], body: "orphan" }], + false, + ); + const held = path.join(live, "payload.bin"); + // Only payload.bin is held open. The sibling and nested files are the + // ones a recursive delete would unlink before it ever hit a lock. + yield* holdLiveExtract(live, held); + const pastCutoff = DateTime.toDateUtc( + DateTime.makeUnsafe(NOW_MS - ANTIGRAVITY_LEGACY_SYSTEM_TEMP_MIN_AGE_MS - 1000), + ); + yield* fs.utimes(live, pastCutoff, pastCutoff); + yield* fs.utimes(orphan, pastCutoff, pastCutoff); + + yield* sweep(root); + + expect(yield* fs.readFileString(path.join(live, "agy_acp_licenses.txt"))).toBe( + "agy-live-license", + ); + expect(yield* fs.readFileString(held)).toBe("open-handle"); + expect(yield* fs.readFileString(path.join(live, "notes.txt"))).toBe("untouched-sibling"); + expect( + yield* fs.readFileString( + path.join(live, "google3", "third_party", "jetski_prod", "localharness"), + ), + ).toBe("nested-harness"); + expect(yield* fs.exists(orphan)).toBe(false); + }), + ); + it.effect("leaves a marked directory alone when remove fails with EBUSY", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index cec596a3d5af..9ddf6e1e0f50 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -1,9 +1,12 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); const decodeSessionMetadata = Schema.decodeEffect( @@ -91,8 +94,8 @@ export const resolveAntigravityLegacySystemTempDirectories = ( * Reclaims T3-created `%TEMP%\_MEI*` leftovers from the pre-#12008 health * probe. Call once on driver start with an injected directory. Only stale * `_MEI*` dirs that contain Antigravity markers are removed. Unmarked dirs, - * dirs at or under the two-day cutoff, and dirs that fail with a lock are - * left alone. The real system temp is never listed from tests. + * dirs at or under the two-day cutoff, and dirs a live process still has + * open are left alone. The real system temp is never listed from tests. */ export const cleanOrphanedAntigravitySystemTempDirs = Effect.fn( "cleanOrphanedAntigravitySystemTempDirs", @@ -112,6 +115,7 @@ export const cleanOrphanedAntigravitySystemTempDirs = Effect.fn( const entries = yield* fs .readDirectory(input.systemTempDirectory) .pipe(Effect.orElseSucceed(() => [])); + const candidates: Array = []; for (const entry of entries) { if (!entry.startsWith("_MEI")) continue; const directory = path.join(input.systemTempDirectory, entry); @@ -122,9 +126,22 @@ export const cleanOrphanedAntigravitySystemTempDirs = Effect.fn( onSome: (mtime) => mtime, }); if (modifiedAt === undefined || nowMs - modifiedAt.getTime() <= minAgeMs) continue; - const marked = yield* hasAntigravityMeiMarker(directory); - if (!marked) continue; - yield* fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore); + if (!(yield* hasAntigravityMeiMarker(directory))) continue; + candidates.push(directory); + } + if (candidates.length === 0) return; + // A failed probe must not start deleting. Treating every candidate as + // referenced skips the sweep instead of guessing that nothing is live. + const live = yield* probeLiveAntigravityExtracts(candidates).pipe( + Effect.orElseSucceed((): LiveAntigravityExtractProbe => ({ + paths: candidates, + protectOpenHandles: true, + caseInsensitive: false, + })), + ); + for (const directory of candidates) { + if (yield* liveExtractIsReferenced(directory, live)) continue; + yield* removeUnreferencedAntigravityExtract(directory, live.protectOpenHandles); } }, Effect.catch(() => Effect.logWarning("Could not remove leftover Antigravity system temp files.")), @@ -140,3 +157,210 @@ const hasAntigravityMeiMarker = Effect.fnUntraced(function* (directory: string) } return false; }); + +/** + * What a live PyInstaller process looks like before we delete anything. + * Recursive removal is the wrong time to discover a lock: Windows unlinks + * the unlocked data files and only then fails on the loaded executable. + */ +interface LiveAntigravityExtractProbe { + readonly paths: ReadonlyArray; + readonly protectOpenHandles: boolean; + readonly caseInsensitive: boolean; +} + +const PATH_BOUNDARY_BEFORE = new Set(["", '"', "'", " ", "=", ","]); +const PATH_BOUNDARY_AFTER = new Set(["", '"', "'", " ", "/"]); + +const normalizePathKey = (value: string, caseInsensitive: boolean): string => { + const slashes = value.replaceAll("\\", "/"); + return caseInsensitive ? slashes.toLowerCase() : slashes; +}; + +/** True when `reference` is the directory or a file/command line inside it. */ +const pathReferencesDirectory = ( + directory: string, + reference: string, + caseInsensitive: boolean, +): boolean => { + const parent = normalizePathKey(directory, caseInsensitive); + const candidate = normalizePathKey(reference, caseInsensitive); + if (parent.length === 0) return false; + let from = 0; + while (from < candidate.length) { + const index = candidate.indexOf(parent, from); + if (index < 0) return false; + const before = index === 0 ? "" : candidate.charAt(index - 1); + const after = candidate.charAt(index + parent.length); + if (PATH_BOUNDARY_BEFORE.has(before) && PATH_BOUNDARY_AFTER.has(after)) return true; + from = index + parent.length; + } + return false; +}; + +const probeLiveAntigravityExtracts = Effect.fnUntraced(function* ( + candidates: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + // `/proc` is a kernel fact. Driver tests simulate a win32 host on Unix, + // where renaming a directory does not mean "no open handle". + const proc = yield* fs.exists("/proc/self/cwd").pipe(Effect.orElseSucceed(() => false)); + const paths = proc + ? yield* linuxProcExtractReferences() + : platform === "darwin" + ? yield* darwinLsofExtractReferences(candidates) + : platform === "win32" + ? yield* windowsProcessExtractReferences() + : []; + return { + paths, + protectOpenHandles: !proc && platform === "win32", + caseInsensitive: platform === "win32", + } satisfies LiveAntigravityExtractProbe; +}); + +const liveExtractIsReferenced = Effect.fnUntraced(function* ( + directory: string, + live: LiveAntigravityExtractProbe, +) { + const fs = yield* FileSystem.FileSystem; + const keys = [directory]; + const real = yield* fs.realPath(directory).pipe(Effect.option); + if (Option.isSome(real) && real.value !== directory) keys.push(real.value); + return live.paths.some((reference) => + keys.some((key) => pathReferencesDirectory(key, reference, live.caseInsensitive)), + ); +}); + +const removeUnreferencedAntigravityExtract = Effect.fnUntraced(function* ( + directory: string, + protectOpenHandles: boolean, +) { + const fs = yield* FileSystem.FileSystem; + if (!protectOpenHandles) { + yield* fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore); + return; + } + // Renaming a Windows directory fails while any file inside is open, and + // the failure happens before any file is unlinked. + const staged = `${directory}.${process.pid}.t3-mei-sweep`; + const moved = yield* fs.rename(directory, staged).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!moved) return; + const restored = yield* fs.rename(staged, directory).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!restored) { + yield* fs.remove(staged, { recursive: true, force: true }).pipe(Effect.ignore); + return; + } + yield* fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore); +}); + +const pushMeiReference = (references: Array, value: string) => { + if (value.includes("_MEI")) references.push(value); +}; + +const collectProcLink = Effect.fnUntraced(function* (references: Array, linkPath: string) { + const fs = yield* FileSystem.FileSystem; + const target = yield* fs.readLink(linkPath).pipe(Effect.option); + if (Option.isNone(target)) return; + pushMeiReference(references, target.value); +}); + +const collectProcMeipass = Effect.fnUntraced(function* ( + references: Array, + environPath: string, +) { + const fs = yield* FileSystem.FileSystem; + const bytes = yield* fs.readFile(environPath).pipe(Effect.option); + if (Option.isNone(bytes)) return; + // The initial environment only. Later `process.env` writes are invisible here. + const text = new TextDecoder().decode(bytes.value); + for (const entry of text.split("\0")) { + if (!entry.startsWith("_MEIPASS=")) continue; + pushMeiReference(references, entry.slice("_MEIPASS=".length)); + } +}); + +const linuxProcExtractReferences = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory("/proc").pipe(Effect.orElseSucceed(() => [])); + const references: Array = []; + const self = String(process.pid); + for (const entry of entries) { + if (entry === self || !/^[0-9]+$/.test(entry)) continue; + const base = `/proc/${entry}`; + yield* collectProcLink(references, `${base}/cwd`); + yield* collectProcMeipass(references, `${base}/environ`); + const fds = yield* fs.readDirectory(`${base}/fd`).pipe(Effect.orElseSucceed(() => [])); + for (const fd of fds) { + yield* collectProcLink(references, `${base}/fd/${fd}`); + } + } + return references; +}); + +const commandOutputReferences = (output: string, stripLsofPrefix: boolean): Array => { + const normalized = output.replaceAll("\u0000", "").replaceAll("\uFEFF", ""); + const references: Array = []; + for (const line of normalized.split(/\r?\n/)) { + const trimmed = line.trim(); + const value = stripLsofPrefix && trimmed.startsWith("n") ? trimmed.slice(1) : trimmed; + pushMeiReference(references, value); + } + return references; +}; + +const darwinLsofExtractReferences = Effect.fnUntraced(function* ( + candidates: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const references: Array = []; + for (const directory of candidates) { + const output = yield* spawner + .string( + ChildProcess.make("lsof", ["-n", "-P", "-F", "n", "+D", directory], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.orElseSucceed(() => "")); + references.push(...commandOutputReferences(output, true)); + } + return references; +}); + +const WINDOWS_LIVE_EXTRACT_SCRIPT = [ + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()", + "$ErrorActionPreference = 'SilentlyContinue'", + "Get-CimInstance Win32_Process | ForEach-Object {", + " if ($_.ExecutablePath -like '*_MEI*') { Write-Output $_.ExecutablePath }", + " if ($_.CommandLine -like '*_MEI*') { Write-Output $_.CommandLine }", + "}", + "Get-Process | Where-Object { $_.ProcessName -match 'agy|localharness|antigravity' } | ForEach-Object {", + " try {", + " foreach ($module in $_.Modules) {", + " if ($module.FileName -like '*_MEI*') { Write-Output $module.FileName }", + " }", + " } catch {}", + "}", +].join("\n"); + +const windowsProcessExtractReferences = Effect.fnUntraced(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner + .string( + ChildProcess.make( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_LIVE_EXTRACT_SCRIPT], + { stdin: "ignore", stderr: "ignore", windowsHide: true }, + ), + ) + .pipe(Effect.orElseSucceed(() => "")); + return commandOutputReferences(output, false); +});