1import { SERVER_VERSION } from "../version.ts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Terminal from "effect/Terminal";
7import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
8import { FetchHttpClient } from "effect/http";
9
10import * as BootService from "../cloud/bootService.ts";
11import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
12import type * as ServerConfig from "../config.ts";
13import * as ProcessRunner from "../processRunner.ts";
14import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
15
16export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
17 BootService.layer({
18 baseDir: config.baseDir,
19 logsDir: config.logsDir,
20 cliVersion: SERVER_VERSION,
21 }).pipe(
22 Layer.provide(ProcessRunner.layer),
23 // Archive-distributed versions download the release archive here.
24 Layer.provide(FetchHttpClient.layer),
25 );
26
27export type ServiceReconcileResult =
28 | {
29 readonly changed: false;
30 readonly status: BootService.BootServiceStatus;
31 }
32 | {
33 readonly changed: true;
34 readonly previouslyInstalled: boolean;
35 readonly plan: BootService.BootServicePlan;
36 };
37
38/** Install, update, or repair the service using the CLI version running this command. */
39export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {
40 readonly allowDowngrade?: boolean;
41 readonly start?: boolean;
42}) {
43 const service = yield* BootService.BootService;
44 const status = yield* service.status;
45 if (status.installed && status.current) {
46 return { changed: false, status } satisfies ServiceReconcileResult;
47 }
48 if (
49 status.installedVersion !== undefined &&
50 options?.allowDowngrade !== true &&
51 compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0
52 ) {
53 return yield* new BootService.BootServiceDowngradeRefusedError({
54 installedVersion: status.installedVersion,
55 targetVersion: SERVER_VERSION,
56 });
57 }
58 const plan = yield* service.install(options);
59 return {
60 changed: true,
61 previouslyInstalled: status.installed,
62 plan,
63 } satisfies ServiceReconcileResult;
64});
65
66export function formatServiceStatus(
67 status: BootService.BootServiceStatus,
68 cliVersion: string,
69): string {
70 if (!status.supported) {
71 return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
72 }
73 if (!status.installed) {
74 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
75 }
76 const installedVersion = status.installedVersion ?? cliVersion;
77 const problems = (status.problems ?? []).map(
78 (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`,
79 );
80 if (
81 !status.current &&
82 status.installedVersion !== undefined &&
83 compareExactServiceVersions(status.installedVersion, cliVersion) > 0
84 ) {
85 return [
86 "T3 Code service",
87 ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`,
88 ` Unit: ${status.unitPath}`,
89 ` Logs: ${status.logPath}`,
90 ...problems,
91 ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`,
92 ].join("\n");
93 }
94 return [
95 "T3 Code service",
96 ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`,
97 ` Unit: ${status.unitPath}`,
98 ` Logs: ${status.logPath}`,
99 ...problems,
100 ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]),
101 ].join("\n");
102}
103
104const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
105 flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
106 run: Effect.Effect<A, E, BootService.BootService>,
107) {
108 const logLevel = yield* GlobalFlag.LogLevel;
109 const config = yield* resolveCliAuthConfig(flags, logLevel);
110 return yield* run.pipe(Effect.provide(layer(config)));
111});
112
113const serviceReconcileFlags = {
114 ...projectLocationFlags,
115 allowDowngrade: Flag.Boolean("allow-downgrade").pipe(
116 Flag.withDescription("Allow replacing a newer installed service with this older CLI version."),
117 Flag.withDefault(false),
118 ),
119};
120
121const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe(
122 Command.withDescription("Install T3 Code as a background service for this user."),
123 Command.withHandler((flags) =>
124 runServiceCommand(
125 flags,
126 Effect.gen(function* () {
127 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
128 if (!result.changed) {
129 yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`);
130 return;
131 }
132 yield* Console.log(
133 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`,
134 );
135 }),
136 ),
137 ),
138);
139
140// Kept one release for muscle memory and old docs. It did what `t3 service
141// install` does; the way to move to a newer release is `t3 update`.
142const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
143 Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."),
144 Command.unlisted,
145 Command.withHandler((flags) =>
146 runServiceCommand(
147 flags,
148 Effect.gen(function* () {
149 yield* Console.log(
150 "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.",
151 );
152 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
153 if (!result.changed) {
154 yield* Console.log(`T3 Code service is already using t3@${SERVER_VERSION}.`);
155 return;
156 }
157 yield* Console.log(
158 `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`,
159 );
160 }),
161 ),
162 ),
163);
164
165const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(
166 Command.withDescription(
167 "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.",
168 ),
169 Command.withHandler((flags) =>
170 runServiceCommand(
171 flags,
172 Effect.gen(function* () {
173 const service = yield* BootService.BootService;
174 const status = yield* service.status;
175 const restarted = yield* service.restart;
176 yield* Console.log(
177 restarted
178 ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.`
179 : "T3 Code service is not installed.",
180 );
181 }),
182 ),
183 ),
184);
185
186const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
187 Command.withDescription("Stop and remove the T3 Code background service."),
188 Command.withHandler((flags) =>
189 runServiceCommand(
190 flags,
191 Effect.gen(function* () {
192 const service = yield* BootService.BootService;
193 const removed = yield* service.uninstall;
194 yield* Console.log(
195 removed ? "Removed the T3 Code service." : "T3 Code service is not installed.",
196 );
197 }),
198 ),
199 ),
200);
201
202const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
203 Command.withDescription("Show whether the T3 Code background service is installed."),
204 Command.withHandler((flags) =>
205 runServiceCommand(
206 flags,
207 Effect.gen(function* () {
208 const service = yield* BootService.BootService;
209 yield* Console.log(formatServiceStatus(yield* service.status, SERVER_VERSION));
210 }),
211 ),
212 ),
213);
214
215export const offerServiceDuringOnboarding = Effect.gen(function* () {
216 const service = yield* BootService.BootService;
217 const status = yield* service.status;
218 const { supported, installed, current } = status;
219 if (!supported) {
220 return false;
221 }
222 if (installed && current) {
223 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
224 return true;
225 }
226 for (const problem of status.problems ?? []) {
227 yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`);
228 }
229 if (
230 installed &&
231 status.installedVersion !== undefined &&
232 compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0
233 ) {
234 yield* Console.log(
235 `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`,
236 );
237 // This CLI cannot verify the newer service. Keep the manual fallback available.
238 return false;
239 }
240 // A LaunchAgent starts at login and dies at logout; there is no
241 // enable-linger equivalent on macOS. Do not promise more than that.
242 const platform = yield* HostProcess.Platform;
243 const wanted = yield* Prompt.run(
244 Prompt.Confirm({
245 message: installed
246 ? "The installed T3 Code service needs an update or repair. Update it now?"
247 : platform === "darwin"
248 ? "Run T3 Code in the background whenever you log in to this Mac? " +
249 "It stays reachable through T3 Connect while you are logged in."
250 : "Run T3 Code in the background whenever this machine boots? " +
251 "It stays reachable through T3 Connect even after you log out.",
252 initial: true,
253 }),
254 );
255 if (!wanted) {
256 return false;
257 }
258 const result = yield* reconcileService();
259 if (result.changed) {
260 yield* Console.log(
261 `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`,
262 );
263 }
264 return true;
265});
266
267export const recoverServiceOnboardingOffer = <R>(
268 offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
269) =>
270 offer.pipe(
271 Effect.catchTags({
272 QuitError: () => Effect.succeed(false),
273 BootServiceUnsupportedError: (error) =>
274 Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
275 BootServiceCommandError: (error) =>
276 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
277 BootServiceInstallError: (error) =>
278 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
279 BootServicePrerequisiteError: (error) =>
280 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
281 BootServiceUpdatePendingError: (error) =>
282 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
283 BootServiceDowngradeRefusedError: (error) =>
284 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
285 }),
286 );
287
288export const serviceCommand = Command.make("service").pipe(
289 Command.withDescription("Manage the T3 Code background service."),
290 Command.withSubcommands([
291 serviceInstallCommand,
292 serviceRestartCommand,
293 serviceUninstallCommand,
294 serviceStatusCommand,
295 serviceUpdateCommand,
296 ]),
297);