Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(server): run the background service on macOS via launchd by t3dotgg · Pull Request #6286 · pingdotgg/t3code · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
147 changes: 143 additions & 4 deletions apps/server/src/cloud/bootService.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,10 @@ import {
HostProcessArguments,
HostProcessExecutablePath,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand DownExpand Up@@ -47,6 +49,51 @@ 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>");
expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</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 All@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
const timeouts = new Map<string, unknown>();
const control: { failCommand: string | undefined } = { failCommand: undefined };
const runner = ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.sync(() => {
const command = `${input.command} ${input.args.join(" ")}`;
commands.push(command);
timeouts.set(command, input.timeout);
return {
stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
stderr: "",
Expand All@@ -99,19 +148,20 @@ 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 } })),
),
),
);
return { service, fs, statePath, commands, control };
return { service, fs, statePath, commands, timeouts, control };
});

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

expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
Expand All@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
expect(yield* service.uninstall).toBe(true);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
// The stop can block up to systemd's 90s TimeoutStopSec; the runner's
// 60s default would cancel it mid-shutdown.
expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

Expand DownExpand Up@@ -195,11 +250,95 @@ 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, timeouts } = 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);
// A bootout can block up to the plist's 90s ExitTimeOut; the runner's
// 60s default would cancel it and let bootstrap race a loaded job.
expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
Duration.seconds(120),
);
}),
);

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 --wait 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 --wait 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 --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plistPath}`,
]);
}),
);
});
Loading
Loading