import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import * as Duration from "effect/Duration";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 TestClock from "effect/testing/TestClock";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessArguments, HostProcessEnvironment, HostProcessExecutablePath, HostProcessPlatform,} from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts";import * as ProcessRunner from "../processRunner.ts";import { BOOT_SERVICE_UNIT_ENV, BOOT_SERVICE_UNIT_FILE, renderBootServiceUnit,} from "./bootService.ts";import * as SelfUpdate from "./selfUpdate.ts"; const NODE_PATH = "/usr/local/bin/node"; interface RecordedCommand { readonly command: string; readonly args: ReadonlyArray<string>;} const makeRecordingRunnerLayer = ( commands: Array<RecordedCommand>, options?: { readonly failWhen?: ((command: string, args: ReadonlyArray<string>) => boolean) | undefined; readonly stdoutFor?: | ((command: string, args: ReadonlyArray<string>) => string | undefined) | undefined; },) => Layer.succeed( ProcessRunner.ProcessRunner, ProcessRunner.ProcessRunner.of({ run: (input) => Effect.sync(() => { commands.push({ command: input.command, args: input.args }); const failed = options?.failWhen?.(input.command, input.args) === true; const versionFromPath = input.command === NODE_PATH && input.args[1] === "--version" ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] : undefined; return { stdout: options?.stdoutFor?.(input.command, input.args) ?? (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`), stderr: failed ? `${input.command} exploded` : "", code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), timedOut: false, stdoutTruncated: false, stderrTruncated: false, }; }), }), ); const provideHostRefs = (input: { readonly platform: NodeJS.Platform; readonly env: NodeJS.ProcessEnv; readonly entryPath: string;}) => Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed(HostProcessEnvironment, input.env), Layer.succeed(HostProcessExecutablePath, NODE_PATH), Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), ), ); it("recognizes published npm artifacts as swappable entry points", () => { assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); assert.isTrue( SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), ); assert.isTrue( SelfUpdate.isPublishedCliEntry( "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", ), ); // Dev checkouts and the desktop bundle run apps/server/dist directly. assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); assert.isFalse(SelfUpdate.isPublishedCliEntry(""));}); it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { const makeHome = Effect.fn("test.makeHome")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); return { fs, path, home }; }); const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( home: string, entryPath: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const unitDir = path.join(home, ".config", "systemd", "user"); yield* fs.makeDirectory(unitDir, { recursive: true }); yield* fs.writeFileString( path.join(unitDir, "t3code.service"), renderBootServiceUnit({ nodePath: NODE_PATH, t3EntryPath: entryPath, baseDir: path.join(home, ".t3"), logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), unitPath: path.join(unitDir, "t3code.service"), }), ); }); it.effect("reports boot-service for the systemd-spawned unit process", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, }, entryPath, }), ); assert.equal(method, "boot-service"); }), ); it.effect("does not claim a systemd process owned by another unit", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123" }, entryPath, }), ); assert.isNull(method); }), ); it.effect("reports respawn for a manual run of the pinned artifact", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); // Same unit on disk, but no INVOCATION_ID: restarting the unit would // not replace this process, so it must respawn itself instead. const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); assert.equal(method, "respawn"); }), ); it.effect("reports respawn for a foreground npx artifact on darwin", () => Effect.gen(function* () { const { home } = yield* makeHome(); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, }), ); assert.equal(method, "respawn"); }), ); it.effect("reports desktop-managed for desktop-supervised backends", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); // Desktop ownership wins over every process-shape heuristic: even a // systemd-looking pinned artifact belongs to the app that spawned it. const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: true, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, }, entryPath, }), ); assert.equal(method, "desktop-managed"); }), ); it.effect("reports no method for dev checkouts and Windows", () => Effect.gen(function* () { const { home } = yield* makeHome(); const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, }), ); assert.isNull(devMethod); const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "win32", env: { HOME: home }, entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", }), ); assert.isNull(windowsMethod); }), );}); it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { interface RecordedSpawn { readonly command: string; readonly args: ReadonlyArray<string>; } const makeContext = Effect.fn("test.makeContext")(function* (options?: { readonly platform?: NodeJS.Platform; readonly bootService?: boolean; readonly desktopManaged?: boolean; readonly entryPath?: string; readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean; readonly stdoutFor?: (command: string, args: ReadonlyArray<string>) => string | undefined; readonly failSpawn?: boolean; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); const baseDir = path.join(home, ".t3"); const entryPath = options?.entryPath ?? path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); const env: NodeJS.ProcessEnv = options?.bootService === true ? { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, } : { HOME: home }; if (options?.bootService === true) { const unitDir = path.join(home, ".config", "systemd", "user"); yield* fs.makeDirectory(unitDir, { recursive: true }); yield* fs.writeFileString( path.join(unitDir, "t3code.service"), renderBootServiceUnit({ nodePath: NODE_PATH, t3EntryPath: entryPath, baseDir, logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), unitPath: path.join(unitDir, "t3code.service"), }), ); } const commands: Array<RecordedCommand> = []; const spawns: Array<RecordedSpawn> = []; let exited = 0; // layerTest always reports mode "web"; desktop-managed contexts overlay // the mode the desktop app's bootstrap envelope would set. const configLayer = options?.desktopManaged === true ? Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; return { ...config, mode: "desktop" as const }; }), ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) : ServerConfig.layerTest(home, baseDir); const service = yield* SelfUpdate.make({ host: { spawnDetached: (command, args) => Effect.sync(() => spawns.push({ command, args })).pipe( Effect.andThen( options?.failSpawn === true ? Effect.fail( new ProcessRunner.ProcessSpawnError({ command, argumentCount: args.length, cause: new Error("detached spawn failed"), }), ) : Effect.void, ), ), exitProcess: () => { exited += 1; }, }, }).pipe( Effect.provide( Layer.mergeAll( makeRecordingRunnerLayer(commands, { failWhen: options?.failWhen, stdoutFor: options?.stdoutFor, }), configLayer, ), ), provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), ); return { fs, path, home, baseDir, entryPath, commands, spawns, exitCount: () => exited, service, }; }); it.effect("rejects dist-tags and other non-exact versions", () => Effect.gen(function* () { const context = yield* makeContext(); const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); assert.include(error.reason, "not an exact t3 version"); assert.lengthOf(context.commands, 0); }), ); it.effect("refuses to update a desktop-managed backend and points at the app", () => Effect.gen(function* () { const context = yield* makeContext({ desktopManaged: true, bootService: true }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "desktop app"); assert.lengthOf(context.commands, 0); assert.lengthOf(context.spawns, 0); }), ); it.effect("fails without touching anything when no update method applies", () => Effect.gen(function* () { const context = yield* makeContext({ entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "cannot update itself"); assert.lengthOf(context.commands, 0); }), ); it.effect("surfaces a failed npm install and never schedules a restart", () => Effect.gen(function* () { const context = yield* makeContext({ failWhen: (command) => command === "npm" }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.equal(error.reason, "Could not install the requested t3 version."); yield* TestClock.adjust(Duration.seconds(10)); assert.lengthOf(context.spawns, 0); assert.equal(context.exitCount(), 0); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("reinstalls the same version after a failed preflight", () => Effect.gen(function* () { let preflightAttempts = 0; const context = yield* makeContext({ failWhen: (command) => { if (command !== NODE_PATH) return false; preflightAttempts += 1; return preflightAttempts === 1; }, }); const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); yield* context.fs.writeFileString(entryPath, "export {};\n"); yield* context.fs.writeFileString( context.path.join(versionDir, ".install-complete"), "0.0.29\n", ); const firstError = yield* context.service .update({ targetVersion: "0.0.29" }) .pipe(Effect.flip); assert.include(firstError.reason, "failed its version check"); assert.isFalse(yield* context.fs.exists(versionDir)); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); assert.deepEqual( context.commands.map((entry) => entry.command), [NODE_PATH, "npm", NODE_PATH], ); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("rejects and removes an installed runtime that reports the wrong version", () => Effect.gen(function* () { const context = yield* makeContext({ stdoutFor: (command, args) => command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined, }); const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "did not report the requested"); assert.isFalse(yield* context.fs.exists(versionDir)); assert.lengthOf(context.spawns, 0); }), ); it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => Effect.gen(function* () { const context = yield* makeContext({ failSpawn: true }); const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(first.reason, "Could not start the replacement"); const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(second.reason, "Could not start the replacement"); assert.notInclude(second.reason, "already in progress"); assert.lengthOf(context.spawns, 2); assert.equal(context.exitCount(), 0); }), ); it.effect("installs, preflights, and respawns a foreground server", () => Effect.gen(function* () { const context = yield* makeContext(); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); assert.lengthOf(context.spawns, 1); const concurrentError = yield* context.service .update({ targetVersion: "0.0.30" }) .pipe(Effect.flip); assert.include(concurrentError.reason, "already in progress"); const pinnedEntry = context.path.join( context.baseDir, "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", ); assert.deepEqual( context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), [ `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, `${NODE_PATH} ${pinnedEntry} --version`, ], ); // The restart is deferred so the RPC acknowledgement flushes first. yield* TestClock.adjust(Duration.seconds(10)); assert.lengthOf(context.spawns, 1); const spawn = context.spawns[0]; assert.equal(spawn?.command, "/bin/sh"); assert.include(spawn?.args ?? [], pinnedEntry); // The replacement replays the original CLI arguments. assert.include(spawn?.args ?? [], "serve"); assert.equal(context.exitCount(), 1); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("rewrites the systemd unit and restarts the boot service", () => Effect.gen(function* () { const context = yield* makeContext({ bootService: true }); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); const pinnedEntry = context.path.join( context.baseDir,Lines 1–500 of 589. Load more
import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; 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 TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessArguments, HostProcessEnvironment, HostProcessExecutablePath, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { BOOT_SERVICE_UNIT_ENV, BOOT_SERVICE_UNIT_FILE, renderBootServiceUnit, } from "./bootService.ts"; import * as SelfUpdate from "./selfUpdate.ts"; const NODE_PATH = "/usr/local/bin/node"; interface RecordedCommand { readonly command: string; readonly args: ReadonlyArray<string>; } const makeRecordingRunnerLayer = ( commands: Array<RecordedCommand>, options?: { readonly failWhen?: ((command: string, args: ReadonlyArray<string>) => boolean) | undefined; readonly stdoutFor?: | ((command: string, args: ReadonlyArray<string>) => string | undefined) | undefined; }, ) => Layer.succeed( ProcessRunner.ProcessRunner, ProcessRunner.ProcessRunner.of({ run: (input) => Effect.sync(() => { commands.push({ command: input.command, args: input.args }); const failed = options?.failWhen?.(input.command, input.args) === true; const versionFromPath = input.command === NODE_PATH && input.args[1] === "--version" ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] : undefined; return { stdout: options?.stdoutFor?.(input.command, input.args) ?? (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`), stderr: failed ? `${input.command} exploded` : "", code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), timedOut: false, stdoutTruncated: false, stderrTruncated: false, }; }), }), ); const provideHostRefs = (input: { readonly platform: NodeJS.Platform; readonly env: NodeJS.ProcessEnv; readonly entryPath: string; }) => Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, input.platform), Layer.succeed(HostProcessEnvironment, input.env), Layer.succeed(HostProcessExecutablePath, NODE_PATH), Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), ), ); it("recognizes published npm artifacts as swappable entry points", () => { assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); assert.isTrue( SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), ); assert.isTrue( SelfUpdate.isPublishedCliEntry( "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", ), ); // Dev checkouts and the desktop bundle run apps/server/dist directly. assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); assert.isFalse(SelfUpdate.isPublishedCliEntry("")); }); it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { const makeHome = Effect.fn("test.makeHome")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); return { fs, path, home }; }); const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( home: string, entryPath: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const unitDir = path.join(home, ".config", "systemd", "user"); yield* fs.makeDirectory(unitDir, { recursive: true }); yield* fs.writeFileString( path.join(unitDir, "t3code.service"), renderBootServiceUnit({ nodePath: NODE_PATH, t3EntryPath: entryPath, baseDir: path.join(home, ".t3"), logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), unitPath: path.join(unitDir, "t3code.service"), }), ); }); it.effect("reports boot-service for the systemd-spawned unit process", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, }, entryPath, }), ); assert.equal(method, "boot-service"); }), ); it.effect("does not claim a systemd process owned by another unit", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123" }, entryPath, }), ); assert.isNull(method); }), ); it.effect("reports respawn for a manual run of the pinned artifact", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); // Same unit on disk, but no INVOCATION_ID: restarting the unit would // not replace this process, so it must respawn itself instead. const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); assert.equal(method, "respawn"); }), ); it.effect("reports respawn for a foreground npx artifact on darwin", () => Effect.gen(function* () { const { home } = yield* makeHome(); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, }), ); assert.equal(method, "respawn"); }), ); it.effect("reports desktop-managed for desktop-supervised backends", () => Effect.gen(function* () { const { home, path } = yield* makeHome(); // Desktop ownership wins over every process-shape heuristic: even a // systemd-looking pinned artifact belongs to the app that spawned it. const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); yield* writeUnitReferencing(home, entryPath); const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: true, }).pipe( provideHostRefs({ platform: "linux", env: { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, }, entryPath, }), ); assert.equal(method, "desktop-managed"); }), ); it.effect("reports no method for dev checkouts and Windows", () => Effect.gen(function* () { const { home } = yield* makeHome(); const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "darwin", env: { HOME: home }, entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, }), ); assert.isNull(devMethod); const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ desktopManaged: false, }).pipe( provideHostRefs({ platform: "win32", env: { HOME: home }, entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", }), ); assert.isNull(windowsMethod); }), ); }); it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { interface RecordedSpawn { readonly command: string; readonly args: ReadonlyArray<string>; } const makeContext = Effect.fn("test.makeContext")(function* (options?: { readonly platform?: NodeJS.Platform; readonly bootService?: boolean; readonly desktopManaged?: boolean; readonly entryPath?: string; readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean; readonly stdoutFor?: (command: string, args: ReadonlyArray<string>) => string | undefined; readonly failSpawn?: boolean; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); const baseDir = path.join(home, ".t3"); const entryPath = options?.entryPath ?? path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); const env: NodeJS.ProcessEnv = options?.bootService === true ? { HOME: home, INVOCATION_ID: "abc123", [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, } : { HOME: home }; if (options?.bootService === true) { const unitDir = path.join(home, ".config", "systemd", "user"); yield* fs.makeDirectory(unitDir, { recursive: true }); yield* fs.writeFileString( path.join(unitDir, "t3code.service"), renderBootServiceUnit({ nodePath: NODE_PATH, t3EntryPath: entryPath, baseDir, logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), unitPath: path.join(unitDir, "t3code.service"), }), ); } const commands: Array<RecordedCommand> = []; const spawns: Array<RecordedSpawn> = []; let exited = 0; // layerTest always reports mode "web"; desktop-managed contexts overlay // the mode the desktop app's bootstrap envelope would set. const configLayer = options?.desktopManaged === true ? Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; return { ...config, mode: "desktop" as const }; }), ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) : ServerConfig.layerTest(home, baseDir); const service = yield* SelfUpdate.make({ host: { spawnDetached: (command, args) => Effect.sync(() => spawns.push({ command, args })).pipe( Effect.andThen( options?.failSpawn === true ? Effect.fail( new ProcessRunner.ProcessSpawnError({ command, argumentCount: args.length, cause: new Error("detached spawn failed"), }), ) : Effect.void, ), ), exitProcess: () => { exited += 1; }, }, }).pipe( Effect.provide( Layer.mergeAll( makeRecordingRunnerLayer(commands, { failWhen: options?.failWhen, stdoutFor: options?.stdoutFor, }), configLayer, ), ), provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), ); return { fs, path, home, baseDir, entryPath, commands, spawns, exitCount: () => exited, service, }; }); it.effect("rejects dist-tags and other non-exact versions", () => Effect.gen(function* () { const context = yield* makeContext(); const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); assert.include(error.reason, "not an exact t3 version"); assert.lengthOf(context.commands, 0); }), ); it.effect("refuses to update a desktop-managed backend and points at the app", () => Effect.gen(function* () { const context = yield* makeContext({ desktopManaged: true, bootService: true }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "desktop app"); assert.lengthOf(context.commands, 0); assert.lengthOf(context.spawns, 0); }), ); it.effect("fails without touching anything when no update method applies", () => Effect.gen(function* () { const context = yield* makeContext({ entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "cannot update itself"); assert.lengthOf(context.commands, 0); }), ); it.effect("surfaces a failed npm install and never schedules a restart", () => Effect.gen(function* () { const context = yield* makeContext({ failWhen: (command) => command === "npm" }); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.equal(error.reason, "Could not install the requested t3 version."); yield* TestClock.adjust(Duration.seconds(10)); assert.lengthOf(context.spawns, 0); assert.equal(context.exitCount(), 0); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("reinstalls the same version after a failed preflight", () => Effect.gen(function* () { let preflightAttempts = 0; const context = yield* makeContext({ failWhen: (command) => { if (command !== NODE_PATH) return false; preflightAttempts += 1; return preflightAttempts === 1; }, }); const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); yield* context.fs.writeFileString(entryPath, "export {};\n"); yield* context.fs.writeFileString( context.path.join(versionDir, ".install-complete"), "0.0.29\n", ); const firstError = yield* context.service .update({ targetVersion: "0.0.29" }) .pipe(Effect.flip); assert.include(firstError.reason, "failed its version check"); assert.isFalse(yield* context.fs.exists(versionDir)); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); assert.deepEqual( context.commands.map((entry) => entry.command), [NODE_PATH, "npm", NODE_PATH], ); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("rejects and removes an installed runtime that reports the wrong version", () => Effect.gen(function* () { const context = yield* makeContext({ stdoutFor: (command, args) => command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined, }); const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(error.reason, "did not report the requested"); assert.isFalse(yield* context.fs.exists(versionDir)); assert.lengthOf(context.spawns, 0); }), ); it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => Effect.gen(function* () { const context = yield* makeContext({ failSpawn: true }); const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(first.reason, "Could not start the replacement"); const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); assert.include(second.reason, "Could not start the replacement"); assert.notInclude(second.reason, "already in progress"); assert.lengthOf(context.spawns, 2); assert.equal(context.exitCount(), 0); }), ); it.effect("installs, preflights, and respawns a foreground server", () => Effect.gen(function* () { const context = yield* makeContext(); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); assert.lengthOf(context.spawns, 1); const concurrentError = yield* context.service .update({ targetVersion: "0.0.30" }) .pipe(Effect.flip); assert.include(concurrentError.reason, "already in progress"); const pinnedEntry = context.path.join( context.baseDir, "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", ); assert.deepEqual( context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), [ `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, `${NODE_PATH} ${pinnedEntry} --version`, ], ); // The restart is deferred so the RPC acknowledgement flushes first. yield* TestClock.adjust(Duration.seconds(10)); assert.lengthOf(context.spawns, 1); const spawn = context.spawns[0]; assert.equal(spawn?.command, "/bin/sh"); assert.include(spawn?.args ?? [], pinnedEntry); // The replacement replays the original CLI arguments. assert.include(spawn?.args ?? [], "serve"); assert.equal(context.exitCount(), 1); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("rewrites the systemd unit and restarts the boot service", () => Effect.gen(function* () { const context = yield* makeContext({ bootService: true }); const result = yield* context.service.update({ targetVersion: "0.0.29" }); assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); const pinnedEntry = context.path.join( context.baseDir,