Skip to content
Merged
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
134 changes: 132 additions & 2 deletions apps/server/src/cli/invocation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, expect, it } from "@effect/vitest";
import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessIsExecutable,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { formatCliCommand } from "./invocation.ts";
import { formatCliCommand, resolveServerInstallation } from "./invocation.ts";

it("formats package runner commands from their cache entry paths", () => {
for (const [entryPath, expected] of [
Expand Down Expand Up @@ -87,3 +97,123 @@ it("formats serve suggestions to match the launching command", () => {
"t3 serve",
);
});

it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
it.effect("recognizes runner caches for both script and executable packages", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
for (const [relative, kind] of [
["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"],
["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"],
["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"],
[".bun/install/cache/t3/dist/bin.mjs", "bunx"],
] as const) {
const entry = path.join(root, relative);
yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
yield* fs.writeFileString(entry, "");
const installation = yield* resolveServerInstallation.pipe(
Effect.provideService(HostProcessArguments, ["node", entry]),
Effect.provideService(HostProcessExecutablePath, entry),
Effect.provideService(HostProcessIsExecutable, entry.endsWith("/t3")),
);
expect(installation).toEqual({ kind });
}
}),
);

it.effect("requires the npm prefix's bin to point to the running package", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
const prefix = path.join(root, "bunx-tools");
const packageRoot = path.join(prefix, "lib/node_modules/t3");
const entry = path.join(packageRoot, "dist/bin.mjs");
const globalBin = path.join(prefix, "bin/t3");
yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true });
yield* fs.writeFileString(entry, "");
yield* fs.writeFileString(
path.join(packageRoot, "package.json"),
'{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
);
const resolve = resolveServerInstallation.pipe(
Effect.provideService(HostProcessArguments, ["node", entry]),
Effect.provideService(HostProcessIsExecutable, false),
Effect.provideService(HostProcessPlatform, "linux"),
);
expect(yield* resolve).toBeNull();
yield* fs.symlink(entry, globalBin);
expect(yield* resolve).toEqual({ kind: "npm-global", prefix });
yield* fs.remove(globalBin);
yield* fs.writeFileString(globalBin, "an unrelated t3 command");
expect(yield* resolve).toBeNull();
expect(yield* resolve.pipe(Effect.provideService(HostProcessPlatform, "win32"))).toBeNull();
}),
);

it.effect("proves the native executable belongs to the npm launcher", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
const prefix = path.join(root, "bunx-tools");
const packageRoot = path.join(prefix, "lib/node_modules/t3");
const launcher = path.join(packageRoot, "bin/t3.js");
const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3");
yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
yield* fs.makeDirectory(path.join(prefix, "bin"));
yield* fs.writeFileString(launcher, "");
yield* fs.writeFileString(entry, "");
yield* fs.writeFileString(
path.join(packageRoot, "package.json"),
'{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}',
);
yield* fs.symlink(launcher, path.join(prefix, "bin/t3"));
const resolve = resolveServerInstallation.pipe(
Effect.provideService(HostProcessExecutablePath, entry),
Effect.provideService(HostProcessIsExecutable, true),
Effect.provideService(HostProcessPlatform, "linux"),
);
for (const [version, expected] of [
["0.0.44", null],
["0.0.45", { kind: "npm-global", prefix }],
]) {
yield* fs.writeFileString(
path.join(path.dirname(entry), "package.json"),
`{"name":"@t3code/t3-linux-x64","version":"${version}"}`,
);
expect(yield* resolve).toEqual(expected);
}
}),
);

it.effect("leaves local, standalone, missing and unreadable installs unknown", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped();
for (const relative of [
"project/node_modules/t3/dist/bin.mjs",
"project/apps/server/dist/bin.mjs",
".t3/runtime/0.0.45/t3",
"missing/dist/bin.mjs",
]) {
const entry = path.join(root, relative);
if (!relative.startsWith("missing")) {
yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
yield* fs.writeFileString(entry, "");
}
expect(
yield* resolveServerInstallation.pipe(
Effect.provideService(HostProcessArguments, ["node", entry]),
Effect.provideService(HostProcessIsExecutable, false),
),
).toBeNull();
}
}),
);
});
73 changes: 72 additions & 1 deletion apps/server/src/cli/invocation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { HostProcessArguments } from "@t3tools/shared/hostProcess";
import type { ServerInstallation } from "@t3tools/contracts";
import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessIsExecutable,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";

import packageJson from "../../package.json" with { type: "json" };

Expand Down Expand Up @@ -36,6 +45,68 @@ function detectCliRunner(entryPath: string): CliRunner | null {
return null;
}

const InstallManifest = Schema.Struct({
name: Schema.String,
version: Schema.String,
bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })),
optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
});
const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest));

