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 @@...@@ -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
5const status = {25const status = {
6 supported: true,26 supported: true,
...@@ -35,3 +55,149 @@ it("explains where the service is supported", () => {...@@ -35,3 +55,149 @@ it("explains where the service is supported", () => {
35 "Supported on: Linux with systemd, macOS with launchd",55 "Supported on: Linux with systemd, macOS with launchd",
36 );56 );
37});57});
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";...@@ -3,10 +3,11 @@ import * as Console from "effect/Console";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Terminal from "effect/Terminal";5import * as Terminal from "effect/Terminal";
6import { Command, GlobalFlag, Prompt } from "effect/unstable/cli";6import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
77
8import packageJson from "../../package.json" with { type: "json" };8import packageJson from "../../package.json" with { type: "json" };
9import * as BootService from "../cloud/bootService.ts";9import * as BootService from "../cloud/bootService.ts";
10import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
10import type * as ServerConfig from "../config.ts";11import type * as ServerConfig from "../config.ts";
11import * as ProcessRunner from "../processRunner.ts";12import * as ProcessRunner from "../processRunner.ts";
12import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";13import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
...@@ -30,13 +31,25 @@ export type ServiceReconcileResult =...@@ -30,13 +31,25 @@ export type ServiceReconcileResult =
30 };31 };
3132
32/** Install, update, or repair the service using the CLI version running this command. */33/** 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}) {
34 const service = yield* BootService.BootService;37 const service = yield* BootService.BootService;
35 const status = yield* service.status;38 const status = yield* service.status;
36 if (status.installed && status.current) {39 if (status.installed && status.current) {
37 return { changed: false, status } satisfies ServiceReconcileResult;40 return { changed: false, status } satisfies ServiceReconcileResult;
38 }41 }
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);
40 return {53 return {
41 changed: true,54 changed: true,
42 previouslyInstalled: status.installed,55 previouslyInstalled: status.installed,
...@@ -54,9 +67,23 @@ export function formatServiceStatus(...@@ -54,9 +67,23 @@ export function formatServiceStatus(
54 if (!status.installed) {67 if (!status.installed) {
55 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";68 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
56 }69 }
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 }
57 return [84 return [
58 "T3 Code service",85 "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"}`,
60 ` Unit: ${status.unitPath}`,87 ` Unit: ${status.unitPath}`,
61 ` Logs: ${status.logPath}`,88 ` Logs: ${status.logPath}`,
62 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),89 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
...@@ -72,13 +99,21 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(...@@ -72,13 +99,21 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
72 return yield* run.pipe(Effect.provide(bootServiceLayer(config)));99 return yield* run.pipe(Effect.provide(bootServiceLayer(config)));
73});100});
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(
76 Command.withDescription("Install T3 Code as a background service for this user."),111 Command.withDescription("Install T3 Code as a background service for this user."),
77 Command.withHandler((flags) =>112 Command.withHandler((flags) =>
78 runServiceCommand(113 runServiceCommand(
79 flags,114 flags,
80 Effect.gen(function* () {115 Effect.gen(function* () {
81 const result = yield* reconcileService();116 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
82 if (!result.changed) {117 if (!result.changed) {
83 yield* Console.log(118 yield* Console.log(
84 `T3 Code service is already installed with t3@${packageJson.version}.`,119 `T3 Code service is already installed with t3@${packageJson.version}.`,
...@@ -93,7 +128,7 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe...@@ -93,7 +128,7 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe
93 ),128 ),
94);129);
95130
96const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(131const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
97 Command.withDescription(132 Command.withDescription(
98 "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",133 "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",
99 ),134 ),
...@@ -101,7 +136,7 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(...@@ -101,7 +136,7 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
101 runServiceCommand(136 runServiceCommand(
102 flags,137 flags,
103 Effect.gen(function* () {138 Effect.gen(function* () {
104 const result = yield* reconcileService();139 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
105 if (!result.changed) {140 if (!result.changed) {
106 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);141 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
107 return;142 return;
...@@ -145,7 +180,8 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(...@@ -145,7 +180,8 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
145180
146export const offerServiceDuringOnboarding = Effect.gen(function* () {181export const offerServiceDuringOnboarding = Effect.gen(function* () {
147 const service = yield* BootService.BootService;182 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;
149 if (!supported) {185 if (!supported) {
150 return false;186 return false;
151 }187 }
...@@ -153,6 +189,17 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {...@@ -153,6 +189,17 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
153 yield* Console.log("T3 Code is already set up to run in the background on this machine.");189 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
154 return true;190 return true;
155 }191 }
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 }
156 // A LaunchAgent starts at login and dies at logout; there is no203 // A LaunchAgent starts at login and dies at logout; there is no
157 // enable-linger equivalent on macOS. Do not promise more than that.204 // enable-linger equivalent on macOS. Do not promise more than that.
158 const platform = yield* HostProcessPlatform;205 const platform = yield* HostProcessPlatform;
...@@ -194,6 +241,8 @@ export const recoverServiceOnboardingOffer = <R>(...@@ -194,6 +241,8 @@ export const recoverServiceOnboardingOffer = <R>(
194 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),241 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
195 BootServiceUpdatePendingError: (error) =>242 BootServiceUpdatePendingError: (error) =>
196 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),243 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)),
197 }),246 }),
198 );247 );
199248
apps/server/src/cloud/bootService.test.ts+181-51
...@@ -127,24 +127,34 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -127,24 +127,34 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
127127
128 const commands: string[] = [];128 const commands: string[] = [];
129 const timeouts = new Map<string, unknown>();129 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 };
131 const runner = ProcessRunner.ProcessRunner.of({133 const runner = ProcessRunner.ProcessRunner.of({
132 run: (input) =>134 run: Effect.fn("test.run_boot_service_command")(function* (
133 Effect.sync(() => {135 input: ProcessRunner.ProcessRunInput,
134 const command = `${input.command} ${input.args.join(" ")}`;136 ) {
135 commands.push(command);137 const command = `${input.command} ${input.args.join(" ")}`;
136 timeouts.set(command, input.timeout);138 commands.push(command);
137 return {139 timeouts.set(command, input.timeout);
138 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",140 if (
139 stderr: "",141 control.stateAfterStop !== undefined &&
140 code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0),142 (command === "systemctl --user stop t3code.service" ||
141 timedOut: false,143 command.startsWith("launchctl bootout --wait "))
142 stdoutTruncated: false,144 ) {
143 stderrTruncated: false,145 yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie);
144 stdoutInvalidUtf8: false,146 }
145 stderrInvalidUtf8: false,147 return {
146 };148 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
147 }),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 }),
148 });158 });
149 const makeService = (environmentPath = installerPath) =>159 const makeService = (environmentPath = installerPath) =>
150 BootService.make({160 BootService.make({
...@@ -179,14 +189,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -179,14 +189,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
179 it.effect("installs, reports current state, and uninstalls", () =>189 it.effect("installs, reports current state, and uninstalls", () =>
180 Effect.gen(function* () {190 Effect.gen(function* () {
181 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();191 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
182 const plan = yield* service.install;192 const plan = yield* service.install();
183193
184 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({194 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
185 protocol: SERVICE_LAUNCHER_PROTOCOL,195 protocol: SERVICE_LAUNCHER_PROTOCOL,
186 activeVersion: "1.2.3",196 activeVersion: "1.2.3",
187 });197 });
188 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");198 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 });
190 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.203 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
191 const pendingState = JSON.stringify({204 const pendingState = JSON.stringify({
192 protocol: SERVICE_LAUNCHER_PROTOCOL,205 protocol: SERVICE_LAUNCHER_PROTOCOL,
...@@ -212,10 +225,116 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -212,10 +225,116 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
212 }),225 }),
213 );226 );
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
215 it.effect("copies the launcher from the prepared pinned runtime", () =>334 it.effect("copies the launcher from the prepared pinned runtime", () =>
216 Effect.gen(function* () {335 Effect.gen(function* () {
217 const { service, fs } = yield* makeHarness("linux", true);336 const { service, fs } = yield* makeHarness("linux", true);
218 const plan = yield* service.install;337 const plan = yield* service.install();
219338
220 expect(yield* fs.readFileString(plan.launcherPath)).toBe(339 expect(yield* fs.readFileString(plan.launcherPath)).toBe(
221 "export const source = 'pinned runtime';\n",340 "export const source = 'pinned runtime';\n",
...@@ -226,11 +345,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -226,11 +345,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
226 it.effect("restarts an installed service when repair fails", () =>345 it.effect("restarts an installed service when repair fails", () =>
227 Effect.gen(function* () {346 Effect.gen(function* () {
228 const { service, commands, control } = yield* makeHarness();347 const { service, commands, control } = yield* makeHarness();
229 yield* service.install;348 yield* service.install();
230 commands.length = 0;349 commands.length = 0;
231 control.failCommand = "systemctl --user daemon-reload";350 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);
234 expect(error._tag).toBe("BootServiceCommandError");353 expect(error._tag).toBe("BootServiceCommandError");
235 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([354 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
236 "systemctl --user stop t3code.service",355 "systemctl --user stop t3code.service",
...@@ -243,7 +362,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -243,7 +362,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
243 it.effect("restarts without overwriting a pending remote update", () =>362 it.effect("restarts without overwriting a pending remote update", () =>
244 Effect.gen(function* () {363 Effect.gen(function* () {
245 const { service, fs, statePath, commands } = yield* makeHarness();364 const { service, fs, statePath, commands } = yield* makeHarness();
246 yield* service.install;365 yield* service.install();
247 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.366 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
248 const pendingState = JSON.stringify({367 const pendingState = JSON.stringify({
249 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,368 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
...@@ -256,14 +375,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -256,14 +375,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
256 },375 },
257 });376 });
258 yield* fs.writeFileString(statePath, pendingState);377 yield* fs.writeFileString(statePath, pendingState);
259 commands.length = 0;378 for (const allowDowngrade of [false, true]) {
260379 commands.length = 0;
261 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");380
262 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);381 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
263 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([382 "BootServiceUpdatePendingError",
264 "systemctl --user stop t3code.service",383 );
265 "systemctl --user restart t3code.service",384 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
266 ]);385 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
386 "systemctl --user stop t3code.service",
387 "systemctl --user restart t3code.service",
388 ]);
389 }
267 }),390 }),
268 );391 );
269392
...@@ -271,14 +394,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -271,14 +394,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
271 Effect.gen(function* () {394 Effect.gen(function* () {
272 const { service } = yield* makeHarness("win32");395 const { service } = yield* makeHarness("win32");
273 expect((yield* service.status).supported).toBe(false);396 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");
275 }),398 }),
276 );399 );
277400
278 it.effect("installs, reports current state, and uninstalls on macOS", () =>401 it.effect("installs, reports current state, and uninstalls on macOS", () =>
279 Effect.gen(function* () {402 Effect.gen(function* () {
280 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");403 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");
281 const plan = yield* service.install;404 const plan = yield* service.install();
282405
283 expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(406 expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
284 true,407 true,
...@@ -291,7 +414,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -291,7 +414,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
291 activeVersion: "1.2.3",414 activeVersion: "1.2.3",
292 });415 });
293 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");416 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 });
295 expect(yield* service.uninstall).toBe(true);421 expect(yield* service.uninstall).toBe(true);
296 expect((yield* service.status).installed).toBe(false);422 expect((yield* service.status).installed).toBe(false);
297 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);423 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
...@@ -307,12 +433,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -307,12 +433,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
307 it.effect("restarts the launch agent when repair fails", () =>433 it.effect("restarts the launch agent when repair fails", () =>
308 Effect.gen(function* () {434 Effect.gen(function* () {
309 const { service, commands, control } = yield* makeHarness("darwin");435 const { service, commands, control } = yield* makeHarness("darwin");
310 yield* service.install;436 yield* service.install();
311 const plistPath = (yield* service.status).unitPath;437 const plistPath = (yield* service.status).unitPath;
312 commands.length = 0;438 commands.length = 0;
313 control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`;439 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);
316 expect(error._tag).toBe("BootServiceCommandError");442 expect(error._tag).toBe("BootServiceCommandError");
317 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([443 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
318 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",444 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
...@@ -326,7 +452,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -326,7 +452,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
326 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>452 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
327 Effect.gen(function* () {453 Effect.gen(function* () {
328 const { service, fs } = yield* makeHarness("darwin", false, "");454 const { service, fs } = yield* makeHarness("darwin", false, "");
329 const plan = yield* service.install;455 const plan = yield* service.install();
330456
331 expect(yield* fs.readFileString(plan.unitPath)).toContain(457 expect(yield* fs.readFileString(plan.unitPath)).toContain(
332 " <key>PATH</key>\n <string>/usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",458 " <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) => {...@@ -338,7 +464,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
338 it.effect("adds missing provider directories to a minimal installer PATH", () =>464 it.effect("adds missing provider directories to a minimal installer PATH", () =>
339 Effect.gen(function* () {465 Effect.gen(function* () {
340 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");466 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");
341 const plan = yield* service.install;467 const plan = yield* service.install();
342468
343 expect(yield* fs.readFileString(plan.unitPath)).toContain(469 expect(yield* fs.readFileString(plan.unitPath)).toContain(
344 " <key>PATH</key>\n <string>/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin</string>",470 " <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) => {...@@ -350,7 +476,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
350 it.effect("keeps an installed launch agent current when the process PATH changes", () =>476 it.effect("keeps an installed launch agent current when the process PATH changes", () =>
351 Effect.gen(function* () {477 Effect.gen(function* () {
352 const { service, makeService } = yield* makeHarness("darwin");478 const { service, makeService } = yield* makeHarness("darwin");
353 yield* service.install;479 yield* service.install();
354480
355 const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin");481 const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin");
356 expect((yield* restartedService.status).current).toBe(true);482 expect((yield* restartedService.status).current).toBe(true);
...@@ -364,7 +490,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -364,7 +490,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
364 false,490 false,
365 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",491 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
366 );492 );
367 const plan = yield* service.install;493 const plan = yield* service.install();
368 const plist = yield* fs.readFileString(plan.unitPath);494 const plist = yield* fs.readFileString(plan.unitPath);
369495
370 expect(plist).toContain(496 expect(plist).toContain(
...@@ -378,10 +504,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -378,10 +504,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
378 it.effect("ignores a bootout for an agent that is not loaded", () =>504 it.effect("ignores a bootout for an agent that is not loaded", () =>
379 Effect.gen(function* () {505 Effect.gen(function* () {
380 const { service, control } = yield* makeHarness("darwin");506 const { service, control } = yield* makeHarness("darwin");
381 yield* service.install;507 yield* service.install();
382 control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service";508 control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service";
383509
384 yield* service.install;510 yield* service.install();
385 expect((yield* service.status).current).toBe(true);511 expect((yield* service.status).current).toBe(true);
386 }),512 }),
387 );513 );
...@@ -389,7 +515,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -389,7 +515,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
389 it.effect("restarts without overwriting a pending remote update on macOS", () =>515 it.effect("restarts without overwriting a pending remote update on macOS", () =>
390 Effect.gen(function* () {516 Effect.gen(function* () {
391 const { service, fs, statePath, commands } = yield* makeHarness("darwin");517 const { service, fs, statePath, commands } = yield* makeHarness("darwin");
392 yield* service.install;518 yield* service.install();
393 const plistPath = (yield* service.status).unitPath;519 const plistPath = (yield* service.status).unitPath;
394 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.520 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
395 const pendingState = JSON.stringify({521 const pendingState = JSON.stringify({
...@@ -403,14 +529,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -403,14 +529,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
403 },529 },
404 });530 });
405 yield* fs.writeFileString(statePath, pendingState);531 yield* fs.writeFileString(statePath, pendingState);
406 commands.length = 0;532 for (const allowDowngrade of [false, true]) {
407533 commands.length = 0;
408 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");534
409 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);535 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
410 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([536 "BootServiceUpdatePendingError",
411 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",537 );
412 `launchctl bootstrap gui/501 ${plistPath}`,538 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
413 ]);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 }
414 }),544 }),
415 );545 );
416});546});
apps/server/src/cloud/bootService.ts+45-9
...@@ -24,7 +24,9 @@ import {...@@ -24,7 +24,9 @@ import {
24 SERVICE_LAUNCHER_FILE,24 SERVICE_LAUNCHER_FILE,
25 SERVICE_LAUNCHER_PROTOCOL,25 SERVICE_LAUNCHER_PROTOCOL,
26 SERVICE_STATE_FILE,26 SERVICE_STATE_FILE,
27 compareExactServiceVersions,
27 parseServiceState,28 parseServiceState,
29 serviceStateActiveVersion,
28 serviceStateHasPendingUpdate,30 serviceStateHasPendingUpdate,
29 type ServiceState,31 type ServiceState,
30} from "./serviceProtocol.ts";32} from "./serviceProtocol.ts";
...@@ -416,16 +418,30 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootS...@@ -416,16 +418,30 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootS
416 }418 }
417}419}
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
419export type BootServiceError =433export type BootServiceError =
420 | BootServiceUnsupportedError434 | BootServiceUnsupportedError
421 | BootServiceCommandError435 | BootServiceCommandError
422 | BootServiceInstallError436 | BootServiceInstallError
423 | BootServiceUpdatePendingError;437 | BootServiceUpdatePendingError
438 | BootServiceDowngradeRefusedError;
424439
425export interface BootServiceStatus {440export interface BootServiceStatus {
426 readonly supported: boolean;441 readonly supported: boolean;
427 readonly installed: boolean;442 readonly installed: boolean;
428 readonly current: boolean;443 readonly current: boolean;
444 readonly installedVersion?: string;
429 readonly unitPath: string;445 readonly unitPath: string;
430 readonly logPath: string;446 readonly logPath: string;
431}447}
...@@ -433,7 +449,9 @@ export interface BootServiceStatus {...@@ -433,7 +449,9 @@ export interface BootServiceStatus {
433export class BootService extends Context.Service<449export class BootService extends Context.Service<
434 BootService,450 BootService,
435 {451 {
436 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;452 readonly install: (options?: {
453 readonly allowDowngrade?: boolean;
454 }) => Effect.Effect<BootServicePlan, BootServiceError>;
437 readonly uninstall: Effect.Effect<boolean, BootServiceError>;455 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
438 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;456 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
439 }457 }
...@@ -569,7 +587,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -569,7 +587,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
569 { discard: true },587 { discard: true },
570 );588 );
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 }) {
573 const manager = yield* requireManager;593 const manager = yield* requireManager;
574 yield* fs594 yield* fs
575 .makeDirectory(input.logsDir, { recursive: true })595 .makeDirectory(input.logsDir, { recursive: true })
...@@ -638,11 +658,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -638,11 +658,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
638 yield* Effect.gen(function* () {658 yield* Effect.gen(function* () {
639 if (installed) {659 if (installed) {
640 const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);660 const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
641 if (661 if (Option.isSome(previousStateText)) {
642 Option.isSome(previousStateText) &&662 if (serviceStateHasPendingUpdate(previousStateText.value)) {
643 serviceStateHasPendingUpdate(previousStateText.value)663 return yield* new BootServiceUpdatePendingError();
644 ) {664 }
645 return yield* new BootServiceUpdatePendingError();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 }
646 }678 }
647 }679 }
648 yield* fs680 yield* fs
...@@ -670,7 +702,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -670,7 +702,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
670 ),702 ),
671 );703 );
672 return plan;704 return plan;
673 }).pipe(Effect.withSpan("cloud.boot_service.install"));705 });
674706
675 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {707 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
676 const manager = yield* requireManager;708 const manager = yield* requireManager;
...@@ -704,6 +736,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -704,6 +736,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
704 fs.readFileString(statePath).pipe(Effect.option),736 fs.readFileString(statePath).pipe(Effect.option),
705 ]);737 ]);
706 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;738 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
739 const installedVersion = Option.isSome(stateText)
740 ? serviceStateActiveVersion(stateText.value)
741 : undefined;
707 const normalizeUnit = (contents: string) =>742 const normalizeUnit = (contents: string) =>
708 detectedManager.kind === "launchd"743 detectedManager.kind === "launchd"
709 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")744 ? 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: {...@@ -711,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
711 return {746 return {
712 supported: true,747 supported: true,
713 installed: true,748 installed: true,
749 ...(installedVersion === undefined ? {} : { installedVersion }),
714 current:750 current:
715 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&751 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
716 launcherExists &&752 launcherExists &&
apps/server/src/cloud/serviceProtocol.ts+14
...@@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean {...@@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean {
184 }184 }
185}185}
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
187export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {201export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
188 let parsed: unknown;202 let parsed: unknown;
189 try {203 try {
docs/user/background-service.md+16
...@@ -23,6 +23,22 @@ Update or repair it:...@@ -23,6 +23,22 @@ Update or repair it:
23npx t3@latest service update23npx t3@latest service update
24```24```
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
26Stop it and remove it from startup:42Stop it and remove it from startup:
2743
28```sh44```sh