Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RelayClientInstallProgressStage,
} from "@t3tools/contracts";
import { RelayOkResponse } from "@t3tools/contracts/relay";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as RelayClient from "@t3tools/shared/relayClient";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -694,8 +695,11 @@ export const connectCommand = Command.make("connect", {
// fail the command, just tell the user what happened and move on.
const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
if (background) {
const platform = yield* HostProcessPlatform;
yield* Console.log(
"\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
platform === "darwin"
? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac."
: "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
);
return;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/cli/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => {
);
});

it("explains service availability without systemd", () => {
it("explains where the service is supported", () => {
assert.include(
formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
"Supported on: Linux with systemd",
"Supported on: Linux with systemd, macOS with launchd",
);
});
13 changes: 10 additions & 3 deletions apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -48,7 +49,7 @@ export function formatServiceStatus(
cliVersion: string,
): string {
if (!status.supported) {
return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd";
return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd";
}
if (!status.installed) {
return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
Expand Down Expand Up @@ -152,12 +153,18 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
yield* Console.log("T3 Code is already set up to run in the background on this machine.");
return true;
}
// A LaunchAgent starts at login and dies at logout; there is no
// enable-linger equivalent on macOS. Do not promise more than that.
const platform = yield* HostProcessPlatform;
const wanted = yield* Prompt.run(
Prompt.confirm({
message: installed
? "The installed T3 Code service needs an update or repair. Update it now?"
: "Run T3 Code in the background whenever this machine boots? " +
"It stays reachable through T3 Connect even after you log out.",
: platform === "darwin"
? "Run T3 Code in the background whenever you log in to this Mac? " +
"It stays reachable through T3 Connect while you are logged in."
: "Run T3 Code in the background whenever this machine boots? " +
"It stays reachable through T3 Connect even after you log out.",
initial: true,
}),
);
Expand Down
129 changes: 127 additions & 2 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -47,6 +48,50 @@ it("survives the kernel OOM-killing a greedy agent child", () => {
expect(unit).toContain("OOMPolicy=continue");
});

const macPlan = {
nodePath: "/opt/homebrew/bin/node",
launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs",
baseDir: "/Users/theo/.t3",
logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
};

it("keeps launchd pinned to the stable launcher rather than a versioned server", () => {
const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });

expect(plist).toContain("<string>/opt/homebrew/bin/node</string>");
expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>");
expect(plist).not.toContain("versions/1.2.3");
});

it("restarts the launch agent on the systemd cadence", () => {
const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });

expect(plist).toContain("<key>RunAtLoad</key>\n <true/>");
expect(plist).toContain("<key>KeepAlive</key>\n <true/>");
expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>5</integer>");
});

it("appends both stdio streams to the boot service log", () => {
const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });

expect(plist).toContain(
"<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
);
expect(plist).toContain(
"<key>StandardErrorPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
);
});

it("escapes XML in host paths", () => {
const plist = BootService.renderBootServicePlist(
{ ...macPlan, baseDir: "/Users/theo/T3 & <Co>" },
{ homeDir: "/Users/theo" },
);

expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>");
});

const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
platform: NodeJS.Platform = "linux",
usePinnedLauncher = false,
Expand Down Expand Up @@ -99,6 +144,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
Effect.provide(
Layer.mergeAll(
Layer.succeed(HostProcessPlatform, platform),
Layer.succeed(HostProcessUserId, 501),
Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
Expand Down Expand Up @@ -195,11 +241,90 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
}),
);

it.effect("fails closed off Linux", () =>
it.effect("fails closed on Windows", () =>
Effect.gen(function* () {
const { service } = yield* makeHarness("darwin");
const { service } = yield* makeHarness("win32");
expect((yield* service.status).supported).toBe(false);
expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
}),
);

it.effect("installs, reports current state, and uninstalls on macOS", () =>
Effect.gen(function* () {
const { service, fs, statePath, commands } = yield* makeHarness("darwin");
const plan = yield* service.install;

expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
true,
);
expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.3",
});
expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
expect((yield* service.status).current).toBe(true);
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
}),
);

it.effect("restarts the launch agent when repair fails", () =>
Effect.gen(function* () {
const { service, commands, control } = yield* makeHarness("darwin");
yield* service.install;
const plistPath = (yield* service.status).unitPath;
commands.length = 0;
control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`;

const error = yield* service.install.pipe(Effect.flip);
expect(error._tag).toBe("BootServiceCommandError");
expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
"launchctl bootout gui/501/com.t3tools.t3code.service",
"launchctl enable gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);

it.effect("ignores a bootout for an agent that is not loaded", () =>
Effect.gen(function* () {
const { service, control } = yield* makeHarness("darwin");
yield* service.install;
control.failCommand = "launchctl bootout gui/501/com.t3tools.t3code.service";

yield* service.install;
expect((yield* service.status).current).toBe(true);
}),
);

it.effect("restarts without overwriting a pending remote update on macOS", () =>
Effect.gen(function* () {
const { service, fs, statePath, commands } = yield* makeHarness("darwin");
yield* service.install;
const plistPath = (yield* service.status).unitPath;
// @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
const pendingState = JSON.stringify({
protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
activeVersion: "1.2.3",
update: {
id: "remote-update",
fromVersion: "1.2.3",
targetVersion: "1.2.4",
status: "pending",
},
});
yield* fs.writeFileString(statePath, pendingState);
commands.length = 0;

expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError");
expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
"launchctl bootout gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading