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/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 55e159197e6b..1212d051a2a9 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -23,7 +24,9 @@ import { import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, + OPENCODE_VERSION_PROBE_TIMEOUT_ENV, openCodeCommandsToServerProviderSlashCommands, + resolveOpenCodeVersionProbeTimeout, } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; @@ -163,6 +166,7 @@ const runtimeMock = { state: { runVersionError: null as Error | null, runVersionPending: false, + runVersionDelayMs: null as number | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -182,6 +186,7 @@ const runtimeMock = { reset() { this.state.runVersionError = null; this.state.runVersionPending = false; + this.state.runVersionDelayMs = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -254,7 +259,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: runtimeMock.state.runVersionError, }), ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + : runtimeMock.state.runVersionDelayMs === null + ? Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }) + : Effect.sleep(runtimeMock.state.runVersionDelayMs).pipe( + Effect.as({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + ), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; @@ -340,6 +349,52 @@ const checkProvider = Effect.fn("checkProvider")(function* ( ); }); +it("resolves the OpenCode version probe timeout without changing the 4 second default", () => { + const millis = (duration: Duration.Duration) => Duration.toMillis(duration); + + NodeAssert.equal(millis(resolveOpenCodeVersionProbeTimeout({})), millis(Duration.seconds(4))); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8 seconds", + }), + ), + millis(Duration.seconds(8)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "nope", + }), + ), + millis(Duration.seconds(4)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "0 seconds", + }), + ), + millis(Duration.seconds(4)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "1 ms", + }), + ), + millis(Duration.seconds(1)), + ); + NodeAssert.equal( + millis( + resolveOpenCodeVersionProbeTimeout({ + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "Infinity", + }), + ), + millis(Duration.seconds(60)), + ); +}); + it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { @@ -369,7 +424,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("times out a hanging local CLI version probe", () => Effect.gen(function* () { runtimeMock.state.runVersionPending = true; - const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); yield* Effect.yieldNow; yield* TestClock.adjust("4 seconds"); @@ -384,6 +441,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("keeps the 4 second default when a cold start needs 5 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe( + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("waits for a slow version probe when the environment timeout allows it", () => + Effect.gen(function* () { + runtimeMock.state.runVersionDelayMs = 5_000; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8s", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "1.14.19"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("clamps an oversized version probe timeout to 60 seconds", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "2 hours", + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("59 seconds"); + yield* Effect.yieldNow; + NodeAssert.equal(probeFiber.pollUnsafe(), undefined); + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("still reports a missing binary when the version probe timeout is raised", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + [OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "60 seconds", + }); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.equal(snapshot.version, null); + NodeAssert.equal( + snapshot.message, + "OpenCode CLI (`opencode`) is not installed or not on PATH.", + ); + }), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 0495f8b30737..46c2760fba96 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -8,7 +8,9 @@ import { import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -33,13 +35,117 @@ const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; + +/** + * Product default from #8750. It stays 4 seconds so a hung `--version` is + * still killed quickly (#8681). Slow hosts opt in with + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` instead of changing it. + */ +const DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(4); +const MIN_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(1); +const MAX_OPENCODE_VERSION_PROBE_TIMEOUT = Duration.seconds(60); +export const OPENCODE_VERSION_PROBE_TIMEOUT_ENV = "T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT"; +const OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND = /^(?\d+(?:\.\d+)?)\s*(?ms|s|m|h)$/i; + +const OPENCODE_VERSION_PROBE_TIMEOUT_UNITS = { + ms: "millis", + s: "seconds", + m: "minutes", + h: "hours", +} as const; + +/** + * Duration label placed in the `--version` timeout message. Whole seconds + * render as "1 second" or "N seconds"; every other duration renders as + * "N millis". The registry only treats those shapes as a probe timeout. + */ +const formatOpenCodeVersionProbeTimeout = (duration: Duration.Duration): string => { + const millis = Duration.toMillis(duration); + if (Number.isInteger(millis) && millis % 1000 === 0) { + const seconds = millis / 1000; + return seconds === 1 ? "1 second" : `${seconds} seconds`; + } + return `${millis} millis`; +}; + +const OPENCODE_CLI_HEALTH_CHECK_FAILURE = "Failed to execute OpenCode CLI health check"; + +/** Text before the duration label in the exact `--version` timeout message. */ +export const OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX = `${OPENCODE_CLI_HEALTH_CHECK_FAILURE}: OpenCode CLI version probe timed out after `; + +/** Detail text for a `--version` probe that reached its cap. */ +const openCodeVersionProbeTimeoutDetail = (timeoutLabel: string): string => + `OpenCode CLI version probe timed out after ${timeoutLabel}.`; + +/** + * Exact provider message for that timeout. Snapshot merging must compare this + * with `===`; a launch error that only contains the phrase is a different failure. + */ +export const openCodeVersionProbeTimeoutMessage = (timeoutLabel: string): string => + `${OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX}${timeoutLabel}.`; + +/** + * Parses `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT`. Empty or unparseable text + * returns undefined so the 4 second default stands. `ms`, `s`, `m`, and `h` + * shorthand is accepted. Finite values clamp to 1-60 seconds; non-positive + * values are ignored and an infinite value uses the 60 second cap. + */ +const parseOpenCodeVersionProbeTimeout = ( + raw: string | undefined, +): Duration.Duration | undefined => { + const trimmed = raw?.trim() ?? ""; + if (trimmed.length === 0) { + return undefined; + } + + const shorthand = OPENCODE_VERSION_PROBE_TIMEOUT_SHORTHAND.exec(trimmed); + const shorthandAmount = shorthand?.groups?.amount; + const shorthandUnit = shorthand?.groups?.unit?.toLowerCase(); + const normalized = + shorthandAmount !== undefined && + shorthandUnit !== undefined && + shorthandUnit in OPENCODE_VERSION_PROBE_TIMEOUT_UNITS + ? `${shorthandAmount} ${OPENCODE_VERSION_PROBE_TIMEOUT_UNITS[shorthandUnit as keyof typeof OPENCODE_VERSION_PROBE_TIMEOUT_UNITS]}` + : trimmed; + const decoded = Duration.fromInput(normalized as Duration.Input); + if (Option.isNone(decoded)) { + return undefined; + } + + const millis = Duration.toMillis(decoded.value); + // Non-positive values fall through to the 4 second default. Infinity is + // finite-capped with the rest of the range. + if (!Number.isFinite(millis) || millis <= 0) { + return millis > 0 ? MAX_OPENCODE_VERSION_PROBE_TIMEOUT : undefined; + } + if (millis < Duration.toMillis(MIN_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MIN_OPENCODE_VERSION_PROBE_TIMEOUT; + } + if (millis > Duration.toMillis(MAX_OPENCODE_VERSION_PROBE_TIMEOUT)) { + return MAX_OPENCODE_VERSION_PROBE_TIMEOUT; + } + return decoded.value; +}; + +/** + * `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` overrides the 4 second default. + * Invalid text is ignored. Accepted values are clamped to 1-60 seconds. + */ +export const resolveOpenCodeVersionProbeTimeout = ( + environment?: NodeJS.ProcessEnv, +): Duration.Duration => + parseOpenCodeVersionProbeTimeout(environment?.[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]) ?? + DEFAULT_OPENCODE_VERSION_PROBE_TIMEOUT; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause?: unknown; readonly detail: string; }> {} +/** + * Drops blank text and Effect's generic `try` / `tryPromise` wrappers so a + * probe failure keeps the detail the snapshot can show. + */ function normalizeProbeMessage(message: string): string | undefined { const trimmed = message.trim(); if (trimmed.length === 0) { @@ -54,6 +160,11 @@ function normalizeProbeMessage(message: string): string | undefined { return trimmed; } +/** + * Detail string for a probe failure. `OpenCodeProbeError` contributes its + * `detail`; any other `Error` contributes `message`. Both go through + * `normalizeProbeMessage`. + */ function normalizedErrorMessage(cause: unknown): string | undefined { if (cause instanceof OpenCodeProbeError) { return normalizeProbeMessage(cause.detail); @@ -66,6 +177,11 @@ function normalizedErrorMessage(cause: unknown): string | undefined { return normalizeProbeMessage(cause.message); } +/** + * Maps a version or inventory probe failure onto the snapshot fields the UI + * shows. Local `--version` failures use `OPENCODE_CLI_HEALTH_CHECK_FAILURE`, + * so a timeout stays identical to `openCodeVersionProbeTimeoutMessage`. + */ function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; @@ -135,7 +251,7 @@ function formatOpenCodeProbeError(input: { const failureLabel = input.phase === "inventory" ? "Failed to load OpenCode provider inventory" - : "Failed to execute OpenCode CLI health check"; + : OPENCODE_CLI_HEALTH_CHECK_FAILURE; return { installed: true, message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, @@ -381,189 +497,208 @@ export const makePendingOpenCodeProvider = ( }); }); -export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, - environment?: NodeJS.ProcessEnv, -): Effect.fn.Return< - ServerProviderDraft, - never, - OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner -> { - const openCodeRuntime = yield* OpenCodeRuntime; - const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; - const resolvedEnvironment = environment ?? process.env; - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const customModels = openCodeSettings.customModels; - const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - - const fallback = ( - cause: unknown, - version: string | null = null, - phase: "version" | "inventory" = "version", - ) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - phase, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: openCodeSettings.binaryPath, - args: ["--version"], - environment: resolvedEnvironment, - }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - Effect.timeoutOrElse({ - duration: OPENCODE_VERSION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new OpenCodeProbeError({ - detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, - }), - ), - }), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { +/** + * Checks OpenCode once and returns its provider snapshot. A local install + * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading + * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. + * A configured server URL skips that CLI probe and connects directly. + */ +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")( + /** + * Generator passed to `Effect.fn` for that status check. A local install + * runs `--version` under `resolveOpenCodeVersionProbeTimeout` before loading + * inventory; a timeout is reported with `openCodeVersionProbeTimeoutDetail`. + * A configured server URL skips that CLI probe and connects directly. + */ + function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner + > { + const openCodeRuntime = yield* OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; + const resolvedEnvironment = environment ?? process.env; + const versionProbeTimeout = resolveOpenCodeVersionProbeTimeout(resolvedEnvironment); + const versionProbeTimeoutLabel = formatOpenCodeVersionProbeTimeout(versionProbeTimeout); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = openCodeSettings.customModels; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + const fallback = ( + cause: unknown, + version: string | null = null, + phase: "version" | "inventory" = "version", + ) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + phase, + serverUrl: openCodeSettings.serverUrl, + }); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { - installed: true, + installed: failure.installed, version, status: "error", auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + message: failure.message, + }, + }); + }; + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", }, }); } - } - const loadInventory = (server: { - readonly url: string; - readonly serverPassword?: string; - readonly version: string; - }) => - openCodeRuntime - .loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), - }), - ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); - const inventoryEffect = isExternalServer - ? openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - directory: cwd, - serverUrl: openCodeSettings.serverUrl, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }) - .pipe(Effect.flatMap(loadInventory), Effect.scoped) - : serverOwner.withServer(loadInventory); - const inventoryExit = yield* Effect.exit( - inventoryEffect.pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + openCodeRuntime + .runOpenCodeCommand({ + binaryPath: openCodeSettings.binaryPath, + args: ["--version"], + environment: resolvedEnvironment, + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + Effect.timeoutOrElse({ + duration: versionProbeTimeout, + orElse: () => + Effect.fail( + new OpenCodeProbeError({ + detail: openCodeVersionProbeTimeoutDetail(versionProbeTimeoutLabel), + }), + ), + }), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + + if (!version) { + return fallback( + new Error( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + null, + ); + } + if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } + } + + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); - } + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); + } - version = inventoryExit.value.version; + version = inventoryExit.value.version; - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: true, - checkedAt, - models, - skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value.inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: openCodeCommandsToServerProviderSlashCommands( + inventoryExit.value.inventory.commands, + ), + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, - }); -}); + }); + }, +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0970ea88748b..e9ba672fa6b7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -922,6 +922,197 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); }); + it("keeps a ready OpenCode snapshot through a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], + } as const satisfies ServerProvider; + const timedOutProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(previousProvider, timedOutProvider); + assert.equal(merged.status, "ready"); + assert.equal(merged.version, previousProvider.version); + assert.deepStrictEqual(merged.auth, previousProvider.auth); + assert.deepStrictEqual(merged.models, [...previousProvider.models]); + assert.deepStrictEqual(merged.slashCommands, previousProvider.slashCommands); + assert.deepStrictEqual(merged.skills, previousProvider.skills); + assert.equal(merged.message, timedOutProvider.message); + assert.equal(merged.checkedAt, timedOutProvider.checkedAt); + + for (const message of [ + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1 second.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1500 millis.", + ]) { + const variant = mergeProviderSnapshot(previousProvider, { ...timedOutProvider, message }); + assert.equal(variant.status, "ready", message); + assert.equal(variant.version, previousProvider.version, message); + assert.deepStrictEqual(variant.auth, previousProvider.auth, message); + } + }); + + it("does not treat a launch failure that only contains the timeout phrase as a version-probe timeout", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const binaryPath = "/opt/version probe timed out/opencode"; + const messages = [ + `Failed to execute OpenCode CLI health check: Failed to execute '${binaryPath} --version': spawn ${binaryPath} ENOENT`, + "version probe timed out", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds. See the binary path.", + "Note: Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds", + ]; + + for (const message of messages) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message, + } satisfies ServerProvider; + const merged = mergeProviderSnapshot(previousProvider, failedProvider); + assert.equal(merged.status, "error", message); + assert.equal(merged.version, null, message); + assert.deepStrictEqual(merged.auth, { status: "unknown" }, message); + assert.equal(merged.message, message); + } + }); + + it("does not keep last-known-good OpenCode state without a ready versioned snapshot", () => { + const pendingProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode provider status has not been checked in this session yet.", + } as const satisfies ServerProvider; + const timedOutProvider = { + ...pendingProvider, + status: "error", + installed: true, + checkedAt: "2026-07-17T00:01:00.000Z", + message: + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + } satisfies ServerProvider; + + const merged = mergeProviderSnapshot(pendingProvider, timedOutProvider); + assert.equal(merged.status, "error"); + assert.equal(merged.version, null); + assert.equal(merged.message, timedOutProvider.message); + }); + + it("does not keep a ready OpenCode snapshot for a missing binary or other probe error", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "opencode" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.18.19", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const otherError = { + ...missingProvider, + installed: true, + message: "Failed to execute OpenCode CLI health check.", + } satisfies ServerProvider; + + const missing = mergeProviderSnapshot(previousProvider, missingProvider); + assert.equal(missing.status, "error"); + assert.equal(missing.installed, false); + assert.equal(missing.version, null); + assert.deepStrictEqual(missing.models, []); + + const failed = mergeProviderSnapshot(previousProvider, otherError); + assert.equal(failed.status, "error"); + assert.equal(failed.version, null); + assert.equal(failed.message, otherError.message); + }); + describe("Codex model inventories", () => { const cachedProvider = { instanceId: ProviderInstanceId.make("codex-personal"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 063d3712c589..186eeb5f00ee 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -56,6 +56,10 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX, + openCodeVersionProbeTimeoutMessage, +} from "./OpenCodeProvider.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -173,6 +177,79 @@ const mergeProviderModels = ( : mergedModels; }; +// Labels `formatOpenCodeVersionProbeTimeout` can emit: "1 second", "N seconds", +// or "N millis" when the cap is not a whole number of seconds. +const OPENCODE_VERSION_PROBE_TIMEOUT_LABEL = /^(?:1 second|[1-9]\d* seconds|[1-9]\d* millis)$/; + +/** + * Duration label inside the exact `--version` timeout message. A launch + * failure whose text merely contains "version probe timed out" — including a + * configured `binaryPath` with that phrase — does not match. + */ +const readOpenCodeVersionProbeTimeoutLabel = (message: string | undefined): string | undefined => { + if ( + message === undefined || + !message.startsWith(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX) || + !message.endsWith(".") + ) { + return undefined; + } + const label = message.slice(OPENCODE_VERSION_PROBE_TIMEOUT_MESSAGE_PREFIX.length, -1); + if (!OPENCODE_VERSION_PROBE_TIMEOUT_LABEL.test(label)) { + return undefined; + } + return message === openCodeVersionProbeTimeoutMessage(label) ? label : undefined; +}; + +/** + * True when `provider` is an installed OpenCode instance whose snapshot failed + * with the exact `--version` timeout message and no version. A launch error + * that only contains that phrase, including a `binaryPath` with the words, is + * not a timeout. + */ +const isOpenCodeVersionProbeTimeout = (provider: ServerProvider): boolean => { + const label = readOpenCodeVersionProbeTimeoutLabel(provider.message); + if (label === undefined) { + return false; + } + const OPENCODE_VERSION_PROBE_TIMEOUT_MARKER = openCodeVersionProbeTimeoutMessage(label); + return ( + provider.driver === ProviderDriverKind.make("opencode") && + provider.installed && + provider.status === "error" && + provider.version === null && + provider.message === OPENCODE_VERSION_PROBE_TIMEOUT_MARKER + ); +}; + +/** + * A slow OpenCode `--version` must not mark a provider Unavailable after it + * has already reported a version. The probe still dies at its cap. Only a + * timeout against a previous ready, versioned snapshot keeps status, version, + * and auth. A first probe, a missing binary, and any other failure replace + * the snapshot. + */ +const carryLastKnownOpenCodeOnVersionProbeTimeout = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined => { + if ( + !isOpenCodeVersionProbeTimeout(nextProvider) || + previousProvider.driver !== ProviderDriverKind.make("opencode") || + previousProvider.status !== "ready" || + previousProvider.version === null || + previousProvider.version.length === 0 + ) { + return undefined; + } + + return { + auth: previousProvider.auth, + status: previousProvider.status, + version: previousProvider.version, + }; +}; + /** * Antigravity's health check only initializes the agent, so after a server * restart it reports the account as unchecked. The saved Google login still @@ -205,6 +282,13 @@ const carrySavedAntigravityAccount = ( return { auth: previousProvider.auth, status }; }; +/** + * Folds a fresh probe into the previous snapshot. Carries Antigravity account + * state forward, and keeps a ready OpenCode version, status, and auth across + * an exact `--version` timeout. Models, workspace snapshots, and empty + * OpenCode skills or slash commands stay when the new probe did not replace + * them. + */ export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, @@ -213,12 +297,17 @@ export const mergeProviderSnapshot = ( return nextProvider; } const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + const lastKnownOpenCode = carryLastKnownOpenCodeOnVersionProbeTimeout( + previousProvider, + nextProvider, + ); // "Google account access is not checked yet" describes the probe, not the // account; it must not outlive the state it explained. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return { ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), ...savedAccount, + ...lastKnownOpenCode, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), ...(nextProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: nextProvider.workspaceSnapshots } 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/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); 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/providers-opencode.md b/docs/user/providers-opencode.md index 23de99798956..b4cc001b075d 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -20,6 +20,15 @@ fail, check the URL, credentials, and OpenCode version, then refresh provider st After a lost connection, send another prompt to reconnect to the same OpenCode session. +## Slow version checks + +If OpenCode is installed but a refresh leaves it unavailable, the version check +may be timing out. Set `T3CODE_OPENCODE_VERSION_PROBE_TIMEOUT` on the machine +running T3 Code, for example `10 seconds`. The default wait is 4 seconds, and +any value is limited to 1-60 seconds. Once OpenCode has already been ready, a +slow check keeps that status. A missing `opencode` binary still reports as not +installed. + ## Approvals OpenCode follows the shared [permission modes](./permission-modes.md). **Auto** has 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; +} 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.