import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => BootService.layer({ baseDir: config.baseDir, logsDir: config.logsDir, cliVersion: packageJson.version, }).pipe(Layer.provide(ProcessRunner.layer)); export type ServiceReconcileResult = | { readonly changed: false; readonly status: BootService.BootServiceStatus; } | { readonly changed: true; readonly previouslyInstalled: boolean; readonly plan: BootService.BootServicePlan; }; /** Install, update, or repair the service using the CLI version running this command. */ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { readonly allowDowngrade?: boolean; }) { const service = yield* BootService.BootService; const status = yield* service.status; if (status.installed && status.current) { return { changed: false, status } satisfies ServiceReconcileResult; } if ( status.installedVersion !== undefined && options?.allowDowngrade !== true && compareExactServiceVersions(packageJson.version, status.installedVersion) < 0 ) { return yield* new BootService.BootServiceDowngradeRefusedError({ installedVersion: status.installedVersion, targetVersion: packageJson.version, }); } const plan = yield* service.install(options); return { changed: true, previouslyInstalled: status.installed, plan, } satisfies ServiceReconcileResult; }); export function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, ): string { if (!status.supported) { return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; } if (!status.installed) { return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; } const installedVersion = status.installedVersion ?? cliVersion; const problems = (status.problems ?? []).map( (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, ); if ( !status.current && status.installedVersion !== undefined && compareExactServiceVersions(status.installedVersion, cliVersion) > 0 ) { return [ "T3 Code service", ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, ].join("\n"); } return [ "T3 Code service", ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), ].join("\n"); } const runServiceCommand = Effect.fn("cli.service.run")(function* ( flags: { readonly baseDir: Parameters[0]["baseDir"] }, run: Effect.Effect, ) { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); return yield* run.pipe(Effect.provide(bootServiceLayer(config))); }); const serviceReconcileFlags = { ...projectLocationFlags, allowDowngrade: Flag.boolean("allow-downgrade").pipe( Flag.withDescription("Allow replacing a newer installed service with this older CLI version."), Flag.withDefault(false), ), }; const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe( Command.withDescription("Install T3 Code as a background service for this user."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log( `T3 Code service is already installed with t3@${packageJson.version}.`, ); return; } yield* Console.log( `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, ); }), ), ), ); const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( Command.withDescription( "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", ), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); return; } yield* Console.log( `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, ); }), ), ), ); const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( Command.withDescription("Stop and remove the T3 Code background service."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { const service = yield* BootService.BootService; const removed = yield* service.uninstall; yield* Console.log( removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", ); }), ), ), ); const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( Command.withDescription("Show whether the T3 Code background service is installed."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { const service = yield* BootService.BootService; yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); }), ), ), ); export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; const status = yield* service.status; const { supported, installed, current } = status; if (!supported) { return false; } if (installed && current) { yield* Console.log("T3 Code is already set up to run in the background on this machine."); return true; } for (const problem of status.problems ?? []) { yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); } if ( installed && status.installedVersion !== undefined && compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 ) { yield* Console.log( `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, ); // This CLI cannot verify the newer service. Keep the manual fallback available. return false; } // A LaunchAgent starts at login and dies at logout; there is no // enable-linger equivalent on macOS. Do not promise more than that. const platform = yield* HostProcessPlatform; const wanted = yield* Prompt.run( Prompt.confirm({ message: installed ? "The installed T3 Code service needs an update or repair. Update it now?" : platform === "darwin" ? "Run T3 Code in the background whenever you log in to this Mac? " + "It stays reachable through T3 Connect while you are logged in." : "Run T3 Code in the background whenever this machine boots? " + "It stays reachable through T3 Connect even after you log out.", initial: true, }), ); if (!wanted) { return false; } const result = yield* reconcileService(); if (result.changed) { yield* Console.log( `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, ); } return true; }); export const recoverServiceOnboardingOffer = ( offer: Effect.Effect, ) => offer.pipe( Effect.catchTags({ QuitError: () => Effect.succeed(false), BootServiceUnsupportedError: (error) => Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), BootServiceCommandError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceInstallError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServicePrerequisiteError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceUpdatePendingError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceDowngradeRefusedError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), }), ); export const serviceCommand = Command.make("service").pipe( Command.withDescription("Manage the T3 Code background service."), Command.withSubcommands([ serviceInstallCommand, serviceUninstallCommand, serviceUpdateCommand, serviceStatusCommand, ]), );