Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion apps/server/src/cli/invocation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { assert, it } from "@effect/vitest";

import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts";
import {
detectCliRunner,
detectServerInstall,
formatCliCommand,
suggestedPackageSpec,
} from "./invocation.ts";

it("detects package runners from their cache entry paths", () => {
assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx");
Expand Down Expand Up @@ -41,6 +46,45 @@ it("treats stable installs as direct invocations", () => {
assert.isNull(detectCliRunner(""));
});

it("tells package runners, global installs, and everything else apart", () => {
assert.equal(
detectServerInstall("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
"npx",
);
assert.equal(
detectServerInstall("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"),
"pnpm-dlx",
);
assert.equal(
detectServerInstall("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"),
"bunx",
);
assert.equal(detectServerInstall("/usr/local/lib/node_modules/t3/dist/bin.mjs"), "npm-global");
assert.equal(
detectServerInstall("/home/theo/.nvm/versions/node/v24.13.1/lib/node_modules/t3/dist/bin.mjs"),
"npm-global",
);
assert.equal(
detectServerInstall("C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs"),
"npm-global",
);
assert.equal(
detectServerInstall(
"/home/theo/.local/share/pnpm/global/5/.pnpm/t3@0.0.35/node_modules/t3/dist/bin.mjs",
),
"pnpm-global",
);
assert.equal(
detectServerInstall("/home/theo/.bun/install/global/node_modules/t3/dist/bin.mjs"),
"bun-global",
);
// Nothing global updates these, so no command is suggested.
assert.isNull(detectServerInstall("/srv/project/node_modules/t3/dist/bin.mjs"));
assert.isNull(detectServerInstall("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs"));
assert.isNull(detectServerInstall("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs"));
assert.isNull(detectServerInstall(""));
});

it("re-suggests the nightly channel only for nightly builds", () => {
assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly");
assert.equal(suggestedPackageSpec("0.0.31"), "t3");
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/cli/invocation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ServerInstallKind } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import { HostProcessArguments } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -36,6 +37,46 @@ export function detectCliRunner(entryPath: string): CliRunner | null {
return null;
}

const SERVER_INSTALL_BY_RUNNER: Record<CliRunner, ServerInstallKind> = {
npx: "npx",
"pnpm dlx": "pnpm-dlx",
bunx: "bunx",
};

/**
* How the CLI is installed, for clients that must tell the user how to update
* it by hand. Package runners are recognised as above. Global installs are
* recognised by each package manager's own layout, so the suggested command
* is the one that manages that install:
*
* npm <prefix>/lib/node_modules/t3/... (system node, nvm, fnm, volta,
* Homebrew), or %APPDATA%/npm/node_modules/t3/... on Windows
* pnpm <pnpm home>/global/<n>/...
* bun ~/.bun/install/global/...
*
* Pass the resolved entry script: the bin a global install runs is usually a
* symlink into the package. Anything else (repo checkouts, project-local
* installs, the pinned service runtime) returns null, since no global
* command would update the executable that gets restarted.
*/
export function detectServerInstall(entryPath: string): ServerInstallKind | null {
const runner = detectCliRunner(entryPath);
if (runner !== null) {
return SERVER_INSTALL_BY_RUNNER[runner];
}
const path = entryPath.replaceAll("\\", "/");
if (path.includes("/pnpm/global/")) {
return "pnpm-global";
}
if (path.includes("/.bun/install/global/")) {
return "bun-global";
}
if (path.includes("/lib/node_modules/") || path.includes("/npm/node_modules/")) {
return "npm-global";
}
return null;
}

/**
* The `t3` package spec to suggest. The literal spec the user typed (e.g.
* `t3@nightly`) is resolved away before our process starts, so re-derive it
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import { HostProcessArguments } from "@t3tools/shared/hostProcess";
import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -257,6 +258,56 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
}),
);

it.effect("reports how the server is installed only when it cannot update itself", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-server-environment-install-test-",
});
const serverConfig = yield* makeServerConfig(baseDir);
yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });

const describeWith = (
overrides: Partial<ServerConfig.ServerConfig["Service"]>,
entryPath: string,
) =>
Effect.gen(function* () {
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
return yield* serverEnvironment.getDescriptor;
}).pipe(
Effect.provide(
ServerEnvironment.layer.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })),
Layer.provide(Layer.succeed(HostProcessArguments, ["/usr/bin/node", entryPath])),
),
),
);

const npx = yield* describeWith({}, "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs");
expect(npx.capabilities.serverSelfUpdate).toBeUndefined();
expect(npx.capabilities.serverInstall).toBe("npx");

// A global install runs through a bin symlink; the install is read off
// the package it points into.
const packageDir = `${baseDir}/lib/node_modules/t3/dist`;
yield* fileSystem.makeDirectory(packageDir, { recursive: true });
yield* fileSystem.writeFileString(`${packageDir}/bin.mjs`, "");
yield* fileSystem.makeDirectory(`${baseDir}/bin`, { recursive: true });
yield* fileSystem.symlink(`${packageDir}/bin.mjs`, `${baseDir}/bin/t3`);
const global = yield* describeWith({}, `${baseDir}/bin/t3`);
expect(global.capabilities.serverInstall).toBe("npm-global");

const checkout = yield* describeWith({}, "/home/theo/Code/t3code/apps/server/src/bin.ts");
expect(checkout.capabilities.serverInstall).toBeUndefined();

// A desktop-managed server updates through the app, so it never hands out a command.
const desktop = yield* describeWith({ mode: "desktop" }, `${baseDir}/bin/t3`);
expect(desktop.capabilities.serverSelfUpdate).toBe("desktop-managed");
expect(desktop.capabilities.serverInstall).toBeUndefined();
}),
);

it.effect("structures persisted environment id filesystem failures", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
type ExecutionEnvironmentDescriptor,
} from "@t3tools/contracts";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
HostProcessArchitecture,
HostProcessArguments,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand All @@ -14,6 +18,7 @@ import * as Schema from "effect/Schema";

import packageJson from "../../package.json" with { type: "json" };
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { detectServerInstall } from "../cli/invocation.ts";
import { readAgentActivityPublishingActive } from "../cloud/config.ts";
import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
Expand Down Expand Up @@ -181,11 +186,13 @@ const makeIdentity = Effect.gen(function* () {

export const make = Effect.gen(function* () {
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const serverConfig = yield* ServerConfig.ServerConfig;
const secrets = yield* ServerSecretStore.ServerSecretStore;
const identity = yield* ServerEnvironmentIdentity;
const hostPlatform = yield* HostProcessPlatform;
const hostArchitecture = yield* HostProcessArchitecture;
const processArguments = yield* HostProcessArguments;
const environmentId = yield* identity.getEnvironmentId;
const cwdBaseName = path.basename(serverConfig.cwd).trim();
const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
Expand All @@ -201,6 +208,16 @@ export const make = Effect.gen(function* () {
// the fd and correctly do not advertise.
const desktopAppUpdate =
serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined;
// Only a server with no self-update path hands the user a command, so only
// that server needs to say which command will land on its install. The bin
// a global install runs is a symlink into the package, so resolve it first.
const entryPath = processArguments[1] ?? "";
const serverInstall =
serverSelfUpdate === null
? detectServerInstall(
yield* fileSystem.realPath(entryPath).pipe(Effect.orElseSucceed(() => entryPath)),
)
: null;

const descriptor: ExecutionEnvironmentDescriptor = {
environmentId,
Expand All @@ -227,6 +244,7 @@ export const make = Effect.gen(function* () {
threadPullRequestLinking: true,
environmentIcon: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstall === null ? {} : { serverInstall }),
...(serverSelfUpdate === "boot-service" || desktopAppUpdate
? {
serverSelfUpdateProgress: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ import {
isServerUpdateFailureDismissed,
isVersionMismatchDismissed,
resolveServerConfigVersionMismatch,
resolveServerInstall,
resolveServerSelfUpdateCapability,
serverUpdateGuidance,
supportsDesktopAppUpdate,
Expand Down Expand Up @@ -2247,6 +2248,7 @@ function ChatViewContent(props: ChatViewProps) {
const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
const versionMismatchInstall = resolveServerInstall(serverConfig);
const serverUpdateState = useAtomValue(
serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
);
Expand Down Expand Up @@ -2383,6 +2385,7 @@ function ChatViewContent(props: ChatViewProps) {
selfUpdate={versionMismatchSelfUpdate}
desktopAppUpdate={versionMismatchDesktopAppUpdate}
threadContinuation={versionMismatchThreadContinuation}
install={versionMismatchInstall}
targetVersion={versionMismatch.clientVersion}
label={updateFailed ? "Retry" : "Update"}
variant="ghost"
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
import type {
EnvironmentId,
ServerInstallKind,
ServerSelfUpdateCapability,
} from "@t3tools/contracts";
import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -80,6 +84,7 @@ export function ServerUpdateAction({
selfUpdate,
desktopAppUpdate = false,
threadContinuation = false,
install,
targetVersion,
label = "Update",
variant = "outline",
Expand All @@ -93,6 +98,9 @@ export function ServerUpdateAction({
readonly desktopAppUpdate?: boolean;
/** The server can durably continue running provider turns after updating. */
readonly threadContinuation?: boolean;
/** How the server is installed (capabilities.serverInstall), which picks
the manual update command when it cannot update itself. */
readonly install?: ServerInstallKind | undefined;
readonly targetVersion: string;
readonly label?: string;
readonly variant?: ComponentProps<typeof Button>["variant"];
Expand Down Expand Up @@ -185,7 +193,7 @@ export function ServerUpdateAction({
}

if (selfUpdate === null) {
const command = manualServerUpdateCommand(targetVersion);
const command = manualServerUpdateCommand(targetVersion, install);
return (
<Button size={size} variant={variant} onClick={() => copyToClipboard(command, { command })}>
Copy update command
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
import { useUiStateStore } from "~/uiStateStore";
import {
resolveServerConfigVersionMismatch,
resolveServerInstall,
resolveServerSelfUpdateCapability,
supportsDesktopAppUpdate,
supportsServerUpdateThreadContinuation,
Expand Down Expand Up @@ -1527,6 +1528,7 @@ function SavedBackendListRow({
selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
install={resolveServerInstall(environment.serverConfig)}
targetVersion={versionMismatch.clientVersion}
label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
/>
Expand Down Expand Up @@ -3144,6 +3146,7 @@ export function ConnectionsSettings() {
threadContinuation={supportsServerUpdateThreadContinuation(
primaryServerConfig,
)}
install={resolveServerInstall(primaryServerConfig)}
targetVersion={primaryVersionMismatch.clientVersion}
label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
/>
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/versionSkew.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
dismissVersionMismatch,
isServerUpdateFailureDismissed,
isVersionMismatchDismissed,
manualServerUpdateCommand,
resolveServerConfigVersionMismatch,
resolveServerSelfUpdateCapability,
resolveVersionMismatch,
Expand All @@ -29,6 +30,17 @@ describe("versionSkew", () => {
branding.APP_VERSION = "0.0.34";
});

it("hands out the update command that matches the server install", () => {
expect(manualServerUpdateCommand("0.0.35", "npm-global")).toBe("npm i -g t3@0.0.35");
expect(manualServerUpdateCommand("0.0.35", "pnpm-global")).toBe("pnpm add -g t3@0.0.35");
expect(manualServerUpdateCommand("0.0.35", "bun-global")).toBe("bun add -g t3@0.0.35");
expect(manualServerUpdateCommand("0.0.35", "npx")).toBe("npx t3@0.0.35");
expect(manualServerUpdateCommand("0.0.35", "pnpm-dlx")).toBe("pnpm dlx t3@0.0.35");
expect(manualServerUpdateCommand("0.0.35", "bunx")).toBe("bunx t3@0.0.35");
// Older servers and dev checkouts report no install; the relaunch stays.
expect(manualServerUpdateCommand("0.0.35", undefined)).toBe("npx t3@0.0.35");
});

it("dismisses only the current failed attempt without clearing its retry state", () => {
const failure = {
status: "failed",
Expand Down
41 changes: 37 additions & 4 deletions apps/web/src/versionSkew.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
import type {
EnvironmentId,
ServerConfig,
ServerInstallKind,
ServerSelfUpdateCapability,
} from "@t3tools/contracts";
import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -114,9 +119,37 @@ export function supportsServerUpdateThreadContinuation(
return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true;
}

/** The command to hand users whose server cannot update itself. */
export function manualServerUpdateCommand(targetVersion: string): string {
return `npx t3@${targetVersion}`;
/** How the connected server is installed, when it says. Older servers and
dev checkouts do not. */
export function resolveServerInstall(
serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
): ServerInstallKind | undefined {
return serverConfig?.environment.capabilities.serverInstall;
}

/** The command to hand users whose server cannot update itself. A global
install is upgraded in place; a server started through a package runner
is relaunched at the target version. Servers that do not report their
install keep the npx relaunch. */
export function manualServerUpdateCommand(
targetVersion: string,
install: ServerInstallKind | undefined,
): string {
switch (install) {
case "npm-global":
return `npm i -g t3@${targetVersion}`;
case "pnpm-global":
return `pnpm add -g t3@${targetVersion}`;
case "bun-global":
return `bun add -g t3@${targetVersion}`;
case "pnpm-dlx":
return `pnpm dlx t3@${targetVersion}`;
case "bunx":
return `bunx t3@${targetVersion}`;
case "npx":
case undefined:
return `npx t3@${targetVersion}`;
}
}

export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
Expand Down
Loading
Loading