import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; import { Command } from "effect/unstable/cli"; import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { formatServiceStatus, offerServiceDuringOnboarding, reconcileService, recoverServiceOnboardingOffer, serviceCommand, } from "./service.ts"; afterEach(() => vi.restoreAllMocks()); const status = { supported: true, installed: true, current: true, unitPath: "/home/me/.config/systemd/user/t3code.service", logPath: "/home/me/.t3/userdata/logs/boot-service.log", } as const; it("reports the installed service version and host paths", () => { assert.equal( formatServiceStatus(status, "0.0.29"), [ "T3 Code service", " Status: installed ยท t3@0.0.29", " Unit: /home/me/.config/systemd/user/t3code.service", " Logs: /home/me/.t3/userdata/logs/boot-service.log", ].join("\n"), ); }); it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), "Next: Run `npx t3@0.0.29 service update`.", ); }); it("explains an incomplete nightly installation and keeps repair on its installed version", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.32-nightly.1", problems: ["linger-disabled", "service-stopped"], }, "0.0.32-nightly.1", ); expect(output).toContain("[linger-disabled]"); expect(output).toContain("last login session ends"); expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); expect(output).toContain("[service-stopped]"); expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); expect(output).not.toContain("t3@latest"); }); it("suggests the newer CLI version when the installed service needs an update", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.28" }, "0.0.29", ); expect(output).toContain("npx t3@0.0.29 service update"); expect(output).not.toContain("npx t3@0.0.28 service update"); }); it("explains where the service is supported", () => { assert.include( formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), "Supported on: Linux with systemd, macOS with launchd", ); }); it("reports a newer installed service and gives an exact-version repair command", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, "0.0.31", ); assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); assert.include(output, "npx t3@0.0.32-nightly.1 service update"); assert.notInclude(output, "npx t3@latest service update"); }); const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; function makeTestService(serviceStatus: BootService.BootServiceStatus) { const installOptions: Array[0]> = []; const service = BootService.BootService.of({ status: Effect.succeed(serviceStatus), install: (options) => Effect.sync(() => { installOptions.push(options); return { nodePath: "/test/node", launcherPath: "/test/service-launcher.mjs", baseDir: "/test/t3", unitPath: serviceStatus.unitPath, logPath: serviceStatus.logPath, }; }), uninstall: Effect.succeed(false), }); return { service, installOptions }; } it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); const { service, installOptions } = makeTestService(newerServiceStatus); vi.spyOn(BootService, "layer").mockReturnValue( Layer.succeed(BootService.BootService, service), ); const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ command, "--base-dir", baseDir, ]).pipe( Effect.provideService(HostProcessEnvironment, {}), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), Effect.flip, ); expect(error).toMatchObject({ _tag: "BootServiceDowngradeRefusedError", installedVersion: "999.0.0", targetVersion: packageJson.version, }); expect(installOptions).toEqual([]); }), ); it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); const { service, installOptions } = makeTestService(newerServiceStatus); vi.spyOn(BootService, "layer").mockReturnValue( Layer.succeed(BootService.BootService, service), ); yield* Command.runWith(serviceCommand, { version: packageJson.version })([ command, "--base-dir", baseDir, "--allow-downgrade", ]).pipe( Effect.provideService(HostProcessEnvironment, {}), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), ); expect(installOptions).toEqual([{ allowDowngrade: true }]); }), ); }); it.effect.each([ { name: "a new service", state: { ...status, installed: false, current: false } }, { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } }, { name: "the same version", state: { ...status, current: false, installedVersion: packageJson.version }, }, { name: "an incomplete install of the same version", state: { ...status, current: false, installedVersion: packageJson.version, problems: ["linger-disabled"] as const, }, }, { name: "an unknown version", state: { ...status, current: false } }, ])("installs or repairs $name without an override", ({ state }) => Effect.gen(function* () { const { service, installOptions } = makeTestService(state); const result = yield* reconcileService().pipe( Effect.provideService(BootService.BootService, service), ); expect(result.changed).toBe(true); expect(installOptions).toEqual([undefined]); }), ); it.effect("leaves a newer service unchanged during onboarding without prompting", () => Effect.gen(function* () { const { service, installOptions } = makeTestService(newerServiceStatus); const terminal = Terminal.make({ columns: Effect.succeed(80), rows: Effect.succeed(24), readInput: Effect.die("Onboarding must not prompt to replace a newer service."), readLine: Effect.die("Onboarding must not prompt to replace a newer service."), display: () => Effect.die("Onboarding must not prompt to replace a newer service."), }); const ready = yield* offerServiceDuringOnboarding.pipe( Effect.provideService(BootService.BootService, service), Effect.provideService(Terminal.Terminal, terminal), Effect.provide(NodeServices.layer), ); expect(ready).toBe(false); expect(installOptions).toEqual([]); }), ); it.effect("keeps onboarding successful when a newer version appears before install", () => Effect.gen(function* () { const ready = yield* recoverServiceOnboardingOffer( Effect.fail( new BootService.BootServiceDowngradeRefusedError({ installedVersion: "999.0.0", targetVersion: packageJson.version, }), ), ); expect(ready).toBe(false); }), ); it.effect("keeps the manual-server fallback when background prerequisites fail", () => Effect.gen(function* () { const ready = yield* recoverServiceOnboardingOffer( Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), ); expect(ready).toBe(false); }), );