authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:25:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:25:52-07:00
log0e77fbd3d0d79eec5247e75583a04590a1785133
treec87ddfc3ec37a0cdfd3b545a0692ec6a22d10ab5
parent8efd4e95fcb08478c9c6e8eee384bfca559f62cc
signature Signed by PGP key B5690EEEBB952194

fix(server): prevent accidental service downgrades (#5302)


6 files changed, 482 insertions(+), 71 deletions(-)

apps/server/src/cli/service.test.ts+168-2
......@@ -1,6 +1,26 @@
1import { assert, it } from "@effect/vitest";
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
4import * as NetService from "@t3tools/shared/Net";
5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Terminal from "effect/Terminal";
10import { Command } from "effect/unstable/cli";
11import { afterEach, vi } from "vite-plus/test";
212
3import { formatServiceStatus } from "./service.ts";
13import packageJson from "../../package.json" with { type: "json" };
14import * as BootService from "../cloud/bootService.ts";
15import {
16 formatServiceStatus,
17 offerServiceDuringOnboarding,
18 reconcileService,
19 recoverServiceOnboardingOffer,
20 serviceCommand,
21} from "./service.ts";
22
23afterEach(() => vi.restoreAllMocks());
424
525const status = {
626 supported: true,
......@@ -35,3 +55,149 @@ it("explains where the service is supported", () => {
3555 "Supported on: Linux with systemd, macOS with launchd",
3656 );
3757});
58
59it("reports a newer installed service and gives an exact-version repair command", () => {
60 const output = formatServiceStatus(
61 { ...status, current: false, installedVersion: "0.0.32-nightly.1" },
62 "0.0.31",
63 );
64
65 assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)");
66 assert.include(output, "npx t3@0.0.32-nightly.1 service update");
67 assert.notInclude(output, "npx t3@latest service update");
68});
69
70const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
71
72function makeTestService(serviceStatus: BootService.BootServiceStatus) {
73 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];
74 const service = BootService.BootService.of({
75 status: Effect.succeed(serviceStatus),
76 install: (options) =>
77 Effect.sync(() => {
78 installOptions.push(options);
79 return {
80 nodePath: "/test/node",
81 launcherPath: "/test/service-launcher.mjs",
82 baseDir: "/test/t3",
83 unitPath: serviceStatus.unitPath,
84 logPath: serviceStatus.logPath,
85 };
86 }),
87 uninstall: Effect.succeed(false),
88 });
89 return { service, installOptions };
90}
91
92it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
93 it.effect.each(["install", "update"] as const)(
94 "%s refuses a downgrade before changing the service",
95 (command) =>
96 Effect.gen(function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
99 const { service, installOptions } = makeTestService(newerServiceStatus);
100 vi.spyOn(BootService, "layer").mockReturnValue(
101 Layer.succeed(BootService.BootService, service),
102 );
103
104 const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
105 command,
106 "--base-dir",
107 baseDir,
108 ]).pipe(
109 Effect.provideService(HostProcessEnvironment, {}),
110 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
111 Effect.flip,
112 );
113
114 expect(error).toMatchObject({
115 _tag: "BootServiceDowngradeRefusedError",
116 installedVersion: "999.0.0",
117 targetVersion: packageJson.version,
118 });
119 expect(installOptions).toEqual([]);
120 }),
121 );
122
123 it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) =>
124 Effect.gen(function* () {
125 const fs = yield* FileSystem.FileSystem;
126 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
127 const { service, installOptions } = makeTestService(newerServiceStatus);
128 vi.spyOn(BootService, "layer").mockReturnValue(
129 Layer.succeed(BootService.BootService, service),
130 );
131
132 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
133 command,
134 "--base-dir",
135 baseDir,
136 "--allow-downgrade",
137 ]).pipe(
138 Effect.provideService(HostProcessEnvironment, {}),
139 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
140 );
141
142 expect(installOptions).toEqual([{ allowDowngrade: true }]);
143 }),
144 );
145});
146
147it.effect.each([
148 { name: "a new service", state: { ...status, installed: false, current: false } },
149 { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } },
150 {
151 name: "the same version",
152 state: { ...status, current: false, installedVersion: packageJson.version },
153 },
154 { name: "an unknown version", state: { ...status, current: false } },
155])("installs or repairs $name without an override", ({ state }) =>
156 Effect.gen(function* () {
157 const { service, installOptions } = makeTestService(state);
158
159 const result = yield* reconcileService().pipe(
160 Effect.provideService(BootService.BootService, service),
161 );
162
163 expect(result.changed).toBe(true);
164 expect(installOptions).toEqual([undefined]);
165 }),
166);
167
168it.effect("leaves a newer service unchanged during onboarding without prompting", () =>
169 Effect.gen(function* () {
170 const { service, installOptions } = makeTestService(newerServiceStatus);
171 const terminal = Terminal.make({
172 columns: Effect.succeed(80),
173 rows: Effect.succeed(24),
174 readInput: Effect.die("Onboarding must not prompt to replace a newer service."),
175 readLine: Effect.die("Onboarding must not prompt to replace a newer service."),
176 display: () => Effect.die("Onboarding must not prompt to replace a newer service."),
177 });
178
179 const ready = yield* offerServiceDuringOnboarding.pipe(
180 Effect.provideService(BootService.BootService, service),
181 Effect.provideService(Terminal.Terminal, terminal),
182 Effect.provide(NodeServices.layer),
183 );
184
185 expect(ready).toBe(false);
186 expect(installOptions).toEqual([]);
187 }),
188);
189
190it.effect("keeps onboarding successful when a newer version appears before install", () =>
191 Effect.gen(function* () {
192 const ready = yield* recoverServiceOnboardingOffer(
193 Effect.fail(
194 new BootService.BootServiceDowngradeRefusedError({
195 installedVersion: "999.0.0",
196 targetVersion: packageJson.version,
197 }),
198 ),
199 );
200
201 expect(ready).toBe(false);
202 }),
203);
apps/server/src/cli/service.ts+58-9
......@@ -3,10 +3,11 @@ import * as Console from "effect/Console";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Terminal from "effect/Terminal";
6import { Command, GlobalFlag, Prompt } from "effect/unstable/cli";
6import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
77
88import packageJson from "../../package.json" with { type: "json" };
99import * as BootService from "../cloud/bootService.ts";
10import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
1011import type * as ServerConfig from "../config.ts";
1112import * as ProcessRunner from "../processRunner.ts";
1213import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
......@@ -30,13 +31,25 @@ export type ServiceReconcileResult =
3031 };
3132
3233/** Install, update, or repair the service using the CLI version running this command. */
33export const reconcileService = Effect.fn("cli.service.reconcile")(function* () {
34export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {
35 readonly allowDowngrade?: boolean;
36}) {
3437 const service = yield* BootService.BootService;
3538 const status = yield* service.status;
3639 if (status.installed && status.current) {
3740 return { changed: false, status } satisfies ServiceReconcileResult;
3841 }
39 const plan = yield* service.install;
42 if (
43 status.installedVersion !== undefined &&
44 options?.allowDowngrade !== true &&
45 compareExactServiceVersions(packageJson.version, status.installedVersion) < 0
46 ) {
47 return yield* new BootService.BootServiceDowngradeRefusedError({
48 installedVersion: status.installedVersion,
49 targetVersion: packageJson.version,
50 });
51 }
52 const plan = yield* service.install(options);
4053 return {
4154 changed: true,
4255 previouslyInstalled: status.installed,
......@@ -54,9 +67,23 @@ export function formatServiceStatus(
5467 if (!status.installed) {
5568 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
5669 }
70 const installedVersion = status.installedVersion ?? cliVersion;
71 if (
72 !status.current &&
73 status.installedVersion !== undefined &&
74 compareExactServiceVersions(status.installedVersion, cliVersion) > 0
75 ) {
76 return [
77 "T3 Code service",
78 ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`,
79 ` Unit: ${status.unitPath}`,
80 ` Logs: ${status.logPath}`,
81 ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`,
82 ].join("\n");
83 }
5784 return [
5885 "T3 Code service",
59 ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,
86 ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`,
6087 ` Unit: ${status.unitPath}`,
6188 ` Logs: ${status.logPath}`,
6289 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
......@@ -72,13 +99,21 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
7299 return yield* run.pipe(Effect.provide(bootServiceLayer(config)));
73100});
74101
75const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe(
102const serviceReconcileFlags = {
103 ...projectLocationFlags,
104 allowDowngrade: Flag.boolean("allow-downgrade").pipe(
105 Flag.withDescription("Allow replacing a newer installed service with this older CLI version."),
106 Flag.withDefault(false),
107 ),
108};
109
110const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe(
76111 Command.withDescription("Install T3 Code as a background service for this user."),
77112 Command.withHandler((flags) =>
78113 runServiceCommand(
79114 flags,
80115 Effect.gen(function* () {
81 const result = yield* reconcileService();
116 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
82117 if (!result.changed) {
83118 yield* Console.log(
84119 `T3 Code service is already installed with t3@${packageJson.version}.`,
......@@ -93,7 +128,7 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe
93128 ),
94129);
95130
96const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
131const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
97132 Command.withDescription(
98133 "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",
99134 ),
......@@ -101,7 +136,7 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
101136 runServiceCommand(
102137 flags,
103138 Effect.gen(function* () {
104 const result = yield* reconcileService();
139 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
105140 if (!result.changed) {
106141 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
107142 return;
......@@ -145,7 +180,8 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
145180
146181export const offerServiceDuringOnboarding = Effect.gen(function* () {
147182 const service = yield* BootService.BootService;
148 const { supported, installed, current } = yield* service.status;
183 const status = yield* service.status;
184 const { supported, installed, current } = status;
149185 if (!supported) {
150186 return false;
151187 }
......@@ -153,6 +189,17 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
153189 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
154190 return true;
155191 }
192 if (
193 installed &&
194 status.installedVersion !== undefined &&
195 compareExactServiceVersions(status.installedVersion, packageJson.version) > 0
196 ) {
197 yield* Console.log(
198 `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`,
199 );
200 // This CLI cannot verify the newer service. Keep the manual fallback available.
201 return false;
202 }
156203 // A LaunchAgent starts at login and dies at logout; there is no
157204 // enable-linger equivalent on macOS. Do not promise more than that.
158205 const platform = yield* HostProcessPlatform;
......@@ -194,6 +241,8 @@ export const recoverServiceOnboardingOffer = <R>(
194241 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
195242 BootServiceUpdatePendingError: (error) =>
196243 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
244 BootServiceDowngradeRefusedError: (error) =>
245 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
197246 }),
198247 );
199248
apps/server/src/cloud/bootService.test.ts+181-51
......@@ -127,24 +127,34 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
127127
128128 const commands: string[] = [];
129129 const timeouts = new Map<string, unknown>();
130 const control: { failCommand: string | undefined } = { failCommand: undefined };
130 const control: { failCommand: string | undefined; stateAfterStop?: string } = {
131 failCommand: undefined,
132 };
131133 const runner = ProcessRunner.ProcessRunner.of({
132 run: (input) =>
133 Effect.sync(() => {
134 const command = `${input.command} ${input.args.join(" ")}`;
135 commands.push(command);
136 timeouts.set(command, input.timeout);
137 return {
138 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
139 stderr: "",
140 code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0),
141 timedOut: false,
142 stdoutTruncated: false,
143 stderrTruncated: false,
144 stdoutInvalidUtf8: false,
145 stderrInvalidUtf8: false,
146 };
147 }),
134 run: Effect.fn("test.run_boot_service_command")(function* (
135 input: ProcessRunner.ProcessRunInput,
136 ) {
137 const command = `${input.command} ${input.args.join(" ")}`;
138 commands.push(command);
139 timeouts.set(command, input.timeout);
140 if (
141 control.stateAfterStop !== undefined &&
142 (command === "systemctl --user stop t3code.service" ||
143 command.startsWith("launchctl bootout --wait "))
144 ) {
145 yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie);
146 }
147 return {
148 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
149 stderr: "",
150 code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0),
151 timedOut: false,
152 stdoutTruncated: false,
153 stderrTruncated: false,
154 stdoutInvalidUtf8: false,
155 stderrInvalidUtf8: false,
156 };
157 }),
148158 });
149159 const makeService = (environmentPath = installerPath) =>
150160 BootService.make({
......@@ -179,14 +189,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
179189 it.effect("installs, reports current state, and uninstalls", () =>
180190 Effect.gen(function* () {
181191 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
182 const plan = yield* service.install;
192 const plan = yield* service.install();
183193
184194 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
185195 protocol: SERVICE_LAUNCHER_PROTOCOL,
186196 activeVersion: "1.2.3",
187197 });
188198 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
189 expect((yield* service.status).current).toBe(true);
199 expect(yield* service.status).toMatchObject({
200 current: true,
201 installedVersion: "1.2.3",
202 });
190203 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
191204 const pendingState = JSON.stringify({
192205 protocol: SERVICE_LAUNCHER_PROTOCOL,
......@@ -212,10 +225,116 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
212225 }),
213226 );
214227
228 it.effect.each(["linux", "darwin"] as const)(
229 "reports the installed version across launcher protocols on %s",
230 (platform) =>
231 Effect.gen(function* () {
232 const { service, fs, statePath } = yield* makeHarness(platform);
233 yield* service.install();
234
235 for (const protocol of [SERVICE_LAUNCHER_PROTOCOL - 1, SERVICE_LAUNCHER_PROTOCOL + 1]) {
236 yield* fs.writeFileString(
237 statePath,
238 `{"protocol":${protocol},"activeVersion":"1.2.4-nightly.1","update":{"status":"unknown"}}`,
239 );
240 expect(yield* service.status).toMatchObject({
241 current: false,
242 installedVersion: "1.2.4-nightly.1",
243 });
244 }
245 }),
246 );
247
248 it.effect("reports an unknown version for invalid service state", () =>
249 Effect.gen(function* () {
250 const { service, fs, statePath } = yield* makeHarness();
251 yield* service.install();
252
253 for (const stateText of [
254 "{",
255 '{"activeVersion":"latest"}',
256 '{"activeVersion":"1.2"}',
257 '{"activeVersion":123}',
258 ]) {
259 yield* fs.writeFileString(statePath, stateText);
260 const status = yield* service.status;
261 expect(status.current).toBe(false);
262 expect(status.installedVersion).toBeUndefined();
263 }
264 }),
265 );
266
267 it.effect.each(["linux", "darwin"] as const)(
268 "preserves a newer version that finishes updating during stop on %s",
269 (platform) =>
270 Effect.gen(function* () {
271 const { service, fs, statePath, commands, control } = yield* makeHarness(platform);
272 const plan = yield* service.install();
273 const launcher = yield* fs.readFileString(plan.launcherPath);
274 const unit = yield* fs.readFileString(plan.unitPath);
275 control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`;
276 commands.length = 0;
277
278 const error = yield* service.install().pipe(Effect.flip);
279
280 expect(error).toMatchObject({
281 _tag: "BootServiceDowngradeRefusedError",
282 installedVersion: "1.2.4",
283 targetVersion: "1.2.3",
284 });
285 expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop);
286 expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher);
287 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
288 expect(
289 commands.filter((command) =>
290 command.startsWith(platform === "linux" ? "systemctl " : "launchctl "),
291 ),
292 ).toEqual(
293 platform === "linux"
294 ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"]
295 : [
296 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
297 `launchctl bootstrap gui/501 ${plan.unitPath}`,
298 ],
299 );
300 }),
301 );
302
303 it.effect("allows an explicit downgrade", () =>
304 Effect.gen(function* () {
305 const { service, fs, statePath } = yield* makeHarness();
306 yield* service.install();
307 yield* fs.writeFileString(
308 statePath,
309 `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`,
310 );
311
312 yield* service.install({ allowDowngrade: true });
313
314 expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.3");
315 expect((yield* service.status).current).toBe(true);
316 }),
317 );
318
319 it.effect("repairs versions with equal SemVer precedence without an override", () =>
320 Effect.gen(function* () {
321 const { service, fs, statePath } = yield* makeHarness();
322 yield* service.install();
323 yield* fs.writeFileString(
324 statePath,
325 `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.3+previous-build"}`,
326 );
327
328 yield* service.install();
329
330 expect((yield* service.status).current).toBe(true);
331 }),
332 );
333
215334 it.effect("copies the launcher from the prepared pinned runtime", () =>
216335 Effect.gen(function* () {
217336 const { service, fs } = yield* makeHarness("linux", true);
218 const plan = yield* service.install;
337 const plan = yield* service.install();
219338
220339 expect(yield* fs.readFileString(plan.launcherPath)).toBe(
221340 "export const source = 'pinned runtime';\n",
......@@ -226,11 +345,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
226345 it.effect("restarts an installed service when repair fails", () =>
227346 Effect.gen(function* () {
228347 const { service, commands, control } = yield* makeHarness();
229 yield* service.install;
348 yield* service.install();
230349 commands.length = 0;
231350 control.failCommand = "systemctl --user daemon-reload";
232351
233 const error = yield* service.install.pipe(Effect.flip);
352 const error = yield* service.install().pipe(Effect.flip);
234353 expect(error._tag).toBe("BootServiceCommandError");
235354 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
236355 "systemctl --user stop t3code.service",
......@@ -243,7 +362,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
243362 it.effect("restarts without overwriting a pending remote update", () =>
244363 Effect.gen(function* () {
245364 const { service, fs, statePath, commands } = yield* makeHarness();
246 yield* service.install;
365 yield* service.install();
247366 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
248367 const pendingState = JSON.stringify({
249368 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
......@@ -256,14 +375,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
256375 },
257376 });
258377 yield* fs.writeFileString(statePath, pendingState);
259 commands.length = 0;
260
261 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
262 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
263 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
264 "systemctl --user stop t3code.service",
265 "systemctl --user restart t3code.service",
266 ]);
378 for (const allowDowngrade of [false, true]) {
379 commands.length = 0;
380
381 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
382 "BootServiceUpdatePendingError",
383 );
384 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
385 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
386 "systemctl --user stop t3code.service",
387 "systemctl --user restart t3code.service",
388 ]);
389 }
267390 }),
268391 );
269392
......@@ -271,14 +394,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
271394 Effect.gen(function* () {
272395 const { service } = yield* makeHarness("win32");
273396 expect((yield* service.status).supported).toBe(false);
274 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
397 expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
275398 }),
276399 );
277400
278401 it.effect("installs, reports current state, and uninstalls on macOS", () =>
279402 Effect.gen(function* () {
280403 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");
281 const plan = yield* service.install;
404 const plan = yield* service.install();
282405
283406 expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
284407 true,
......@@ -291,7 +414,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
291414 activeVersion: "1.2.3",
292415 });
293416 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
294 expect((yield* service.status).current).toBe(true);
417 expect(yield* service.status).toMatchObject({
418 current: true,
419 installedVersion: "1.2.3",
420 });
295421 expect(yield* service.uninstall).toBe(true);
296422 expect((yield* service.status).installed).toBe(false);
297423 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
......@@ -307,12 +433,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
307433 it.effect("restarts the launch agent when repair fails", () =>
308434 Effect.gen(function* () {
309435 const { service, commands, control } = yield* makeHarness("darwin");
310 yield* service.install;
436 yield* service.install();
311437 const plistPath = (yield* service.status).unitPath;
312438 commands.length = 0;
313439 control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`;
314440
315 const error = yield* service.install.pipe(Effect.flip);
441 const error = yield* service.install().pipe(Effect.flip);
316442 expect(error._tag).toBe("BootServiceCommandError");
317443 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
318444 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
......@@ -326,7 +452,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
326452 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
327453 Effect.gen(function* () {
328454 const { service, fs } = yield* makeHarness("darwin", false, "");
329 const plan = yield* service.install;
455 const plan = yield* service.install();
330456
331457 expect(yield* fs.readFileString(plan.unitPath)).toContain(
332458 " <key>PATH</key>\n <string>/usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",
......@@ -338,7 +464,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
338464 it.effect("adds missing provider directories to a minimal installer PATH", () =>
339465 Effect.gen(function* () {
340466 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");
341 const plan = yield* service.install;
467 const plan = yield* service.install();
342468
343469 expect(yield* fs.readFileString(plan.unitPath)).toContain(
344470 " <key>PATH</key>\n <string>/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin</string>",
......@@ -350,7 +476,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
350476 it.effect("keeps an installed launch agent current when the process PATH changes", () =>
351477 Effect.gen(function* () {
352478 const { service, makeService } = yield* makeHarness("darwin");
353 yield* service.install;
479 yield* service.install();
354480
355481 const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin");
356482 expect((yield* restartedService.status).current).toBe(true);
......@@ -364,7 +490,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
364490 false,
365491 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
366492 );
367 const plan = yield* service.install;
493 const plan = yield* service.install();
368494 const plist = yield* fs.readFileString(plan.unitPath);
369495
370496 expect(plist).toContain(
......@@ -378,10 +504,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
378504 it.effect("ignores a bootout for an agent that is not loaded", () =>
379505 Effect.gen(function* () {
380506 const { service, control } = yield* makeHarness("darwin");
381 yield* service.install;
507 yield* service.install();
382508 control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service";
383509
384 yield* service.install;
510 yield* service.install();
385511 expect((yield* service.status).current).toBe(true);
386512 }),
387513 );
......@@ -389,7 +515,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
389515 it.effect("restarts without overwriting a pending remote update on macOS", () =>
390516 Effect.gen(function* () {
391517 const { service, fs, statePath, commands } = yield* makeHarness("darwin");
392 yield* service.install;
518 yield* service.install();
393519 const plistPath = (yield* service.status).unitPath;
394520 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
395521 const pendingState = JSON.stringify({
......@@ -403,14 +529,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
403529 },
404530 });
405531 yield* fs.writeFileString(statePath, pendingState);
406 commands.length = 0;
407
408 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
409 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
410 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
411 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
412 `launchctl bootstrap gui/501 ${plistPath}`,
413 ]);
532 for (const allowDowngrade of [false, true]) {
533 commands.length = 0;
534
535 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
536 "BootServiceUpdatePendingError",
537 );
538 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
539 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
540 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
541 `launchctl bootstrap gui/501 ${plistPath}`,
542 ]);
543 }
414544 }),
415545 );
416546});
apps/server/src/cloud/bootService.ts+45-9
......@@ -24,7 +24,9 @@ import {
2424 SERVICE_LAUNCHER_FILE,
2525 SERVICE_LAUNCHER_PROTOCOL,
2626 SERVICE_STATE_FILE,
27 compareExactServiceVersions,
2728 parseServiceState,
29 serviceStateActiveVersion,
2830 serviceStateHasPendingUpdate,
2931 type ServiceState,
3032} from "./serviceProtocol.ts";
......@@ -416,16 +418,30 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootS
416418 }
417419}
418420
421export class BootServiceDowngradeRefusedError extends Schema.TaggedErrorClass<BootServiceDowngradeRefusedError>()(
422 "BootServiceDowngradeRefusedError",
423 {
424 installedVersion: Schema.String,
425 targetVersion: Schema.String,
426 },
427) {
428 override get message(): string {
429 return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`;
430 }
431}
432
419433export type BootServiceError =
420434 | BootServiceUnsupportedError
421435 | BootServiceCommandError
422436 | BootServiceInstallError
423 | BootServiceUpdatePendingError;
437 | BootServiceUpdatePendingError
438 | BootServiceDowngradeRefusedError;
424439
425440export interface BootServiceStatus {
426441 readonly supported: boolean;
427442 readonly installed: boolean;
428443 readonly current: boolean;
444 readonly installedVersion?: string;
429445 readonly unitPath: string;
430446 readonly logPath: string;
431447}
......@@ -433,7 +449,9 @@ export interface BootServiceStatus {
433449export class BootService extends Context.Service<
434450 BootService,
435451 {
436 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
452 readonly install: (options?: {
453 readonly allowDowngrade?: boolean;
454 }) => Effect.Effect<BootServicePlan, BootServiceError>;
437455 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
438456 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
439457 }
......@@ -569,7 +587,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
569587 { discard: true },
570588 );
571589
572 const install: BootService["Service"]["install"] = Effect.gen(function* () {
590 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {
591 readonly allowDowngrade?: boolean;
592 }) {
573593 const manager = yield* requireManager;
574594 yield* fs
575595 .makeDirectory(input.logsDir, { recursive: true })
......@@ -638,11 +658,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
638658 yield* Effect.gen(function* () {
639659 if (installed) {
640660 const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
641 if (
642 Option.isSome(previousStateText) &&
643 serviceStateHasPendingUpdate(previousStateText.value)
644 ) {
645 return yield* new BootServiceUpdatePendingError();
661 if (Option.isSome(previousStateText)) {
662 if (serviceStateHasPendingUpdate(previousStateText.value)) {
663 return yield* new BootServiceUpdatePendingError();
664 }
665 // A remote update can finish after the CLI checks status. Read its
666 // final version after the launcher stops and before changing files.
667 const installedVersion = serviceStateActiveVersion(previousStateText.value);
668 if (
669 installedVersion !== undefined &&
670 options?.allowDowngrade !== true &&
671 compareExactServiceVersions(input.cliVersion, installedVersion) < 0
672 ) {
673 return yield* new BootServiceDowngradeRefusedError({
674 installedVersion,
675 targetVersion: input.cliVersion,
676 });
677 }
646678 }
647679 }
648680 yield* fs
......@@ -670,7 +702,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
670702 ),
671703 );
672704 return plan;
673 }).pipe(Effect.withSpan("cloud.boot_service.install"));
705 });
674706
675707 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
676708 const manager = yield* requireManager;
......@@ -704,6 +736,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
704736 fs.readFileString(statePath).pipe(Effect.option),
705737 ]);
706738 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
739 const installedVersion = Option.isSome(stateText)
740 ? serviceStateActiveVersion(stateText.value)
741 : undefined;
707742 const normalizeUnit = (contents: string) =>
708743 detectedManager.kind === "launchd"
709744 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
......@@ -711,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
711746 return {
712747 supported: true,
713748 installed: true,
749 ...(installedVersion === undefined ? {} : { installedVersion }),
714750 current:
715751 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
716752 launcherExists &&
apps/server/src/cloud/serviceProtocol.ts+14
......@@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean {
184184 }
185185}
186186
187/** Reads the active version across launcher protocol revisions for downgrade protection. */
188export function serviceStateActiveVersion(value: string): string | undefined {
189 try {
190 const parsed: unknown = JSON.parse(value);
191 return isRecord(parsed) &&
192 typeof parsed.activeVersion === "string" &&
193 isExactServiceVersion(parsed.activeVersion)
194 ? parsed.activeVersion
195 : undefined;
196 } catch {
197 return undefined;
198 }
199}
200
187201export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
188202 let parsed: unknown;
189203 try {
docs/user/background-service.md+16
......@@ -23,6 +23,22 @@ Update or repair it:
2323npx t3@latest service update
2424```
2525
26The service uses the same T3 Code version as the CLI you run. To install a nightly or an exact
27version, use that version of the CLI:
28
29```sh
30npx t3@nightly service update
31npx t3@1.2.3 service update
32```
33
34The install and update commands refuse to replace a newer service with an older version. Setup
35through T3 Connect leaves a newer service unchanged. To downgrade, select the exact older version
36and pass `--allow-downgrade`:
37
38```sh
39npx t3@1.2.3 service update --allow-downgrade
40```
41
2642Stop it and remove it from startup:
2743
2844```sh