Skip to content
Open
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
11 changes: 8 additions & 3 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RelayClientInstallProgressStage,
} from "@t3tools/contracts";
import { RelayOkResponse } from "@t3tools/contracts/relay";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as RelayClient from "@t3tools/shared/relayClient";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -694,9 +695,13 @@ 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) {
yield* Console.log(
"\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
);
// Windows starts the service at sign-in and stops it at sign-out, so
// the Linux promise would contradict the prompt the user just agreed to.
const reach =
(yield* HostProcessPlatform) === "win32"
? "T3 Code will start again every time you sign in to Windows."
: "T3 Code will stay reachable after you log out.";
yield* Console.log(`\n✓ Background service ready\n\n${reach}`);
return;
}
const serveCommand = yield* resolveCliCommand("serve");
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/cli/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ const status = {
supported: true,
installed: true,
current: true,
kind: "systemd",
unitPath: "/home/me/.config/systemd/user/t3code.service",
logPath: "/home/me/.t3/userdata/logs/boot-service.log",
} as const;

const windowsStatus = {
...status,
kind: "win32-startup-shortcut",
unitPath:
"C:\\Users\\me\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\T3 Code Server.lnk",
} as const;

it("reports the installed service version and host paths", () => {
assert.equal(
formatServiceStatus(status, "0.0.29"),
Expand All @@ -32,6 +40,12 @@ it("gives a direct repair command for a stale service", () => {
it("explains service availability without systemd", () => {
assert.include(
formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
"Supported on: Linux with systemd",
"Supported on: Linux with systemd, or Windows",
);
});

it("calls the Windows definition a shortcut, not a unit", () => {
const output = formatServiceStatus(windowsStatus, "0.0.29");
assert.include(output, ` Shortcut: ${windowsStatus.unitPath}`);
assert.notInclude(output, "Unit:");
});
83 changes: 62 additions & 21 deletions apps/server/src/cli/service.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

export const offerServiceDuringOnboarding = Effect.gen(function* () {

On Windows, offerServiceDuringOnboarding returns true after a successful install/update, so the connectCommand caller prints "T3 Code will stay reachable after you log out." — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/cli/service.ts around line 173:

On Windows, `offerServiceDuringOnboarding` returns `true` after a successful install/update, so the `connectCommand` caller prints `"T3 Code will stay reachable after you log out."` — but the new Windows prompt explicitly states the service stops at sign-out. Every successful Windows onboarding therefore shows contradictory and incorrect availability guidance. Consider returning platform-aware state or making the caller's success message platform-specific.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dd3bebe, in the caller rather than the return value.

connectCommand now picks the message by platform: Windows gets "T3 Code will start again every time you sign in to Windows." and Linux keeps "T3 Code will stay reachable after you log out." See apps/server/src/cli/connect.ts:697-704.

I kept the boolean return as-is, since it means "background setup is in place", which is true on both platforms. Only the availability sentence differed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -10,12 +11,23 @@ import type * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";

export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) =>
BootService.layer({
export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => {
const input = {
baseDir: config.baseDir,
logsDir: config.logsDir,
cliVersion: packageJson.version,
}).pipe(Layer.provide(ProcessRunner.layer));
};
// Windows has no systemd, so it gets its own backend. Loading it lazily keeps
// the Linux path free of any Windows import.
return Layer.unwrap(
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
if (platform !== "win32") return BootService.layer(input);
const windows = yield* Effect.promise(() => import("../cloud/bootServiceWindows.ts"));
return windows.layer(input);
}),
).pipe(Layer.provide(ProcessRunner.layer));
};

export type ServiceReconcileResult =
| {
Expand Down Expand Up @@ -48,15 +60,15 @@ 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, or Windows";
}
if (!status.installed) {
return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
}
return [
"T3 Code service",
` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,
` Unit: ${status.unitPath}`,
` ${status.kind === "systemd" ? "Unit" : "Shortcut"}: ${status.unitPath}`,
` Logs: ${status.logPath}`,
...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
].join("\n");
Expand All @@ -71,21 +83,40 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
return yield* run.pipe(Effect.provide(bootServiceLayer(config)));
});

/** Windows only. The blink at sign-in looks alarming until you know what it is. */
const WINDOWS_SERVICE_NOTICE = [
'A small window named "T3 Code Server" blinks once when you sign in. That is expected.',
"It appears under Startup apps in Windows Settings, where you can switch it off.",
"It starts at sign-in, not at boot, and it stops when you sign out.",
].join("\n");

/** Shared by install and update. The Linux output is unchanged. */
const reportReconcileResult = Effect.fn("cli.service.report")(function* (
result: ServiceReconcileResult,
unchangedMessage: string,
) {
if (!result.changed) {
yield* Console.log(unchangedMessage);
return;
}
yield* Console.log(
`${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,
);
if ((yield* HostProcessPlatform) === "win32") {
yield* Console.log(WINDOWS_SERVICE_NOTICE);
}
});

const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe(
Command.withDescription("Install T3 Code as a background service for this user."),
Command.withHandler((flags) =>
runServiceCommand(
flags,
Effect.gen(function* () {
const result = yield* reconcileService();
if (!result.changed) {
yield* Console.log(
`T3 Code service is already installed with t3@${packageJson.version}.`,
);
return;
}
yield* Console.log(
`${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,
yield* reportReconcileResult(
result,
`T3 Code service is already installed with t3@${packageJson.version}.`,
);
}),
),
Expand All @@ -101,12 +132,9 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(
flags,
Effect.gen(function* () {
const result = yield* reconcileService();
if (!result.changed) {
yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
return;
}
yield* Console.log(
`${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`,
yield* reportReconcileResult(
result,
`T3 Code service is already using t3@${packageJson.version}.`,
);
}),
),
Expand Down Expand Up @@ -152,12 +180,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;
}
// Windows starts the service at sign-in, not at boot, and it stops at
// sign-out. Promising otherwise here would be a lie.
const windows = (yield* HostProcessPlatform) === "win32";
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.",
: windows
? "Run T3 Code in the background whenever you sign in to Windows? " +
"It starts again after every reboot, and stops when you sign out."
: "Run T3 Code in the background whenever this machine boots? " +
"It stays reachable through T3 Connect even after you log out.",
initial: true,
}),
);
Expand All @@ -169,6 +203,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () {
yield* Console.log(
`Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`,
);
if (windows) yield* Console.log(WINDOWS_SERVICE_NOTICE);
}
return true;
});
Expand All @@ -187,6 +222,12 @@ export const recoverServiceOnboardingOffer = <R>(
Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
BootServiceUpdatePendingError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
// Both carry guidance the user has to act on, so print the message itself
// rather than the generic "did not finish" wrapper.
BootServicePathHasPercentError: (error) =>
Console.warn(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
BootServiceStartupEntryDisabledError: (error) =>
Console.warn(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
}),
);

Expand Down
67 changes: 59 additions & 8 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootSer
{ platform: Schema.String },
) {
override get message(): string {
return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`;
return `Background setup supports Linux with systemd and Windows; this machine reports '${this.platform}'.`;
}
}

Expand All @@ -99,13 +99,20 @@ export class BootServiceCommandError extends Schema.TaggedErrorClass<BootService
exitCode: Schema.optional(Schema.Number),
stdoutLength: Schema.optional(Schema.Number),
stderrLength: Schema.optional(Schema.Number),
/** Windows only. The launcher that would not stop, and how long we waited. */
pid: Schema.optional(Schema.Number),
timeoutSeconds: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
const detail =
this.pid === undefined || this.timeoutSeconds === undefined
? ""
: ` (process ${this.pid} did not exit within ${this.timeoutSeconds}s)`;
return this.exitCode === undefined
? `Background setup failed while ${this.step}.`
: `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
? `Background setup failed while ${this.step}${detail}.`
: `Background setup failed while ${this.step}${detail} (exit code ${this.exitCode}).`;
}
}

Expand All @@ -127,16 +134,60 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootS
}
}

/**
* Windows only. The command shell expands `%VAR%` on its command line, so a path
* holding one would be rewritten before the service could start. This carries
* which path is at fault, because the generic install error hides its cause and
* the user has to know what to move.
*/
export class BootServicePathHasPercentError extends Schema.TaggedErrorClass<BootServicePathHasPercentError>()(
"BootServicePathHasPercentError",
{ pathLabel: Schema.String },
) {
override get message(): string {
// The label carries what to change. Naming T3CODE_HOME here would be wrong
// advice for the Node executable, which that variable cannot move.
return (
`The path to ${this.pathLabel} contains a percent sign, and the Windows command ` +
"shell would rewrite it before the service could start. Move it to a path without " +
"one, then run this again."
);
}
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

/** Windows only. The Startup entry exists but Windows Settings has it switched off. */
export class BootServiceStartupEntryDisabledError extends Schema.TaggedErrorClass<BootServiceStartupEntryDisabledError>()(
"BootServiceStartupEntryDisabledError",
{ shortcutName: Schema.String },
) {
override get message(): string {
return (
`"${this.shortcutName}" is switched off under Startup apps in Windows Settings. ` +
"Turn it back on, then run this again."
);
}
}

export type BootServiceError =
| BootServiceUnsupportedError
| BootServiceCommandError
| BootServiceInstallError
| BootServiceUpdatePendingError;
| BootServiceUpdatePendingError
| BootServicePathHasPercentError
| BootServiceStartupEntryDisabledError;

/**
* Which backend answered. The name carries the platform on purpose: a bare
* "shortcut" would read as a macOS or Linux desktop entry just as easily.
*/
export type BootServiceKind = "systemd" | "win32-startup-shortcut";

export interface BootServiceStatus {
readonly supported: boolean;
readonly installed: boolean;
readonly current: boolean;
readonly kind: BootServiceKind;
/** The systemd unit on Linux, the Startup folder shortcut on Windows. */
readonly unitPath: string;
readonly logPath: string;
}
Expand Down Expand Up @@ -382,11 +433,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
}).pipe(Effect.withSpan("cloud.boot_service.uninstall"));

const status: BootService["Service"]["status"] = Effect.gen(function* () {
const base = { kind: "systemd", unitPath, logPath } as const;
if (platform !== "linux" || homeDir === "") {
return { supported: false, installed: false, current: false, unitPath, logPath };
return { supported: false, installed: false, current: false, ...base };
}
if (!(yield* fs.exists(unitPath))) {
return { supported: true, installed: false, current: false, unitPath, logPath };
return { supported: true, installed: false, current: false, ...base };
}
const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] =
yield* Effect.all([
Expand All @@ -408,8 +460,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
runtimeSentinel.value.trim() === input.cliVersion &&
state?.activeVersion === input.cliVersion &&
state?.update?.status !== "pending",
unitPath,
logPath,
...base,
};
}).pipe(
Effect.mapError((cause) => new BootServiceInstallError({ cause })),
Expand Down
Loading
Loading