History

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

Theo Browne committed 2 months ago

Commit 0e77fbd1 parent 8efd4e96 files · +482 −71Browse files

Unified · Split

6 files
  1. apps/server/src/cli/service.test.ts +168 −2
  2. apps/server/src/cli/service.ts +58 −9
  3. apps/server/src/cloud/bootService.test.ts +181 −51
  4. apps/server/src/cloud/bootService.ts +45 −9
  5. apps/server/src/cloud/serviceProtocol.ts +14 −0
  6. docs/user/background-service.md +16 −0
apps/server/src/cli/service.test.ts+168−2
@@ -1,6 +1,26 @@
1−import { assert, it } from "@effect/vitest";
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, expect, it } from "@effect/vitest";
3+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
4+import * as NetService from "@t3tools/shared/Net";
5+import * as ConfigProvider from "effect/ConfigProvider";
6+import * as Effect from "effect/Effect";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Terminal from "effect/Terminal";
10+import { Command } from "effect/unstable/cli";
11+import { afterEach, vi } from "vite-plus/test";
212
3−import { formatServiceStatus } from "./service.ts";
13+import packageJson from "../../package.json" with { type: "json" };
14+import * as BootService from "../cloud/bootService.ts";
15+import {
16+ formatServiceStatus,
17+ offerServiceDuringOnboarding,
18+ reconcileService,
19+ recoverServiceOnboardingOffer,
20+ serviceCommand,
21+} from "./service.ts";
22+
23+afterEach(() => vi.restoreAllMocks());
424
525 const status = {
626 supported: true,
@@ -35,3 +55,149 @@ it("explains where the service is supported", () => { · 28 unmodified lines
3555 "Supported on: Linux with systemd, macOS with launchd",
3656 );
3757 });
58+
59+it("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+
70+const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
71+
72+function 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+
92+it.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+
147+it.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+
168+it.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+
190+it.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"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
55 import * as Terminal from "effect/Terminal";
6−import { Command, GlobalFlag, Prompt } from "effect/unstable/cli";
6+import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
77
88 import packageJson from "../../package.json" with { type: "json" };
99 import * as BootService from "../cloud/bootService.ts";
10+import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
1011 import type * as ServerConfig from "../config.ts";
1112 import * as ProcessRunner from "../processRunner.ts";
1213 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
@@ -30,13 +31,25 @@ export type ServiceReconcileResult = · 17 unmodified lines
3031 };
3132
3233 /** Install, update, or repair the service using the CLI version running this command. */
33−export const reconcileService = Effect.fn("cli.service.reconcile")(function* () {
34+export 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( · 11 unmodified lines
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>( · 9 unmodified lines
7299 return yield* run.pipe(Effect.provide(bootServiceLayer(config)));
73100 });
74101
75−const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe(
102+const 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+
110+const 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 · 8 unmodified lines
93128 ),
94129 );
95130
96−const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
131+const 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( · 1 unmodified line
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( · 37 unmodified lines
145180
146181 export 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* () { · 1 unmodified line
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>( · 35 unmodified lines
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* ( · 126 unmodified lines
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) => { · 28 unmodified lines
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) => { · 19 unmodified lines
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) => { · 4 unmodified lines
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) => { · 6 unmodified lines
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) => { · 6 unmodified lines
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) => { · 1 unmodified line
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) => { · 6 unmodified lines
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) => { · 9 unmodified lines
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) => { · 7 unmodified lines
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) => { · 5 unmodified lines
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) => { · 5 unmodified lines
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) => { · 7 unmodified lines
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) => { · 7 unmodified lines
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) => { · 1 unmodified line
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) => { · 7 unmodified lines
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 { · 23 unmodified lines
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 · 385 unmodified lines
416418 }
417419 }
418420
421+export 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+
419433 export type BootServiceError =
420434 | BootServiceUnsupportedError
421435 | BootServiceCommandError
422436 | BootServiceInstallError
423− | BootServiceUpdatePendingError;
437+ | BootServiceUpdatePendingError
438+ | BootServiceDowngradeRefusedError;
424439
425440 export 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 { · 1 unmodified line
433449 export 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: { · 129 unmodified lines
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: { · 62 unmodified lines
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: { · 21 unmodified lines
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: { · 27 unmodified lines
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: { · 1 unmodified line
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−0
@@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean { · 183 unmodified lines
184184 }
185185 }
186186
187+/** Reads the active version across launcher protocol revisions for downgrade protection. */
188+export 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+
187201 export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
188202 let parsed: unknown;
189203 try {
docs/user/background-service.md+16−0
@@ -23,6 +23,22 @@ Update or repair it: · 22 unmodified lines
2323 npx t3@latest service update
2424 ```
2525
26+The service uses the same T3 Code version as the CLI you run. To install a nightly or an exact
27+version, use that version of the CLI:
28+
29+```sh
30+npx t3@nightly service update
31+npx t3@1.2.3 service update
32+```
33+
34+The install and update commands refuse to replace a newer service with an older version. Setup
35+through T3 Connect leaves a newer service unchanged. To downgrade, select the exact older version
36+and pass `--allow-downgrade`:
37+
38+```sh
39+npx t3@1.2.3 service update --allow-downgrade
40+```
41+
2642 Stop it and remove it from startup:
2743
2844 ```sh

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t