diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 5691f50ad2e30..46bfd0dea7cc5 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -497,3 +497,33 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); }); + +describe("runnableCliCommand", () => { + const state = { + supported: true, + installedPath: "/home/me/.local/bin/t3", + onPath: true, + launcherPath: "/home/me/My Home/.t3/bin/t3", + } as const; + it.each([ + { name: "our ada on PATH", state: { ...state, command: "ada" }, expected: "ada" }, + { + name: "another ada in the alias's place", + state: { ...state, command: "t3", aliasBlockedBy: "/home/me/.local/bin/ada" }, + expected: "t3", + }, + { name: "a desktop build that links only t3", state, expected: "t3" }, + { + name: "a command off PATH", + state: { ...state, onPath: false, command: "ada" }, + expected: "'/home/me/My Home/.t3/bin/t3'", + }, + { + name: "no launcher", + state: { ...state, onPath: false, installedPath: null, launcherPath: null }, + expected: undefined, + }, + ])("names $name", ({ state, expected }) => { + expect(DesktopCliCommand.runnableCliCommand(state, "linux")).toBe(expected); + }); +}); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 256b4c1653b06..4dc9d48562118 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -16,7 +16,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; +import { pathSetup, readUserLoginShell, shellCommandPath } from "@t3tools/shared/shell"; import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -65,6 +65,20 @@ if ($env:T3_SET -eq '1') { } `; +/** + * A command to paste that runs this app's CLI: the name PATH runs as ours, else the bundled + * launcher. Undefined when neither is verified, since either name may be another program's. + */ +export const runnableCliCommand = ( + state: DesktopCliCommandState, + platform: NodeJS.Platform, +): string | undefined => + state.onPath + ? (state.command ?? LEGACY_CLI_COMMAND) + : state.launcherPath !== null + ? shellCommandPath(state.launcherPath, platform) + : undefined; + export class DesktopCliCommand extends Context.Service< DesktopCliCommand, { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index b4c7827ce1ae4..fbf415f6a4efd 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as DesktopCliCommand from "../app/DesktopCliCommand.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -149,6 +150,7 @@ function layerPool( updateCancellations: Stream.empty, }), DesktopBrowserHost.layer, + Layer.mock(DesktopCliCommand.DesktopCliCommand)({}), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolvePrimary: Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 91c9d90e4c5ab..e2c4c121ca18b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -17,6 +17,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopCliCommand from "../app/DesktopCliCommand.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -26,7 +27,8 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import { APP_NAME, CLI_COMMAND } from "@t3tools/shared/branding"; +import { APP_NAME, CLI_COMMAND, LEGACY_CLI_COMMAND } from "@t3tools/shared/branding"; +import { powerShellOnly } from "@t3tools/shared/shell"; const { logWarning: logBackendPoolWarning } = DesktopObservability.makeComponentLogger("desktop-backend-pool"); @@ -103,10 +105,24 @@ type UnregisterAction = | { readonly _tag: "Wait"; readonly done: Deferred.Deferred } | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; +/** What to do when another server holds this home; `command` is one that runs this app's CLI. */ +const stateDirOwnedDetail = (command: string | undefined, platform: NodeJS.Platform) => { + const run = (subcommand: string) => + command === undefined ? `\`${subcommand}\`` : `\`${command} ${subcommand}\``; + const where = + command === undefined + ? ` Run these commands with your ${APP_NAME} CLI (\`${CLI_COMMAND}\`, or legacy \`${LEGACY_CLI_COMMAND}\`).` + : powerShellOnly(command) + ? " Run these commands in PowerShell." + : ""; + return `Another ${APP_NAME} server, such as the background service, is using this data directory, or an older background service is set up for it.${where} Stop that server or try ${run("update")}. If the older launcher cannot update, run ${run("service uninstall")} and optionally reinstall with ${run("service install")} from a current CLI. If the CLI is missing or outdated, install it with \`${cliInstallCommand(platform === "win32" ? "win32" : "posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command. Then reopen ${APP_NAME}. You can also set a separate T3CODE_HOME and pair with the running server.`; +}; + export const layer = Layer.effect( DesktopBackendPool, Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const cliCommand = yield* DesktopCliCommand.DesktopCliCommand; const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; @@ -176,13 +192,15 @@ export const layer = Layer.effect( SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), onShutdown: () => desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, - onStateDirOwned: () => - Effect.flatMap(HostProcess.Platform, (platform) => - electronDialog.showErrorBox( - `This ${APP_NAME} home is unavailable`, - `Another ${APP_NAME} server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try \`${CLI_COMMAND} update\`. If the older launcher cannot update, run \`${CLI_COMMAND} service uninstall\` and optionally reinstall with \`${CLI_COMMAND} service install\` from a current CLI. If \`${CLI_COMMAND}\` is missing or outdated, install it with \`${cliInstallCommand(platform === "win32" ? "win32" : "posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command. Then reopen ${APP_NAME}. You can also set a separate T3CODE_HOME and pair with the running server.`, - ), - ).pipe(Effect.andThen(electronApp.quit)), + onStateDirOwned: Effect.fn("DesktopBackendPool.onStateDirOwned")(function* () { + const platform = yield* HostProcess.Platform; + const command = DesktopCliCommand.runnableCliCommand(yield* cliCommand.state, platform); + yield* electronDialog.showErrorBox( + `This ${APP_NAME} home is unavailable`, + stateDirOwnedDetail(command, platform), + ); + yield* electronApp.quit; + }), onDatabaseRefused: () => electronDialog .showErrorBox( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ada099012bfba..20ba19fd56579 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -192,6 +192,7 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( ); const layerDesktopBackend = DesktopBackendPool.layer.pipe( + Layer.provideMerge(DesktopCliCommand.layer), Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), @@ -215,7 +216,6 @@ const layerDesktopApplication = Layer.mergeAll( DesktopWebLinks.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, - DesktopCliCommand.layer, layerDesktopSsh, ).pipe( Layer.provideMerge(layerDesktopSnapShot), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 7974e7282a3fc..06840a6c4ea51 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -15,7 +15,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -import { APP_NAME, CLI_COMMAND } from "@t3tools/shared/branding"; +import { APP_NAME, CLI_COMMAND, LEGACY_CLI_COMMAND } from "@t3tools/shared/branding"; export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; @@ -130,7 +130,7 @@ export const layer = Layer.effect( Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), onStateDirOwned: () => refuse( - `Another ${APP_NAME} server is using this distro's ${APP_NAME} home, or an older background service is set up for it. Stop that server or pair with it, or try \`${CLI_COMMAND} update\` in the distro. If the older launcher cannot update, run \`${CLI_COMMAND} service uninstall\` and optionally reinstall with \`${CLI_COMMAND} service install\` from a current CLI in the distro. If \`${CLI_COMMAND}\` is missing or outdated, install it in the distro with \`${cliInstallCommand("posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command.`, + `Another ${APP_NAME} server is using this distro's ${APP_NAME} home, or an older background service is set up for it. Run these commands with the distro's ${APP_NAME} CLI (\`${CLI_COMMAND}\`, or legacy \`${LEGACY_CLI_COMMAND}\`). Stop that server or pair with it, or try \`update\` in the distro. If the older launcher cannot update, run \`service uninstall\` and optionally reinstall with \`service install\` from a current CLI in the distro. If the CLI is missing or outdated, install it in the distro with \`${cliInstallCommand("posix")}\`, and pass \`--base-dir\` with the service's ${APP_NAME} home to each command.`, ), onDatabaseRefused: () => refuse( diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 39ea97c03d225..e57f8d2149f18 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -21,6 +21,7 @@ import { pinnedRuntimePaths, prunePinnedRuntimes } from "../service/pinnedRuntim import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as ProcessRunner from "../processRunner.ts"; import { + ownedCommandOnPath, repointLauncher, resolveLauncherPath, resolveMirrorVersion, @@ -474,17 +475,27 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { { name: "only t3 is installed", ada: "missing", expected: "t3" }, { name: "another program owns ada", ada: "foreign", expected: "t3" }, { name: "ada and t3 are both installed", ada: "owned", expected: "ada" }, + // Owning ada is not enough when PATH runs another program's ada first. + { name: "another ada runs first on PATH", ada: "owned", shadowed: "earlier", expected: "t3" }, + // An empty PATH entry is the working directory to a POSIX shell. + { name: "an empty PATH entry runs another ada", ada: "owned", shadowed: "cwd", expected: "t3" }, + // A shell skips a file it cannot execute. + { name: "a non-executable ada comes first", ada: "owned", shadowed: "inert", expected: "ada" }, + { name: "the launchers are off PATH", ada: "owned", offPath: true, expected: "runtime" }, { name: "no launcher is installed", ada: "missing", t3: "missing", expected: "runtime" }, ] as const)("names the command and version a pending restart needs when $name", (row) => Effect.gen(function* () { yield* withoutTerminals; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + // A shell would expand or split a runtime path like this one unless it is a literal. + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: 't3 update $workspace "q" `x`-', + }); const version = packageJson.version; const runtime = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(runtime.versionDir, { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, ""); + yield* fs.writeFileString(runtime.entryPath, "", { mode: 0o755 }); yield* fs.writeFileString(runtime.sentinelPath, `${version}\n`); const unitPath = path.join(baseDir, "t3code.service"); const logPath = path.join(baseDir, "boot-service.log"); @@ -508,8 +519,19 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { const launchers = { t3: "t3" in row ? row.t3 : "owned", ada: row.ada }; for (const [name, kind] of Object.entries(launchers)) { if (kind === "owned") yield* fs.symlink(runtime.entryPath, path.join(bin, name)); - if (kind === "foreign") yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n"); + if (kind === "foreign") { + yield* fs.writeFileString(path.join(bin, name), "#!/bin/sh\n", { mode: 0o755 }); + } + } + const earlier = path.join(baseDir, "earlier"); + yield* fs.makeDirectory(earlier); + const shadowed = "shadowed" in row ? row.shadowed : undefined; + if (shadowed !== undefined) { + const mode = shadowed === "inert" ? 0o644 : 0o755; + yield* fs.writeFileString(path.join(earlier, "ada"), "#!/bin/sh\n", { mode }); } + const PATH = + "offPath" in row ? earlier : shadowed === "cwd" ? `:${bin}` : [earlier, bin].join(":"); const runner = ProcessRunner.ProcessRunner.of({ run: (input) => input.args?.includes("--version") === true @@ -529,6 +551,8 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { assumeYes: false, }).pipe( Effect.provideService(HostProcess.InvokedAs, path.join(bin, "t3")), + Effect.provideService(HostProcess.Environment, { PATH }), + Effect.provideService(HostProcess.WorkingDirectory, earlier), Effect.andThen(TestConsole.logLines), Effect.map((lines) => lines.join("\n")), Effect.provide( @@ -545,7 +569,7 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { ), ); - const command = row.expected === "runtime" ? runtime.entryPath : row.expected; + const command = row.expected === "runtime" ? `'${runtime.entryPath}'` : row.expected; assert.include( output, `Background service still running the version before ${version}. Run \`${command} service restart\` when you are ready to switch it to ${version}.`, @@ -561,3 +585,53 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { ), ); }); + +it.layer(NodeServices.layer)("t3 update on Windows PATH", (it) => { + it.effect.each([ + // PATHEXT runs ada.bat before ada.cmd, so another program's ada.bat in an earlier folder wins. + { + name: "another ada.bat earlier on PATH", + pathExt: undefined, + foreign: "ada.bat", + expected: "t3", + }, + { + name: "PATHEXT that lists .cmd first", + pathExt: ".CMD;.BAT", + foreign: "ada.bat", + expected: "ada", + }, + { + name: "an extension PATHEXT leaves out", + pathExt: ".EXE;.CMD", + foreign: "ada.bat", + expected: "ada", + }, + ])("names the command PATH runs with $name", ({ pathExt, foreign, expected }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-win-" }); + const target = path.join(baseDir, "runtime/versions/1.0.0/t3.exe"); + const earlier = path.join(baseDir, "earlier"); + const bin = path.join(baseDir, "bin"); + yield* fs.makeDirectory(earlier); + yield* fs.makeDirectory(bin); + // In one folder, so PATHEXT order alone decides which ada runs there. + const folder = pathExt === undefined ? earlier : bin; + yield* fs.writeFileString(path.join(folder, foreign), "@echo off\r\nother.exe %*"); + for (const name of ["ada", "t3"]) { + yield* fs.writeFileString(path.join(bin, `${name}.cmd`), `@echo off\r\n"${target}" %*`); + } + + const command = yield* ownedCommandOnPath({ baseDir }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { + Path: [earlier, bin].join(";"), + ...(pathExt === undefined ? {} : { PATHEXT: pathExt }), + }), + ); + assert.equal(command, expected); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index c50f1f2c8cdca..7236ba4f3837c 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,5 +1,6 @@ import { APP_NAME, CLI_COMMAND, LEGACY_CLI_COMMAND } from "@t3tools/shared/branding"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { powerShellOnly, shellCommandPath } from "@t3tools/shared/shell"; import { CLI_RELEASE_BASE_URL_ENV, CLI_RELEASE_CHANNELS, @@ -143,15 +144,76 @@ const ownedLaunchers = Effect.fn("cli.update.owned_launchers")(function* (input: }); /** - * The command to tell the user to run: `ada` when this install owns it, else `t3`, else - * `fallback`. A collision can leave either name to another program, and older installs have only `t3`. + * The first of `ada` then `t3` that PATH runs as one of this home's launchers, and, with `runs`, + * one that starts that runtime. A foreign program of either name earlier on PATH would run instead, + * and older installs have only `t3`, so owning a launcher file is not enough. */ -const launcherCommand = (path: Path.Path, owned: ReadonlyArray, fallback: string) => { - const names = owned.map((launcher) => path.basename(launcher, path.extname(launcher))); - return [CLI_COMMAND, LEGACY_CLI_COMMAND].find((name) => names.includes(name)) ?? fallback; -}; +export const ownedCommandOnPath = Effect.fn("cli.update.owned_command_on_path")(function* (input: { + readonly baseDir: string; + readonly runs?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const windows = platform === "win32"; + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const normalize = (file: string) => + windows ? path.resolve(file).toLowerCase() : path.resolve(file); + const targetOf = (launcher: string) => + windows + ? fs.readFileString(launcher).pipe( + Effect.map(windowsShimTarget), + Effect.orElseSucceed(() => undefined), + ) + : fs.readLink(launcher).pipe( + Effect.map((target) => path.resolve(path.dirname(launcher), target)), + Effect.orElseSucceed(() => undefined), + ); + const cwd = yield* HostProcess.WorkingDirectory; + // POSIX shells search an empty entry (`:dir`, `dir::`) as the working directory. + const directories = (environment["PATH"] ?? environment["Path"] ?? "") + .split(windows ? ";" : ":") + .flatMap((directory) => (directory.length > 0 ? [directory] : windows ? [] : [cwd])); + // Windows tries each PATHEXT extension in order; only a .cmd can be one of our shims. + const extensions = windows + ? (environment["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter((extension) => extension.startsWith(".")) + .map((extension) => extension.toLowerCase()) + : [""]; + // A shell skips a file it cannot execute; Windows runs any PATHEXT match. + const runnable = (candidate: string) => + fs.stat(candidate).pipe( + Effect.map((info) => info.type === "File" && (windows || (Number(info.mode) & 0o111) !== 0)), + Effect.orElseSucceed(() => false), + ); + for (const name of [CLI_COMMAND, LEGACY_CLI_COMMAND]) { + let first: string | undefined; + search: for (const directory of directories) { + for (const extension of extensions) { + const candidate = path.join(directory, `${name}${extension}`); + if (yield* runnable(candidate)) { + first = candidate; + break search; + } + } + } + if (first === undefined) continue; + const target = yield* targetOf(first); + if ( + target !== undefined && + launcherOwnsVersionsDir(path, versionsDir, target) && + (input.runs === undefined || normalize(target) === normalize(input.runs)) + ) { + return name; + } + } + return undefined; +}); -const quoted = (file: string) => (/[\s"'$`]/.test(file) ? `"${file}"` : file); +/** Where a pasted command must run, for the Windows path form cmd.exe cannot read. */ +const inShell = (command: string) => (powerShellOnly(command) ? " in PowerShell" : ""); /** * Repoints the launchers {@link ownedLaunchers} finds. Each is recorded before it moves, so @@ -411,9 +473,10 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { const targetChannel = cliReleaseChannelOf(targetVersion); const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; - const owned = yield* ownedLaunchers({ launchedAs, baseDir: input.baseDir }); - // Before the new runtime is on disk, `t3` is the one name every install of ours has had. - const command = launcherCommand(path, owned, LEGACY_CLI_COMMAND); + // What the user just ran runs this program, so it is the fallback before the new runtime exists. + const command = + (yield* ownedCommandOnPath({ baseDir: input.baseDir })) ?? + (launchedAs === undefined ? LEGACY_CLI_COMMAND : shellCommandPath(launchedAs, platform)); const currentChannel = cliReleaseChannelOf(currentVersion); if (targetChannel === "preview" && currentChannel !== "preview") { @@ -422,7 +485,7 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { `t3@${targetVersion} is a preview build.`, " Preview builds are cut by maintainers from unreleased branches to exercise the release", " pipeline. They can be broken, receive no fixes, and are never offered as updates; you", - ` will have to switch back to ${currentChannel} yourself with \`${command} update --channel ${currentChannel} --allow-downgrade\`.`, + ` will have to switch back to ${currentChannel} yourself with \`${command} update --channel ${currentChannel} --allow-downgrade\`${inShell(command)}.`, ].join("\n"), ); if (!(process.stdin.isTTY && process.stdout.isTTY)) { @@ -465,8 +528,8 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { if (executableCurrent && serviceCurrent) { yield* Console.log( serviceVersion !== undefined - ? `${command} and its background service are already on ${targetVersion} (${targetChannel}).` - : `${command} is already on ${targetVersion} (${targetChannel}).`, + ? `${APP_NAME} and its background service are already on ${targetVersion} (${targetChannel}).` + : `${APP_NAME} is already on ${targetVersion} (${targetChannel}).`, ); return; } @@ -511,7 +574,7 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); } else { yield* Console.log( - ` Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run \`${command} service restart\` later.`, + ` Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run \`${command} service restart\`${inShell(command)} later.`, ); } } @@ -565,7 +628,7 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { ), ); - const repointed = yield* repointLauncher({ + yield* repointLauncher({ launchedAs, baseDir: input.baseDir, targetEntryPath: runtime.entryPath, @@ -611,17 +674,16 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { }); } - // The repointed launchers are the ones owned before, so they name the command that now works. - const installedCommand = launcherCommand( - path, - Option.isSome(repointed) ? owned : [], - quoted(runtime.entryPath), - ); + const commandOnPath = yield* ownedCommandOnPath({ + baseDir: input.baseDir, + runs: runtime.entryPath, + }); + const installedCommand = commandOnPath ?? shellCommandPath(runtime.entryPath, platform); progress.success(`Installed ${APP_NAME} ${targetVersion}`); yield* Console.log( - Option.isSome(repointed) - ? ` Run ${installedCommand} to get started.\n` - : ` Run ${installedCommand}\n`, + commandOnPath === undefined + ? ` Run ${installedCommand}${inShell(installedCommand)}\n` + : ` Run ${installedCommand} to get started.\n`, ); if (servicePlan?.handedOff === true) { yield* Console.log( @@ -640,7 +702,7 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { ? `the version before ${serviceVersion}` : serviceVersion; yield* Console.log( - ` Background service still running ${running}. Run \`${installedCommand} service restart\` when you are ready to switch it to ${targetVersion}.`, + ` Background service still running ${running}. Run \`${installedCommand} service restart\`${inShell(installedCommand)} when you are ready to switch it to ${targetVersion}.`, ); } else if (status.installed && !servesThisHome) { yield* Console.log( diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 810190c0aa237..1ccd4a863f064 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -379,6 +379,7 @@ it.effect("records only failed calls of tools that hand off no work", () => { ), flush: Effect.void, recordOwners: () => Effect.void, + enabled: Effect.succeed(true), }), ), ), diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 4e84553dca26a..9d5c6ae87b1e3 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -237,6 +237,7 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }), flush: Effect.void, recordOwners: () => Effect.void, + enabled: Effect.succeed(true), }); const auth = yield* makeCodexChatGptAuth({ instanceId, diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index d1574935b9876..1c8827312016f 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -229,10 +229,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService respondWithStatus(204), Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; + assert.isFalse(yield* analytics.enabled); delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + assert.isFalse(yield* analytics.enabled); yield* analytics.record("test.shell-unset"); yield* analytics.flush; env.T3CODE_TELEMETRY_ENABLED = "true"; + assert.isTrue(yield* analytics.enabled); yield* analytics.record("test.shell-opted-in"); yield* analytics.flush; }), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 2de4634ed7781..3491a5a50aed3 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -90,6 +90,9 @@ export class AnalyticsService extends Context.Service< readonly flush: Effect.Effect; + /** Whether this server sends usage data now, after the environment and any managed policy. */ + readonly enabled: Effect.Effect; + /** * Records `install.owner_observed` for each owner of this install that the * server read itself or a connecting client reported. Each owner is sent once @@ -359,7 +362,7 @@ export const make = Effect.gen(function* () { flush.pipe(Effect.timeout(SHUTDOWN_FLUSH_TIMEOUT), Effect.ignore), ); - return AnalyticsService.of({ record, flush, recordOwners }); + return AnalyticsService.of({ record, flush, recordOwners, enabled: telemetryEnabled }); }); export const layer = Layer.effect(AnalyticsService, make); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 6a4a8d8a64e43..03df29b433222 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -801,7 +801,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(AcpRegistrySessions.AcpRegistrySessions)({}), Layer.mock(AgentSessionImporter.AgentSessionImporter)({}), Layer.mock(AgentSessionScanner.AgentSessionScanner)({}), - Layer.mock(AnalyticsService.AnalyticsService)({}), + Layer.mock(AnalyticsService.AnalyticsService)({ enabled: Effect.succeed(false) }), Layer.mock(AntigravityInstallation.AntigravityInstallation)({ managedDirectory: "/unused" }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e0806b1d4c73e..6aaed67a4a48c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1873,6 +1873,7 @@ export const layerWsRpc = ( ...(serverSettings.managedPolicy === undefined ? {} : { managedPolicy: serverSettings.managedPolicy }), + telemetryEnabled: yield* analytics.enabled, shellResumeCompletionMarker: true, shellThreadFamilies: true, threadLinkMetadata: true, diff --git a/apps/web/src/components/chat/TriageDraftHero.tsx b/apps/web/src/components/chat/TriageDraftHero.tsx index e4d55ebd5d60a..01b356d3c77c3 100644 --- a/apps/web/src/components/chat/TriageDraftHero.tsx +++ b/apps/web/src/components/chat/TriageDraftHero.tsx @@ -1,7 +1,5 @@ -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { TRIAGE_CLI_COMMAND } from "~/hooks/useStartTriage"; +import { TRIAGE_CLI_GUIDANCE } from "~/hooks/useStartTriage"; -import { InlineButton } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { APP_NAME } from "@t3tools/shared/branding"; @@ -13,7 +11,6 @@ export function TriageDraftHero(props: { readonly machineLabel: string | null; readonly serverVersion: string | null; }) { - const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1500, target: "command" }); return (

@@ -40,19 +37,12 @@ export function TriageDraftHero(props: { ยท - copyToClipboard(TRIAGE_CLI_COMMAND, undefined)} - /> - } - > - {isCopied ? "Copied" : "or run"} - {isCopied ? null : {TRIAGE_CLI_COMMAND}} + }> + or run triage - Copy command, for a terminal + + In a terminal on that machine, {TRIAGE_CLI_GUIDANCE}. +

diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index ff876b6774a1a..98a8226d2ca63 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -77,7 +77,7 @@ import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; import { readProjects, useProjects, waitForProject } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; -import { usageDataNotice, useManagedPolicy } from "../../state/managedPolicy"; +import { useManagedPolicy, useUsageDataNotice } from "../../state/managedPolicy"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; import { serverEnvironment } from "../../state/server"; @@ -320,7 +320,7 @@ export function WelcomePage({ function TelemetryNotice({ environmentId }: { readonly environmentId: EnvironmentId }) { return (

- {usageDataNotice(useManagedPolicy(environmentId))} + {useUsageDataNotice(environmentId)}

); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2304bd4dc7788..ae8d6840fc309 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -74,7 +74,7 @@ import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; -import { usageDataNotice, usePrimaryManagedPolicy } from "../../state/managedPolicy"; +import { usePrimaryManagedPolicy, useUsageDataNotice } from "../../state/managedPolicy"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -2343,6 +2343,7 @@ export function AboutSettingsPanel() { const primaryPolicy = usePrimaryManagedPolicy(); // The Welcome page sets up this computer, so it needs one. const primaryEnvironmentId = usePrimaryEnvironmentId(); + const usageDataNotice = useUsageDataNotice(primaryEnvironmentId); return ( @@ -2395,10 +2396,7 @@ export function AboutSettingsPanel() { - + - writeTextToClipboard(TRIAGE_CLI_COMMAND, "command").catch(() => - toastManager.add({ - type: "error", - title: `Could not copy. Type ${TRIAGE_CLI_COMMAND} instead.`, - }), - ); +/** + * How to run triage on a machine whose install this client cannot see. Either name may be another + * program's there, so it is guidance, never a command to copy. + */ +export const TRIAGE_CLI_GUIDANCE = `run triage with ${APP_NAME}'s CLI (${CLI_COMMAND}, or legacy ${LEGACY_CLI_COMMAND})`; /** A draft is a triage draft while its message still links the playbook file. */ export const isTriagePrompt = (prompt: string) => prompt.includes(`[${TRIAGE_PROMPT_FILE_NAME}](`); @@ -49,11 +44,8 @@ export function useStartTriage() { stackedThreadToast({ type: "error", title: "Could not start triage", - description: `${description} Run ${TRIAGE_CLI_COMMAND} in a terminal on that machine instead.`, + description: `${description} Instead, open a terminal on that machine and ${TRIAGE_CLI_GUIDANCE}.`, timeout: 0, - actionProps: { children: "Copy command", onClick: () => void copyTriageCliCommand() }, - // "Copy command" is the copy that helps here, not the error text. - data: { hideCopyButton: true }, }), ); const failWith = ( diff --git a/apps/web/src/lib/bootError.test.ts b/apps/web/src/lib/bootError.test.ts index 4083990a80064..c174d2ccd539f 100644 --- a/apps/web/src/lib/bootError.test.ts +++ b/apps/web/src/lib/bootError.test.ts @@ -19,9 +19,19 @@ const notOnPath = (launcherPath: string | null): DesktopCliCommandState => ({ }); describe("resolveTriageCommand", () => { - it("prefers t3 on PATH, then the desktop launcher", async () => { + it("prefers the command PATH runs as ours, then the desktop launcher", async () => { const onPath = { ...notOnPath("/home/me/.t3/bin/t3"), onPath: true }; + // A desktop build that predates `command` links only t3. await expect(resolveTriageCommand(desktopWith(async () => onPath))).resolves.toBe("t3 triage"); + await expect( + resolveTriageCommand(desktopWith(async () => ({ ...onPath, command: "ada" }))), + ).resolves.toBe("ada triage"); + // Another program's ada blocked the alias. + await expect( + resolveTriageCommand( + desktopWith(async () => ({ ...onPath, command: "t3", aliasBlockedBy: "/usr/bin/ada" })), + ), + ).resolves.toBe("t3 triage"); await expect( resolveTriageCommand(desktopWith(async () => notOnPath("/home/me/.t3/bin/t3"))), ).resolves.toBe("/home/me/.t3/bin/t3 triage"); @@ -41,20 +51,20 @@ describe("resolveTriageCommand", () => { ).resolves.toBe("& 'C:\\Users\\Jo O''Neil\\.t3\\bin\\t3.cmd' triage"); }); - it("falls back to npx without a usable desktop launcher", async () => { + it("offers no command without a usable desktop launcher, since either name may be foreign", async () => { using _warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await expect(resolveTriageCommand(undefined)).resolves.toBe("ada triage"); - await expect(resolveTriageCommand({} as DesktopBridge)).resolves.toBe("ada triage"); - await expect(resolveTriageCommand(desktopWith(async () => notOnPath(null)))).resolves.toBe( - "ada triage", - ); + await expect(resolveTriageCommand(undefined)).resolves.toBeUndefined(); + await expect(resolveTriageCommand({} as DesktopBridge)).resolves.toBeUndefined(); + await expect( + resolveTriageCommand(desktopWith(async () => notOnPath(null))), + ).resolves.toBeUndefined(); const staleWindowsPathEntry = { ...notOnPath(null), supported: true, onPath: true }; await expect( resolveTriageCommand(desktopWith(async () => staleWindowsPathEntry)), - ).resolves.toBe("ada triage"); + ).resolves.toBeUndefined(); await expect( resolveTriageCommand(desktopWith(() => Promise.reject(new Error("ipc closed")))), - ).resolves.toBe("ada triage"); + ).resolves.toBeUndefined(); }); }); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index 4a58a73b92d27..9cb14187552c2 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -1,9 +1,10 @@ import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; -// Boot code loads before any package resolves, so it repeats APP_NAME and CLI_RELEASE_REPOSITORY from @t3tools/shared. +// Boot code loads before any package resolves, so it repeats APP_NAME, the CLI names and CLI_RELEASE_REPOSITORY from @t3tools/shared. const APP_NAME = "Ada"; const REPOSITORY_URL = "https://github.com/jayleaton/ada"; -const FALLBACK_TRIAGE_COMMAND = "ada triage"; +const CLI_COMMAND = "ada"; +const LEGACY_CLI_COMMAND = "t3"; const DOWNLOAD_URL = `${REPOSITORY_URL}/releases`; const ISSUE_URL = `${REPOSITORY_URL}/issues/new`; const LAST_FAILURE_KEY = "t3code:boot-failed-at"; @@ -26,19 +27,26 @@ function launcherCommand(launcherPath: string) { : `'${launcherPath.replaceAll("'", `'"'"'`)}'`; } -/** Desktop can run its bundled launcher; everything else falls back to `ada` on PATH. */ -export async function resolveTriageCommand(bridge: DesktopBridge | undefined) { +/** + * Desktop names the command PATH runs as its own, else its launcher. Undefined elsewhere: either + * name may be another program's there, so no command is offered to copy. + */ +export async function resolveTriageCommand( + bridge: DesktopBridge | undefined, +): Promise { try { const state = await bridge?.cliCommand.getState(); // Windows reports onPath from its PATH entry alone; only an existing launcher proves it runs. if (state?.launcherPath) { - // onPath reports the `t3` link; `ada` beside it is a best-effort alias. - return state.onPath ? "t3 triage" : `${launcherCommand(state.launcherPath)} triage`; + // `command` is absent from desktop builds that link only `t3`. + return state.onPath + ? `${state.command ?? LEGACY_CLI_COMMAND} triage` + : `${launcherCommand(state.launcherPath)} triage`; } } catch (error) { console.warn("Could not read the desktop CLI state.", error); } - return FALLBACK_TRIAGE_COMMAND; + return undefined; } /** The update button only appears when the desktop updater has a newer build to offer. */ @@ -297,7 +305,7 @@ export function showBootError(error: unknown) { const updateNote = element("p", { id: "boot-error-update-note", hidden: true }); if (bridge) mountUpdateButton(bridge, update, updateNote, announce); - const command = element("code", { textContent: FALLBACK_TRIAGE_COMMAND }); + const command = element("code"); const copyCommand = element("button", { className: "boot-error-icon", type: "button", @@ -322,7 +330,25 @@ export function showBootError(error: unknown) { }, ), ); - void resolveTriageCommand(bridge).then((value) => (command.textContent = value)); + const where = `on the computer that runs ${APP_NAME}. Your agent can check the local logs and help you file a bug report.`; + const commandIntro = element("p", {}, [ + "Run ", + element("code", { textContent: "triage" }), + ` with your ${APP_NAME} CLI (`, + element("code", { textContent: CLI_COMMAND }), + ", or legacy ", + element("code", { textContent: LEGACY_CLI_COMMAND }), + `) ${where}`, + ]); + const commandBox = element("div", { id: "boot-error-command" }, [command, copyCommand]); + // #boot-error-command sets display, which would override the hidden attribute. + commandBox.style.display = "none"; + void resolveTriageCommand(bridge).then((value) => { + if (value === undefined) return; + command.textContent = value; + commandIntro.textContent = `Run this${value.startsWith("& '") ? " in PowerShell" : ""} ${where}`; + commandBox.style.removeProperty("display"); + }); let report = "(no error message)"; try { @@ -368,12 +394,7 @@ export function showBootError(error: unknown) { ), ] : []), - element("div", {}, [ - element("p", { - textContent: `Run this on the computer that runs ${APP_NAME}. Your agent can check the local logs and help you file a bug report.`, - }), - element("div", { id: "boot-error-command" }, [command, copyCommand]), - ]), + element("div", {}, [commandIntro, commandBox]), element("div", {}, [ details, element("p", { diff --git a/apps/web/src/state/managedPolicy.test.ts b/apps/web/src/state/managedPolicy.test.ts index 2fde6442a93c4..1844bddde06e3 100644 --- a/apps/web/src/state/managedPolicy.test.ts +++ b/apps/web/src/state/managedPolicy.test.ts @@ -1,7 +1,7 @@ import { ProviderDriverKind, type ManagedPolicySummary } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { managedInstanceLocks } from "./managedPolicy"; +import { managedInstanceLocks, usageDataNotice } from "./managedPolicy"; const codex = ProviderDriverKind.make("codex"); const claude = ProviderDriverKind.make("claudeAgent"); @@ -43,3 +43,35 @@ describe("managedInstanceLocks", () => { expect(managedInstanceLocks(undefined, codex)).toBeUndefined(); }); }); + +describe("usageDataNotice", () => { + it.each([ + { + name: "a server that reports usage data off", + config: { telemetryEnabled: false }, + notice: "Ada doesn't collect usage data unless you turn it on with T3CODE_TELEMETRY_ENABLED.", + }, + { + name: "a server that opted in", + config: { telemetryEnabled: true }, + notice: "This server sends anonymous usage data because T3CODE_TELEMETRY_ENABLED is on.", + }, + // Older servers do not report it and send by default, so opt-in is not promised for them. + { + name: "an older server", + config: {}, + notice: + "This server may send anonymous usage data. Update it, or set T3CODE_TELEMETRY_ENABLED=false on it.", + }, + { + name: "a policy that turns it off", + config: { + telemetryEnabled: true, + managedPolicy: { ...policy, limits: { telemetry: false } }, + }, + notice: "Example Corp turned off anonymous usage data.", + }, + ])("describes $name", ({ config, notice }) => { + expect(usageDataNotice(config)).toBe(notice); + }); +}); diff --git a/apps/web/src/state/managedPolicy.ts b/apps/web/src/state/managedPolicy.ts index 1ca30648b46a5..ee5c93b9631dc 100644 --- a/apps/web/src/state/managedPolicy.ts +++ b/apps/web/src/state/managedPolicy.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, ManagedPolicySummary, ProviderDriverKind } from "@t3tools/contracts"; +import type { + EnvironmentId, + ManagedPolicySummary, + ProviderDriverKind, + ServerConfig, +} from "@t3tools/contracts"; import { APP_NAME } from "@t3tools/shared/branding"; import { useServerConfigs } from "./entities"; @@ -31,11 +36,30 @@ export function firstManagedDenial( return undefined; } -/** What usage-data notices say; a policy can only turn product analytics off. */ -export function usageDataNotice(policy: ManagedPolicySummary | undefined): string { - return policy?.limits?.telemetry === false - ? `${policy.organization} turned off anonymous usage data.` - : `${APP_NAME} doesn't collect usage data unless you turn it on with T3CODE_TELEMETRY_ENABLED.`; +/** + * What usage-data notices say. Only a server that reports `telemetryEnabled` can promise opt-in: + * older servers omit it and send by default. A policy can only turn product analytics off. + */ +export function usageDataNotice( + config: Pick | undefined, +): string { + const policy = config?.managedPolicy; + if (policy?.limits?.telemetry === false) { + return `${policy.organization} turned off anonymous usage data.`; + } + switch (config?.telemetryEnabled) { + case false: + return `${APP_NAME} doesn't collect usage data unless you turn it on with T3CODE_TELEMETRY_ENABLED.`; + case true: + return "This server sends anonymous usage data because T3CODE_TELEMETRY_ENABLED is on."; + default: + return "This server may send anonymous usage data. Update it, or set T3CODE_TELEMETRY_ENABLED=false on it."; + } +} + +export function useUsageDataNotice(environmentId: EnvironmentId | null | undefined): string { + const serverConfigs = useServerConfigs(); + return usageDataNotice(environmentId == null ? undefined : serverConfigs.get(environmentId)); } export interface ManagedInstanceLocks { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6e0413f3fe4a0..22f6fa0809f1a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -623,6 +623,8 @@ export const ServerConfig = Schema.Struct({ settings: ServerSettings, /** Present when an administrator's policy file manages this environment. */ managedPolicy: Schema.optionalKey(ManagedPolicySummary), + /** Whether this server sends anonymous usage data. Older servers omit it and may send by default. */ + telemetryEnabled: Schema.optionalKey(Schema.Boolean), shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** * The shell can leave idle subagent children out of snapshots (`omitIdleSubagents`), and diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 83561c8daced4..31ad57705e672 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -40,6 +40,8 @@ import { withPathDirectoryListings, type WindowsShellEnvironmentReader, XcodeGitLocation, + shellCommandPath, + powerShellOnly, } from "./shell.ts"; const decodeDesktopHostMessage = Schema.decodeUnknownSync(DesktopHostTelemetryMessage); @@ -1249,3 +1251,29 @@ describe("pathSetupCommand", () => { ).toBeUndefined(); }); }); + +describe("shellCommandPath", () => { + it.each([ + ["/home/me/.t3/runtime/versions/1.0.0/t3", "linux", "/home/me/.t3/runtime/versions/1.0.0/t3"], + ["/home/me/My Apps/t3", "linux", "'/home/me/My Apps/t3'"], + ["/home/$workspace/t3", "linux", "'/home/$workspace/t3'"], + ["/home/`id`/t3", "darwin", "'/home/`id`/t3'"], + [`/home/a"b/O'Neil/t3`, "linux", `'/home/a"b/O'"'"'Neil/t3'`], + ["C:\\Users\\Me\\t3.exe", "win32", "C:\\Users\\Me\\t3.exe"], + ["C:\\Users\\Jo O'Neil\\$env:x\\t3.exe", "win32", "& 'C:\\Users\\Jo O''Neil\\$env:x\\t3.exe'"], + ["C:\\Users\\Jo\u2019s `t3`\\t3.exe", "win32", "& 'C:\\Users\\Jo\u2019\u2019s `t3`\\t3.exe'"], + ] as const)("writes %s as a literal for %s", (path, platform, expected) => { + expect(shellCommandPath(path, platform)).toBe(expected); + // Only the quoted Windows form needs PowerShell; a bare path also runs in cmd.exe. + expect(powerShellOnly(expected)).toBe(expected.startsWith("& ")); + }); + + it("round-trips through a POSIX shell without expansion", () => { + const path = `/srv/a b/$HOME/\`echo x\`/"q"/it's`; + const echoed = NodeChildProcess.execFileSync("/bin/sh", [ + "-c", + `printf %s ${shellCommandPath(path, "linux")}`, + ]); + expect(echoed.toString()).toBe(path); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 2b4dd9b96493b..22bbbdaab425f 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1100,3 +1100,20 @@ export function pathSetupCommand(input: PathSetupInput): string | undefined { ? `mkdir -p ${setup.file.slice(0, setup.file.lastIndexOf("/"))} && ${append}` : append; } + +/** + * `path` as a command to paste: bare when it is plainly safe, else a literal the shell never expands. + * POSIX shells get single quotes; Windows gets PowerShell's `& '...'`, which needs `&` to run a quoted + * path and also closes single quotes at typographic quotes, so those are doubled too. + */ +export function shellCommandPath(path: string, platform: NodeJS.Platform): string { + if (platform === "win32") { + return /^[\w:\\.-]+$/.test(path) + ? path + : `& '${path.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`; + } + return /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'"'"'`)}'`; +} + +/** Whether {@link shellCommandPath} wrote PowerShell's `& '...'`, which cmd.exe cannot run. */ +export const powerShellOnly = (command: string) => command.startsWith("& '");