/** Prove the running package and its global bin belong together before suggesting an update. */
export const resolveServerInstallation = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const args = yield* HostProcessArguments;
const executable = yield* HostProcessIsExecutable;
const executablePath = yield* HostProcessExecutablePath;
const platform = yield* HostProcessPlatform;
const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? ""));
const match =
/^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec(
entry,
);
if (!match) {
const runner = detectCliRunner(entry);
return runner === null
? null
: ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation);
}
// A global prefix can contain runner-like names; prove its ownership first.
// Windows shims and other package managers need their own ownership proof.
if (platform === "win32") return null;
const prefix = match[1] || "/";
if (
prefix.includes("/node_modules/") ||
/\/(?:Cellar|Caskroom)\//i.test(prefix) ||
/\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix)
)
return null;

const packageRoot = path.join(prefix, "lib/node_modules/t3");
const manifest = yield* fs
.readFileString(path.join(packageRoot, "package.json"))
.pipe(Effect.flatMap(decodeInstallManifest));
if (manifest.name !== "t3" || !manifest.bin) return null;
const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3));
const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3"));
if (globalBin !== bin) return null;
if (executable) {
const nativeManifest = yield* fs
.readFileString(path.join(path.dirname(entry), "package.json"))
.pipe(Effect.flatMap(decodeInstallManifest));
if (
manifest.bin.t3 !== "./bin/t3.js" ||
manifest.optionalDependencies?.[nativeManifest.name] !== nativeManifest.version ||
nativeManifest.version !== manifest.version
)
return null;
} else if (bin !== entry) {
return null;
}
return { kind: "npm-global", prefix } satisfies ServerInstallation;
}).pipe(Effect.orElseSucceed(() => 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
47 changes: 47 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
import {
HostProcessArguments,
HostProcessEnvironment,
HostProcessIsExecutable,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
Expand Down Expand Up @@ -79,6 +85,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
});

it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
it.effect("publishes proven install ownership only for manually updated servers", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped();
const prefix = `${baseDir}/node`;
const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true });
yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true });
yield* fs.writeFileString(entry, "");
yield* fs.writeFileString(
`${prefix}/lib/node_modules/t3/package.json`,
'{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
);
yield* fs.symlink(entry, `${prefix}/bin/t3`);
const config = yield* makeServerConfig(baseDir);
yield* fs.makeDirectory(config.stateDir, { recursive: true });
for (const mode of ["web", "desktop"] as const) {
const descriptor = yield* Effect.gen(function* () {
const environment = yield* ServerEnvironment.ServerEnvironment;
return yield* environment.getDescriptor;
}).pipe(
Effect.provide(
ServerEnvironment.layer.pipe(
Layer.provide(emptySecretStoreLayer),
Layer.provide(ServerConfig.layer({ ...config, mode })),
),
),
Effect.provideService(HostProcessArguments, ["node", entry]),
Effect.provideService(HostProcessIsExecutable, false),
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provideService(HostProcessEnvironment, {}),
);
expect(descriptor.capabilities.serverInstallation).toEqual(
mode === "web" ? { kind: "npm-global", prefix } : undefined,
);
expect(descriptor.capabilities.serverSelfUpdate).toBe(
mode === "web" ? undefined : "desktop-managed",
);
}
}),
);
it.effect.each([
{ name: "missing", content: undefined },
{ name: "empty", content: "" },
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";

import packageJson from "../../package.json" with { type: "json" };
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { resolveServerInstallation } 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 @@ -196,6 +197,7 @@ export const make = Effect.gen(function* () {
desktopManaged: serverConfig.mode === "desktop",
launcherManaged: launcher.managed,
});
const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null;
// Static is correct: the control fd is known at bootstrap, and the desktop
// app and its bundled server ship in one artifact, so a present fd means
// the app speaks the requestDesktopUpdate protocol. WSL backends never get
Expand Down Expand Up @@ -248,6 +250,7 @@ export const make = Effect.gen(function* () {
environmentIcon: true,
projectCloneTracking: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstallation === null ? {} : { serverInstallation }),
// V2 restart recovery uses the environment-owned opt-in. The old
// per-update request flag is not wired into the V2 update RPC path.
...(serverSelfUpdate === "boot-service" || desktopAppUpdate
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 @@ -2991,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) {
}, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
const serverUpdateState = useAtomValue(
Expand Down Expand Up @@ -3112,6 +3113,7 @@ export default function ChatView(props: ChatViewProps) {
environmentId={serverUpdateEnvironmentId}
serverLabel={versionMismatchServerLabel}
selfUpdate={versionMismatchSelfUpdate}
installation={versionMismatchInstallation}
desktopAppUpdate={versionMismatchDesktopAppUpdate}
threadContinuation={versionMismatchThreadContinuation}
targetVersion={versionMismatch.clientVersion}
Expand Down Expand Up @@ -3153,6 +3155,7 @@ export default function ChatView(props: ChatViewProps) {
versionMismatchDismissKey,
serverUpdateEnvironmentId,
versionMismatchSelfUpdate,
versionMismatchInstallation,
versionMismatchDesktopAppUpdate,
versionMismatchThreadContinuation,
versionMismatchServerLabel,
Expand Down
Loading
Loading