authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-19 04:04:51-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 01:04:51-07:00
log2aa5f095fc3bb65c00cc4efce66a5473e2d4554a
tree5312f6b584510b0b5996b706a840481b3ea79f39
parent7441b36926eb111a193183f98c06aced4f4c3acf
signature Signed by PGP key B5690EEEBB952194

feat(server): run the background service on macOS via launchd (#6286)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

11 files changed, 531 insertions(+), 78 deletions(-)

apps/server/src/cli/connect.ts+5-1
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type RelayClientInstallProgressStage,5 type RelayClientInstallProgressStage,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as RelayClient from "@t3tools/shared/relayClient";9import * as RelayClient from "@t3tools/shared/relayClient";
9import { withRelayClientTracing } from "@t3tools/shared/relayTracing";10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
...@@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", {...@@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", {
695 // fail the command, just tell the user what happened and move on.696 // fail the command, just tell the user what happened and move on.
696 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);697 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
697 if (background) {698 if (background) {
699 const platform = yield* HostProcessPlatform;
698 yield* Console.log(700 yield* Console.log(
699 "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",701 platform === "darwin"
702 ? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac."
703 : "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
700 );704 );
701 return;705 return;
702 }706 }
apps/server/src/cli/service.test.ts+2-2
...@@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => {...@@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => {
29 );29 );
30});30});
3131
32it("explains service availability without systemd", () => {32it("explains where the service is supported", () => {
33 assert.include(33 assert.include(
34 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),34 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
35 "Supported on: Linux with systemd",35 "Supported on: Linux with systemd, macOS with launchd",
36 );36 );
37});37});
apps/server/src/cli/service.ts+10-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1import * as Console from "effect/Console";2import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -48,7 +49,7 @@ export function formatServiceStatus(...@@ -48,7 +49,7 @@ export function formatServiceStatus(
48 cliVersion: string,49 cliVersion: string,
49): string {50): string {
50 if (!status.supported) {51 if (!status.supported) {
51 return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd";52 return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
52 }53 }
53 if (!status.installed) {54 if (!status.installed) {
54 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";55 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
...@@ -152,12 +153,18 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {...@@ -152,12 +153,18 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
152 yield* Console.log("T3 Code is already set up to run in the background on this machine.");153 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
153 return true;154 return true;
154 }155 }
156 // A LaunchAgent starts at login and dies at logout; there is no
157 // enable-linger equivalent on macOS. Do not promise more than that.
158 const platform = yield* HostProcessPlatform;
155 const wanted = yield* Prompt.run(159 const wanted = yield* Prompt.run(
156 Prompt.confirm({160 Prompt.confirm({
157 message: installed161 message: installed
158 ? "The installed T3 Code service needs an update or repair. Update it now?"162 ? "The installed T3 Code service needs an update or repair. Update it now?"
159 : "Run T3 Code in the background whenever this machine boots? " +163 : platform === "darwin"
160 "It stays reachable through T3 Connect even after you log out.",164 ? "Run T3 Code in the background whenever you log in to this Mac? " +
165 "It stays reachable through T3 Connect while you are logged in."
166 : "Run T3 Code in the background whenever this machine boots? " +
167 "It stays reachable through T3 Connect even after you log out.",
161 initial: true,168 initial: true,
162 }),169 }),
163 );170 );
apps/server/src/cloud/bootService.test.ts+143-4
...@@ -4,8 +4,10 @@ import {...@@ -4,8 +4,10 @@ import {
4 HostProcessArguments,4 HostProcessArguments,
5 HostProcessExecutablePath,5 HostProcessExecutablePath,
6 HostProcessPlatform,6 HostProcessPlatform,
7 HostProcessUserId,
7} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
8import * as ConfigProvider from "effect/ConfigProvider";9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";12import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
...@@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => {...@@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => {
47 expect(unit).toContain("OOMPolicy=continue");49 expect(unit).toContain("OOMPolicy=continue");
48});50});
4951
52const macPlan = {
53 nodePath: "/opt/homebrew/bin/node",
54 launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs",
55 baseDir: "/Users/theo/.t3",
56 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
57 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
58};
59
60it("keeps launchd pinned to the stable launcher rather than a versioned server", () => {
61 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
62
63 expect(plist).toContain("<string>/opt/homebrew/bin/node</string>");
64 expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>");
65 expect(plist).not.toContain("versions/1.2.3");
66});
67
68it("restarts the launch agent on the systemd cadence", () => {
69 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
70
71 expect(plist).toContain("<key>RunAtLoad</key>\n <true/>");
72 expect(plist).toContain("<key>KeepAlive</key>\n <true/>");
73 expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>5</integer>");
74 expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</integer>");
75});
76
77it("appends both stdio streams to the boot service log", () => {
78 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
79
80 expect(plist).toContain(
81 "<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
82 );
83 expect(plist).toContain(
84 "<key>StandardErrorPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
85 );
86});
87
88it("escapes XML in host paths", () => {
89 const plist = BootService.renderBootServicePlist(
90 { ...macPlan, baseDir: "/Users/theo/T3 & <Co>" },
91 { homeDir: "/Users/theo" },
92 );
93
94 expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>");
95});
96
50const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (97const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
51 platform: NodeJS.Platform = "linux",98 platform: NodeJS.Platform = "linux",
52 usePinnedLauncher = false,99 usePinnedLauncher = false,
...@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
68 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");115 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
69116
70 const commands: string[] = [];117 const commands: string[] = [];
118 const timeouts = new Map<string, unknown>();
71 const control: { failCommand: string | undefined } = { failCommand: undefined };119 const control: { failCommand: string | undefined } = { failCommand: undefined };
72 const runner = ProcessRunner.ProcessRunner.of({120 const runner = ProcessRunner.ProcessRunner.of({
73 run: (input) =>121 run: (input) =>
74 Effect.sync(() => {122 Effect.sync(() => {
75 const command = `${input.command} ${input.args.join(" ")}`;123 const command = `${input.command} ${input.args.join(" ")}`;
76 commands.push(command);124 commands.push(command);
125 timeouts.set(command, input.timeout);
77 return {126 return {
78 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",127 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
79 stderr: "",128 stderr: "",
...@@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
99 Effect.provide(148 Effect.provide(
100 Layer.mergeAll(149 Layer.mergeAll(
101 Layer.succeed(HostProcessPlatform, platform),150 Layer.succeed(HostProcessPlatform, platform),
151 Layer.succeed(HostProcessUserId, 501),
102 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),152 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
103 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),153 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
104 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),154 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
105 ),155 ),
106 ),156 ),
107 );157 );
108 return { service, fs, statePath, commands, control };158 return { service, fs, statePath, commands, timeouts, control };
109});159});
110160
111it.layer(NodeServices.layer)("boot service install", (it) => {161it.layer(NodeServices.layer)("boot service install", (it) => {
112 it.effect("installs, reports current state, and uninstalls", () =>162 it.effect("installs, reports current state, and uninstalls", () =>
113 Effect.gen(function* () {163 Effect.gen(function* () {
114 const { service, fs, statePath, commands } = yield* makeHarness();164 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
115 const plan = yield* service.install;165 const plan = yield* service.install;
116166
117 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({167 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
...@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
137 expect(yield* service.uninstall).toBe(true);187 expect(yield* service.uninstall).toBe(true);
138 expect((yield* service.status).installed).toBe(false);188 expect((yield* service.status).installed).toBe(false);
139 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);189 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
190 // The stop can block up to systemd's 90s TimeoutStopSec; the runner's
191 // 60s default would cancel it mid-shutdown.
192 expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
193 Duration.seconds(120),
194 );
140 }),195 }),
141 );196 );
142197
...@@ -195,11 +250,95 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -195,11 +250,95 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
195 }),250 }),
196 );251 );
197252
198 it.effect("fails closed off Linux", () =>253 it.effect("fails closed on Windows", () =>
199 Effect.gen(function* () {254 Effect.gen(function* () {
200 const { service } = yield* makeHarness("darwin");255 const { service } = yield* makeHarness("win32");
201 expect((yield* service.status).supported).toBe(false);256 expect((yield* service.status).supported).toBe(false);
202 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");257 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
203 }),258 }),
204 );259 );
260
261 it.effect("installs, reports current state, and uninstalls on macOS", () =>
262 Effect.gen(function* () {
263 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");
264 const plan = yield* service.install;
265
266 expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
267 true,
268 );
269 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
270 protocol: SERVICE_LAUNCHER_PROTOCOL,
271 activeVersion: "1.2.3",
272 });
273 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
274 expect((yield* service.status).current).toBe(true);
275 expect(yield* service.uninstall).toBe(true);
276 expect((yield* service.status).installed).toBe(false);
277 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
278 expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
279 // A bootout can block up to the plist's 90s ExitTimeOut; the runner's
280 // 60s default would cancel it and let bootstrap race a loaded job.
281 expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
282 Duration.seconds(120),
283 );
284 }),
285 );
286
287 it.effect("restarts the launch agent when repair fails", () =>
288 Effect.gen(function* () {
289 const { service, commands, control } = yield* makeHarness("darwin");
290 yield* service.install;
291 const plistPath = (yield* service.status).unitPath;
292 commands.length = 0;
293 control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`;
294
295 const error = yield* service.install.pipe(Effect.flip);
296 expect(error._tag).toBe("BootServiceCommandError");
297 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
298 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
299 "launchctl enable gui/501/com.t3tools.t3code.service",
300 `launchctl bootstrap gui/501 ${plistPath}`,
301 `launchctl bootstrap gui/501 ${plistPath}`,
302 ]);
303 }),
304 );
305
306 it.effect("ignores a bootout for an agent that is not loaded", () =>
307 Effect.gen(function* () {
308 const { service, control } = yield* makeHarness("darwin");
309 yield* service.install;
310 control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service";
311
312 yield* service.install;
313 expect((yield* service.status).current).toBe(true);
314 }),
315 );
316
317 it.effect("restarts without overwriting a pending remote update on macOS", () =>
318 Effect.gen(function* () {
319 const { service, fs, statePath, commands } = yield* makeHarness("darwin");
320 yield* service.install;
321 const plistPath = (yield* service.status).unitPath;
322 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
323 const pendingState = JSON.stringify({
324 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
325 activeVersion: "1.2.3",
326 update: {
327 id: "remote-update",
328 fromVersion: "1.2.3",
329 targetVersion: "1.2.4",
330 status: "pending",
331 },
332 });
333 yield* fs.writeFileString(statePath, pendingState);
334 commands.length = 0;
335
336 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
337 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
338 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
339 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
340 `launchctl bootstrap gui/501 ${plistPath}`,
341 ]);
342 }),
343 );
205});344});
apps/server/src/cloud/bootService.ts+314-47
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";1import {
2 HostProcessExecutablePath,
3 HostProcessPlatform,
4 HostProcessUserId,
5} from "@t3tools/shared/hostProcess";
2import * as Config from "effect/Config";6import * as Config from "effect/Config";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";8import * as DateTime from "effect/DateTime";
...@@ -27,6 +31,10 @@ import {...@@ -27,6 +31,10 @@ import {
2731
28const BOOT_SERVICE_NAME = "t3code";32const BOOT_SERVICE_NAME = "t3code";
29export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;33export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
34// `.service` suffix keeps the label distinct from the desktop app's bundle id
35// (com.t3tools.t3code), so launchd and TCC records never collide.
36export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";
37export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;
30export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";38export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
3139
32/** systemd expands `%` specifiers, including in unquoted append-log paths. */40/** systemd expands `%` specifiers, including in unquoted append-log paths. */
...@@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {...@@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
83 ].join("\n");91 ].join("\n");
84}92}
8593
94/** Plist values are emitted as XML text nodes; only these three need escaping. */
95export function escapeXmlText(value: string): string {
96 return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
97}
98
99/** Pure renderer: launch agents cannot rely on the user's shell or PATH. */
100export function renderBootServicePlist(
101 plan: BootServicePlan,
102 options: { readonly homeDir: string },
103): string {
104 // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd
105 // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever.
106 // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop
107 // completes within the launcher's 5s child grace, but a stop that queues
108 // behind an in-flight update transition can take much longer; launchd's
109 // system-defined default (5s on current macOS) would SIGKILL the launcher
110 // (and, with it, the process group) mid-handoff.
111 // ProcessType Interactive opts out of background-job resource throttling.
112 // AbandonProcessGroup stays at its default (false): launchd reaps leftover
113 // process-group members only when the launcher itself exits — the analog of
114 // KillMode=mixed's final cgroup kill — and not when the launcher restarts its
115 // child, so agent children survive server updates.
116 return [
117 `<?xml version="1.0" encoding="UTF-8"?>`,
118 `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
119 `<plist version="1.0">`,
120 `<dict>`,
121 ` <key>Label</key>`,
122 ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`,
123 ` <key>ProgramArguments</key>`,
124 ` <array>`,
125 ` <string>${escapeXmlText(plan.nodePath)}</string>`,
126 ` <string>${escapeXmlText(plan.launcherPath)}</string>`,
127 ` </array>`,
128 ` <key>EnvironmentVariables</key>`,
129 ` <dict>`,
130 ` <key>T3CODE_HOME</key>`,
131 ` <string>${escapeXmlText(plan.baseDir)}</string>`,
132 ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`,
133 ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`,
134 ` </dict>`,
135 ` <key>WorkingDirectory</key>`,
136 ` <string>${escapeXmlText(options.homeDir)}</string>`,
137 ` <key>RunAtLoad</key>`,
138 ` <true/>`,
139 ` <key>KeepAlive</key>`,
140 ` <true/>`,
141 ` <key>ThrottleInterval</key>`,
142 ` <integer>5</integer>`,
143 ` <key>ExitTimeOut</key>`,
144 ` <integer>90</integer>`,
145 ` <key>ProcessType</key>`,
146 ` <string>Interactive</string>`,
147 ` <key>StandardOutPath</key>`,
148 ` <string>${escapeXmlText(plan.logPath)}</string>`,
149 ` <key>StandardErrorPath</key>`,
150 ` <string>${escapeXmlText(plan.logPath)}</string>`,
151 `</dict>`,
152 `</plist>`,
153 ``,
154 ].join("\n");
155}
156
157export interface BootServiceStep {
158 readonly step: string;
159 readonly command: string;
160 readonly args: ReadonlyArray<string>;
161 /**
162 * Non-zero exit is logged and ignored. Reserved for steps whose common
163 * failures (not loaded, already enabled) leave a state a later strict step
164 * either tolerates or fails loudly on.
165 */
166 readonly optional?: boolean;
167 /** Override the ProcessRunner default (60s) for steps that block longer. */
168 readonly timeout?: Duration.Input;
169}
170
171/**
172 * Stop commands block until the service manager gives up: 90s by default for
173 * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This
174 * must stay above both, or the runner cancels the stop mid-shutdown and the
175 * next step races a still-loaded service.
176 */
177const STOP_STEP_TIMEOUT = Duration.seconds(120);
178
179/**
180 * Platform service-manager integration as data: paths, a pure renderer, and
181 * the command steps each flow runs. install/uninstall/status consume this and
182 * never branch on platform.
183 */
184export interface BootServiceManager {
185 readonly kind: "systemd" | "launchd";
186 readonly unitPath: string;
187 readonly render: (plan: BootServicePlan) => string;
188 /** Before rewriting files, when a unit is already installed. */
189 readonly stop: ReadonlyArray<BootServiceStep>;
190 /** After files are written. The last entry starts the service. */
191 readonly activate: ReadonlyArray<BootServiceStep>;
192 /** Best-effort recovery after a failed repair of an installed service. */
193 readonly restart: ReadonlyArray<BootServiceStep>;
194 /** Uninstall, before the unit file is removed. */
195 readonly deactivate: ReadonlyArray<BootServiceStep>;
196 /** Uninstall, after the unit file is removed. */
197 readonly finalize: ReadonlyArray<BootServiceStep>;
198}
199
200export function systemdManager(input: {
201 readonly path: Path.Path;
202 readonly homeDir: string;
203}): BootServiceManager {
204 const unitPath = input.path.join(
205 input.homeDir,
206 ".config",
207 "systemd",
208 "user",
209 BOOT_SERVICE_UNIT_FILE,
210 );
211 return {
212 kind: "systemd",
213 unitPath,
214 render: renderBootServiceUnit,
215 stop: [
216 {
217 step: "stopping the installed service",
218 command: "systemctl",
219 args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE],
220 timeout: STOP_STEP_TIMEOUT,
221 },
222 ],
223 activate: [
224 {
225 step: "reloading systemd user units",
226 command: "systemctl",
227 args: ["--user", "daemon-reload"],
228 },
229 {
230 step: "enabling the service",
231 command: "systemctl",
232 args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE],
233 },
234 { step: "enabling lingering for this user", command: "loginctl", args: ["enable-linger"] },
235 // Start last. No administrative state write occurs after this succeeds.
236 {
237 step: "starting the service",
238 command: "systemctl",
239 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
240 },
241 ],
242 restart: [
243 {
244 step: "restarting the service after a failed update",
245 command: "systemctl",
246 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
247 },
248 ],
249 deactivate: [
250 {
251 step: "stopping the service",
252 command: "systemctl",
253 args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE],
254 timeout: STOP_STEP_TIMEOUT,
255 },
256 ],
257 finalize: [
258 {
259 step: "reloading systemd user units",
260 command: "systemctl",
261 args: ["--user", "daemon-reload"],
262 },
263 ],
264 };
265}
266
267export function launchdManager(input: {
268 readonly path: Path.Path;
269 readonly homeDir: string;
270 readonly uid: number;
271}): BootServiceManager {
272 const unitPath = input.path.join(
273 input.homeDir,
274 "Library",
275 "LaunchAgents",
276 BOOT_SERVICE_PLIST_FILE,
277 );
278 const domainTarget = `gui/${input.uid}`;
279 const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`;
280 // bootout/enable are optional: they fail on not-loaded states that are fine
281 // to proceed from. The strict `bootstrap` runs last and is also the start:
282 // loading a RunAtLoad/KeepAlive plist starts the job, so a separate
283 // kickstart would kill and restart a server it just booted. A lingering job
284 // that survived bootout, or a gui domain with nobody logged in at the
285 // screen (SSH install), makes bootstrap fail the flow loudly rather than
286 // silently keeping a stale server.
287 return {
288 kind: "launchd",
289 unitPath,
290 render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }),
291 // Without --wait, bootout returns in milliseconds while the job drains
292 // for up to ExitTimeOut, and a bootstrap during the drain fails EIO.
293 // --wait (present on modern macOS, absent from the man page) blocks until
294 // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it.
295 stop: [
296 {
297 step: "stopping the installed launch agent",
298 command: "launchctl",
299 args: ["bootout", "--wait", serviceTarget],
300 optional: true,
301 timeout: STOP_STEP_TIMEOUT,
302 },
303 ],
304 activate: [
305 // A persisted `launchctl disable` override refuses bootstrap; clear it.
306 {
307 step: "enabling the launch agent",
308 command: "launchctl",
309 args: ["enable", serviceTarget],
310 optional: true,
311 },
312 // Start last. No administrative state write occurs after this succeeds.
313 {
314 step: "starting the service",
315 command: "launchctl",
316 args: ["bootstrap", domainTarget, unitPath],
317 },
318 ],
319 restart: [
320 {
321 step: "restarting the service after a failed update",
322 command: "launchctl",
323 args: ["bootstrap", domainTarget, unitPath],
324 },
325 ],
326 // No `launchctl disable` here: a persisted override would sabotage a
327 // later reinstall. Removing the plist is what stops the next login load.
328 // A bootout that fails for a reason other than "not loaded" leaves the
329 // job running until logout; the failure is in the boot-service log.
330 deactivate: [
331 {
332 step: "stopping the service",
333 command: "launchctl",
334 args: ["bootout", "--wait", serviceTarget],
335 optional: true,
336 timeout: STOP_STEP_TIMEOUT,
337 },
338 ],
339 finalize: [],
340 };
341}
342
343/** Undefined means this host cannot run the background service. */
344export function selectBootServiceManager(input: {
345 readonly platform: NodeJS.Platform;
346 readonly homeDir: string;
347 readonly uid: number | undefined;
348 readonly path: Path.Path;
349}): BootServiceManager | undefined {
350 if (input.homeDir === "") {
351 return undefined;
352 }
353 if (input.platform === "linux") {
354 return systemdManager({ path: input.path, homeDir: input.homeDir });
355 }
356 if (input.platform === "darwin" && input.uid !== undefined) {
357 return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid });
358 }
359 return undefined;
360}
361
86export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(362export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
87 "BootServiceUnsupportedError",363 "BootServiceUnsupportedError",
88 { platform: Schema.String },364 { platform: Schema.String },
89) {365) {
90 override get message(): string {366 override get message(): string {
91 return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`;367 return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`;
92 }368 }
93}369}
94370
...@@ -163,14 +439,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -163,14 +439,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
163}) {439}) {
164 const hostExecPath = yield* HostProcessExecutablePath;440 const hostExecPath = yield* HostProcessExecutablePath;
165 const platform = yield* HostProcessPlatform;441 const platform = yield* HostProcessPlatform;
442 const uid = yield* HostProcessUserId;
166 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));443 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
167 const fs = yield* FileSystem.FileSystem;444 const fs = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;445 const path = yield* Path.Path;
169 const runner = yield* ProcessRunner.ProcessRunner;446 const runner = yield* ProcessRunner.ProcessRunner;
170 const host = input.host ?? { execPath: hostExecPath };447 const host = input.host ?? { execPath: hostExecPath };
171448
172 const unitDir = path.join(homeDir, ".config", "systemd", "user");449 const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path });
173 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);450 const unitPath = detectedManager?.unitPath ?? "";
174 const logPath = path.join(input.logsDir, "boot-service.log");451 const logPath = path.join(input.logsDir, "boot-service.log");
175 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);452 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
176 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);453 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
...@@ -198,11 +475,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -198,11 +475,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
198 unitPath,475 unitPath,
199 };476 };
200477
201 const requireSystemdLinux = Effect.gen(function* () {478 const requireManager = Effect.suspend(() =>
202 if (platform !== "linux" || homeDir === "") {479 detectedManager === undefined
203 return yield* new BootServiceUnsupportedError({ platform });480 ? new BootServiceUnsupportedError({ platform })
204 }481 : Effect.succeed(detectedManager),
205 });482 );
206483
207 const runStep = Effect.fn("cloud.boot_service.run_step")(function* (484 const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
208 step: string,485 step: string,
...@@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
235 );512 );
236 });513 });
237514
515 const runSteps = (steps: ReadonlyArray<BootServiceStep>) =>
516 Effect.forEach(
517 steps,
518 (entry) => {
519 const run = runStep(
520 entry.step,
521 entry.command,
522 entry.args,
523 entry.timeout === undefined ? undefined : { timeout: entry.timeout },
524 );
525 // runStep's tapError already appends the failure to the log, so an
526 // ignored optional step still leaves a trace.
527 return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid);
528 },
529 { discard: true },
530 );
531
238 const install: BootService["Service"]["install"] = Effect.gen(function* () {532 const install: BootService["Service"]["install"] = Effect.gen(function* () {
239 yield* requireSystemdLinux;533 const manager = yield* requireManager;
240 yield* fs534 yield* fs
241 .makeDirectory(input.logsDir, { recursive: true })535 .makeDirectory(input.logsDir, { recursive: true })
242 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));536 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
...@@ -298,11 +592,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -298,11 +592,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
298 .exists(unitPath)592 .exists(unitPath)
299 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));593 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
300 if (installed) {594 if (installed) {
301 yield* runStep("stopping the installed service", "systemctl", [595 yield* runSteps(manager.stop);
302 "--user",
303 "stop",
304 BOOT_SERVICE_UNIT_FILE,
305 ]);
306 }596 }
307597
308 yield* Effect.gen(function* () {598 yield* Effect.gen(function* () {
...@@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
316 }606 }
317 }607 }
318 yield* fs608 yield* fs
319 .makeDirectory(unitDir, { recursive: true })609 .makeDirectory(path.dirname(unitPath), { recursive: true })
320 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));610 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
321 yield* writeDurably(launcherPath, launcherSource);611 yield* writeDurably(launcherPath, launcherSource);
322 yield* writeDurably(612 yield* writeDurably(
...@@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
331 2,621 2,
332 )}\n`,622 )}\n`,
333 );623 );
334 yield* writeDurably(unitPath, renderBootServiceUnit(plan));624 yield* writeDurably(unitPath, manager.render(plan));
335625
336 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);626 yield* runSteps(manager.activate);
337 yield* runStep("enabling the service", "systemctl", [
338 "--user",
339 "enable",
340 BOOT_SERVICE_UNIT_FILE,
341 ]);
342 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
343 // Start last. No administrative state write occurs after this succeeds.
344 yield* runStep("starting the service", "systemctl", [
345 "--user",
346 "restart",
347 BOOT_SERVICE_UNIT_FILE,
348 ]);
349 }).pipe(627 }).pipe(
350 Effect.tapError(() =>628 Effect.tapError(() =>
351 installed629 installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,
352 ? runStep("restarting the service after a failed update", "systemctl", [
353 "--user",
354 "restart",
355 BOOT_SERVICE_UNIT_FILE,
356 ]).pipe(Effect.ignore)
357 : Effect.void,
358 ),630 ),
359 );631 );
360 return plan;632 return plan;
361 }).pipe(Effect.withSpan("cloud.boot_service.install"));633 }).pipe(Effect.withSpan("cloud.boot_service.install"));
362634
363 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {635 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
364 yield* requireSystemdLinux;636 const manager = yield* requireManager;
365 if (637 if (
366 !(yield* fs638 !(yield* fs
367 .exists(unitPath)639 .exists(unitPath)
368 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))640 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))
369 )641 )
370 return false;642 return false;
371 yield* runStep("stopping the service", "systemctl", [643 yield* runSteps(manager.deactivate);
372 "--user",
373 "disable",
374 "--now",
375 BOOT_SERVICE_UNIT_FILE,
376 ]);
377 yield* fs644 yield* fs
378 .remove(unitPath)645 .remove(unitPath)
379 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));646 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
380 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);647 yield* runSteps(manager.finalize);
381 return true;648 return true;
382 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));649 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
383650
384 const status: BootService["Service"]["status"] = Effect.gen(function* () {651 const status: BootService["Service"]["status"] = Effect.gen(function* () {
385 if (platform !== "linux" || homeDir === "") {652 if (detectedManager === undefined) {
386 return { supported: false, installed: false, current: false, unitPath, logPath };653 return { supported: false, installed: false, current: false, unitPath, logPath };
387 }654 }
388 if (!(yield* fs.exists(unitPath))) {655 if (!(yield* fs.exists(unitPath))) {
...@@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
401 supported: true,668 supported: true,
402 installed: true,669 installed: true,
403 current:670 current:
404 unit === renderBootServiceUnit(plan) &&671 unit === detectedManager.render(plan) &&
405 launcherExists &&672 launcherExists &&
406 runtimeEntryExists &&673 runtimeEntryExists &&
407 Option.isSome(runtimeSentinel) &&674 Option.isSome(runtimeSentinel) &&
apps/server/src/cloud/http.ts+2-1
...@@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () {...@@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () {
649});649});
650650
651// A pending update alone is not proof a replacement server is coming: an651// A pending update alone is not proof a replacement server is coming: an
652// explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during652// explicit launcher stop (`t3 service uninstall`, `systemctl stop`,
653// `launchctl bootout`) during
653// the pending window also tears this server down. The launcher marks that case654// the pending window also tears this server down. The launcher marks that case
654// just before it signals the child, so pending + no marker is the handoff.655// just before it signals the child, so pending + no marker is the handoff.
655const pendingUpdateHandoffExists = Effect.gen(function* () {656const pendingUpdateHandoffExists = Effect.gen(function* () {
apps/server/src/cloud/pinnedRuntime.ts+2-2
...@@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts";...@@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts";
1010
11/**11/**
12 * A pinned runtime is an exact `t3@<version>` npm-installed into12 * A pinned runtime is an exact `t3@<version>` npm-installed into
13 * <baseDir>/runtime/versions/<version>. The boot service points its systemd13 * <baseDir>/runtime/versions/<version>. The boot service points its unit or
14 * unit here, and server self-update installs the target version here before14 * launch agent here, and server self-update installs the target version here before
15 * switching over, never `npx t3`, whose cache is ephemeral and whose15 * switching over, never `npx t3`, whose cache is ephemeral and whose
16 * registry fetch at boot would make startup depend on the network.16 * registry fetch at boot would make startup depend on the network.
17 */17 */
apps/server/src/serviceLauncher.ts+3-1
...@@ -317,7 +317,9 @@ export class Launcher {...@@ -317,7 +317,9 @@ export class Launcher {
317 // This must happen synchronously at signal receipt. A queued update317 // This must happen synchronously at signal receipt. A queued update
318 // transition may already be terminating the active child, and that child318 // transition may already be terminating the active child, and that child
319 // needs to see the marker in its shutdown finalizer. KillMode=mixed also319 // needs to see the marker in its shutdown finalizer. KillMode=mixed also
320 // ensures systemd signals the launcher before the rest of the cgroup.320 // ensures systemd signals the launcher before the rest of the cgroup, and
321 // launchd signals only the job's main process (this launcher), so the
322 // marker lands before the child sees any signal on both platforms.
321 try {323 try {
322 NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 });324 NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 });
323 } catch {325 } catch {
docs/internals/server-updates.md+7-6
...@@ -2,14 +2,15 @@...@@ -2,14 +2,15 @@
22
3> For maintainers. Using T3 Code? See [docs/user](../user/).3> For maintainers. Using T3 Code? See [docs/user](../user/).
44
5Remote server updates use one stable systemd launcher. Foreground CLI processes do not self-update,5Remote server updates use one stable launcher selected by the platform service manager (systemd on
6and a running server never edits its systemd unit or durable service state.6Linux, launchd on macOS). Foreground CLI processes do not self-update, and a running server never
7edits its service definition or durable service state.
78
8## Ownership9## Ownership
910
10The service files under `<baseDir>/runtime` are:11The service files under `<baseDir>/runtime` are:
1112
12- `service-launcher.mjs`, the stable process selected by systemd;13- `service-launcher.mjs`, the stable process selected by the service manager;
13- `service-state.json`, the launcher's durable selection state;14- `service-state.json`, the launcher's durable selection state;
14- `versions/<version>`, immutable exact-version npm installs.15- `versions/<version>`, immutable exact-version npm installs.
1516
...@@ -22,7 +23,7 @@ The state contains one active version and, at most, one update record:...@@ -22,7 +23,7 @@ The state contains one active version and, at most, one update record:
22- `pending A → B` selects B as a retryable trial;23- `pending A → B` selects B as a retryable trial;
23- `committed A → B` selects B for ordinary restarts;24- `committed A → B` selects B for ordinary restarts;
24- `rolled-back A → B` or `failed A → B` selects A;25- `rolled-back A → B` or `failed A → B` selects A;
25- invalid state fails closed so systemd cannot guess at a runtime.26- invalid state fails closed so the service manager cannot guess at a runtime.
2627
27Every write uses same-directory replacement plus file and directory fsync.28Every write uses same-directory replacement plus file and directory fsync.
2829
...@@ -50,8 +51,8 @@ operations. It only opens prepared gates and publishes prepared lifecycle state....@@ -50,8 +51,8 @@ operations. It only opens prepared gates and publishes prepared lifecycle state.
50The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within51The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
51120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the52120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the
52snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore53snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore
53resume before either version can boot. After commit, B is active and normal systemd restart policy54resume before either version can boot. After commit, B is active and the service manager's normal
54applies.55restart policy applies.
5556
56## Database Rollback57## Database Rollback
5758
docs/user/background-service.md+35-11
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1# Running T3 Code in the Background1# Running T3 Code in the Background
22
3On a Linux host, T3 Code can run as a background service for your user. It starts when the machine3On Linux and macOS, T3 Code can run as a background service for your user, so it is ready without
4boots and keeps running after you log out.4keeping a terminal open.
55
6## Manage the Service6## Manage the Service
77
...@@ -32,18 +32,42 @@ npx t3@latest service uninstall...@@ -32,18 +32,42 @@ npx t3@latest service uninstall
32Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.32Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
33If a remote update is already in progress, wait for it to finish before retrying a local update.33If a remote update is already in progress, wait for it to finish before retrying a local update.
3434
35The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so35The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a
36a failed remote candidate can return to the previous version without rewriting the unit. The36failed remote candidate can return to the previous version without rewriting the service
37launcher snapshots the database before a remote candidate starts, so database updates roll back37definition. The launcher snapshots the database before a remote candidate starts, so database
38with the server version. An older launcher may require one local `service update` before this is38updates roll back with the server version. An older launcher may require one local
39available.39`service update` before this is available.
40
41## Platform Support
42
43**Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts
44when the machine boots and keeps running after you log out (lingering is enabled during install).
45
46**macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It
47starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no
48equivalent of Linux lingering for user agents. For a Mac that should stay reachable unattended,
49turn on automatic login (System Settings → Users & Groups; unavailable while FileVault is on) and
50keep the Mac from sleeping.
51
52A few more macOS notes:
53
54- Installing over SSH needs someone logged in at the Mac's screen to start the agent right away.
55 Without that, the install command reports an error at the final start step, but the agent is
56 fully installed and starts at the next login.
57- macOS may show privacy prompts for protected folders such as Desktop, Documents, or Downloads,
58 attributed to a bare `node` process, or deny access without a prompt. If agent work fails to
59 read those folders, grant Full Disk Access to the node binary listed in the launch agent's
60 `ProgramArguments`.
61- The agent appears under System Settings → General → Login Items. If it was switched off there,
62 or disabled with `launchctl disable`, macOS will not start it at login until you switch it back
63 on.
64
65**Windows** is not supported yet.
4066
41## Using It with T3 Connect67## Using It with T3 Connect
4268
43T3 Connect may offer to install the service during setup so the host stays reachable after you log69T3 Connect may offer to install the service during setup so the host stays reachable in the
44out. This is only an onboarding shortcut: the service and T3 Connect are managed separately.70background. This is only an onboarding shortcut: the service and T3 Connect are managed separately.
4571
46Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer72Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
47want T3 Code to start in the background.73want T3 Code to start in the background.
48
49The background service currently requires Linux with systemd.
packages/shared/src/hostProcess.ts+8
...@@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(...@@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
51 },51 },
52);52);
5353
54/** Undefined on platforms without POSIX uids (Windows). */
55export const HostProcessUserId = Context.Reference<number | undefined>(
56 "@t3tools/shared/hostProcess/HostProcessUserId",
57 {
58 defaultValue: () => process.getuid?.(),
59 },
60);
61
54export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");62export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");