diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 1a9c66428da9..3e8964e2ce5e 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -4,14 +4,120 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import { + HostProcessArchitecture, HostProcessEnvironment, + HostProcessExecutablePath, HostProcessInvokedAs, HostProcessPlatform, HostProcessWorkingDirectory, } from "@t3tools/shared/hostProcess"; -import { repointLauncher, resolveLauncherPath } from "./update.ts"; +import * as BootService from "../cloud/bootService.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + repointLauncher, + resolveLauncherPath, + resolvePackageManagedInstall, + runUpdate, +} from "./update.ts"; + +it.layer(NodeServices.layer)("t3 update package-managed install", (it) => { + it.effect("reads the package-type marker beside the executable behind the launcher", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const exe = path.join(root, "opt/t3code-cli/t3"); + const launcher = path.join(root, "usr/bin/t3"); + yield* fs.makeDirectory(path.dirname(exe), { recursive: true }); + yield* fs.writeFileString(exe, ""); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.symlink(exe, launcher); + const marker = path.join(path.dirname(exe), "package-type"); + const detect = (executablePath: string) => + resolvePackageManagedInstall.pipe( + Effect.provideService(HostProcessExecutablePath, executablePath), + ); + + assert.equal(yield* detect(launcher), undefined, "no marker"); + yield* fs.writeFileString(marker, "deb\n"); + assert.equal(yield* detect(launcher), "deb"); + assert.equal(yield* detect(exe), "deb"); + yield* fs.writeFileString(marker, "rpm\n"); + assert.equal(yield* detect(launcher), "rpm"); + yield* fs.writeFileString(marker, "snap\n"); + assert.equal(yield* detect(launcher), "unknown"); + yield* fs.writeFileString(marker, "\n"); + assert.equal(yield* detect(launcher), undefined, "empty marker"); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to update a package-managed install before touching anything", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const exe = path.join(root, "opt/t3code-cli/t3"); + yield* fs.makeDirectory(path.dirname(exe), { recursive: true }); + yield* fs.writeFileString(exe, ""); + yield* fs.writeFileString(path.join(path.dirname(exe), "package-type"), "deb\n"); + const touched: string[] = []; + const update = runUpdate({ + baseDir: path.join(root, "home"), + logsDir: path.join(root, "home/logs"), + serverRuntimeStatePath: path.join(root, "home/runtime/server.json"), + channel: undefined, + requestedVersion: "9.9.9", + allowDowngrade: false, + assumeYes: true, + }).pipe( + Effect.provideService(HostProcessExecutablePath, exe), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + touched.push(`fetch ${request.url}`); + return Effect.die("unexpected network request"); + }), + ), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + touched.push(`run ${input.command}`); + return Effect.die("unexpected process"); + }, + }), + Effect.provideService(BootService.BootService, { + status: Effect.sync(() => { + touched.push("service status"); + return { + supported: false, + installed: false, + current: false, + unitPath: "", + logPath: "", + }; + }), + install: () => Effect.die("unexpected service install"), + restart: Effect.die("unexpected service restart"), + uninstall: Effect.die("unexpected service uninstall"), + }), + ); + + const error = yield* Effect.flip(update); + assert.equal(error._tag, "CliUpdateError"); + assert.equal( + error.message, + "t3 is installed with apt. Update it with: sudo apt update && sudo apt upgrade", + ); + assert.deepStrictEqual(touched, []); + assert.isFalse(yield* fs.exists(path.join(root, "home"))); + }).pipe(Effect.scoped), + ); +}); it.layer(NodeServices.layer)("t3 update launcher", (it) => { it.effect("repoints a symlink that lives in a runtime versions tree", () => diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 5a67ba267965..b02065b9b8ae 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,6 +1,7 @@ import { HostProcessArchitecture, HostProcessEnvironment, + HostProcessExecutablePath, HostProcessInvokedAs, HostProcessIsExecutable, HostProcessPlatform, @@ -100,6 +101,41 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); }); +/** + * The system package (`t3code-cli` for Debian and RPM) unpacks the release + * archive under `/opt/t3code-cli` and writes a one-line `package-type` file + * beside the executable, the marker electron-builder leaves in a packaged + * desktop app. That package manager owns updates: `t3 update` cannot repoint + * `/usr/bin/t3`, and a copy downloaded into this home would leave the + * launcher and the service on different versions. + * + * Read beside the real executable, behind the `/usr/bin/t3` symlink. A + * missing or unreadable marker is an archive or npm install that `t3 update` + * manages itself; any other content is a package manager this build does not + * know. + */ +export const resolvePackageManagedInstall = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = yield* HostProcessExecutablePath; + const realPath = yield* fs + .realPath(executablePath) + .pipe(Effect.orElseSucceed(() => executablePath)); + const marker = yield* fs + .readFileString(path.join(path.dirname(realPath), "package-type")) + .pipe(Effect.option); + if (Option.isNone(marker)) return undefined; + const packageType = marker.value.trim(); + if (packageType === "deb" || packageType === "rpm") return packageType; + return packageType.length > 0 ? ("unknown" as const) : undefined; +}); + +const PACKAGE_MANAGED_UPDATE_REASONS = { + deb: "t3 is installed with apt. Update it with: sudo apt update && sudo apt upgrade", + rpm: "t3 is installed with dnf. Update it with: sudo dnf upgrade", + unknown: "t3 is installed by your system package manager. Update it with that package manager.", +} as const; + /** Whether a launcher target lives inside `/runtime/versions`. */ export function launcherOwnsVersionsDir( path: Path.Path, @@ -333,7 +369,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun return false; }); -const runUpdate = Effect.fn("cli.update.run")(function* (input: { +export const runUpdate = Effect.fn("cli.update.run")(function* (input: { readonly baseDir: string; readonly logsDir: string; readonly serverRuntimeStatePath: string; @@ -351,6 +387,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; + // Refused before anything is fetched, written, or asked: the package + // manager is the only thing that may replace this executable. + const packageManaged = yield* resolvePackageManagedInstall; + if (packageManaged !== undefined) { + return yield* new CliUpdateError({ reason: PACKAGE_MANAGED_UPDATE_REASONS[packageManaged] }); + } + const currentVersion = packageJson.version; const channel = input.channel ?? cliReleaseChannelOf(currentVersion); if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {