From 7a5811b64fb3cb11901c5e45a23005c0a2e06c2b Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:03:19 -0800 Subject: [PATCH 1/3] test(cli): t3 connect and t3 service name next steps with --base-dir --- apps/server/src/cli/connect.test.ts | 36 ++++++++++++++++++++++ apps/server/src/cli/service.test.ts | 46 ++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index a524ab8ddea5..c31a5f5058c2 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; @@ -21,6 +22,7 @@ import { persistServerRuntimeState, } from "../serverRuntimeState.ts"; import { acquireRelayClientForLink } from "./connect.ts"; +import { resolveHostCliCommand } from "./invocation.ts"; const managedExecutable = { status: "available", @@ -58,6 +60,40 @@ it.effect("does not install the relay client when the user declines the managed }), ); +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer))( + "t3 connect next steps", + (it) => { + it.effect.each([ + { subcommand: "status", hint: "connect link" }, + { subcommand: "publish", hint: "connect login" }, + ])("t3 connect $subcommand names `t3 $hint` with --base-dir", ({ subcommand, hint }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-connect-hint-test-" }); + + yield* Command.runWith(makeCli({ cloudEnabled: true }), { version: "0.0.0" })([ + "connect", + subcommand, + "--base-dir", + baseDir, + ]); + + const { command } = yield* resolveHostCliCommand(hint, ["--base-dir", baseDir]); + assert.include((yield* TestConsole.logLines).join("\n"), `\`${command}\``); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.Environment, {}), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ), + ); + }, +); + it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer))( "t3 connect unlink", (it) => { diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 7ab52e82a399..8ceac3a2266c 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -12,6 +12,7 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import { resolveHostCliCommand } from "./invocation.ts"; import { serviceCommand } from "./service.ts"; afterEach(() => vi.restoreAllMocks()); @@ -113,11 +114,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands Layer.succeed(BootService.BootService, service), ); - yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + const [lines, up] = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ "install", "--base-dir", baseDir, ]).pipe( + Effect.andThen( + Effect.all([TestConsole.logLines, resolveHostCliCommand("up", ["--base-dir", baseDir])]), + ), Effect.provideService(HostProcess.Environment, {}), Effect.provide( Layer.mergeAll( @@ -127,6 +131,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands ), ); expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe(input.expected); + expect(lines.map(String).filter((line) => line.includes("other devices"))).toEqual( + input.installed ? [] : [`Run \`${up.command}\` to choose how your other devices reach it.`], + ); }), ); @@ -199,6 +206,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect.each([ + { + label: "a missing service", + status: { ...status, installed: false }, + hint: "service install", + args: [], + }, + { label: "a newer service", status: newerServiceStatus, hint: "update", args: ["999.0.0"] }, + ])("status of $label names its next step with --base-dir", (input) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, makeTestService(input.status).service), + ); + + const [lines, next] = yield* Command.runWith(serviceCommand, { + version: packageJson.version, + })(["status", "--base-dir", baseDir]).pipe( + Effect.andThen( + Effect.all([ + TestConsole.logLines, + resolveHostCliCommand(input.hint, ["--base-dir", baseDir, ...input.args]), + ]), + ), + Effect.provideService(HostProcess.Environment, {}), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + expect(lines.join("\n")).toContain(`Next: Run \`${next.command}\``); + }), + ); + it.effect.each([ { label: "this home's service", otherHome: false, unreadable: false, removed: [true] }, { label: "another home's service", otherHome: true, unreadable: false, removed: [] }, From d637d2b1fa36a3341be942f078fe433bb58ddbe8 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:04:59 -0800 Subject: [PATCH 2/3] fix(cli): t3 connect and t3 service hints keep --base-dir `t3 connect status`, `link` and `publish` printed their next steps as bare `t3 connect link`, `t3 service status`, `t3 serve` and `t3 connect login`, and `t3 service status` and `install` printed bare `t3 service install`, `t3 update ` and `t3 up`. Run with `--base-dir `, each printed command then acted on the default home instead of , so following "Run `t3 service install`" installed a service for ~/.t3. A new `resolveHostCliHint` next to `resolveHostCliCommand` puts the caller's `--base-dir` first, as `t3 up` already does, and both commands build their hints through it. They also print the runner or launcher form when that is how the CLI ran. Without `--base-dir` and run as plain `t3`, the text is unchanged. Bug: t3 connect's next-step hints drop --base-dir Bug: t3 service's next-step hints drop --base-dir --- apps/server/src/cli/connect.ts | 27 ++++++++++++++++++--------- apps/server/src/cli/invocation.ts | 11 +++++++++++ apps/server/src/cli/service.ts | 20 ++++++++++++++------ 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 5145429e304d..cf66cb6aab6a 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -52,7 +52,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; -import { resolveCliCommand } from "./invocation.ts"; +import { resolveHostCliHint } from "./invocation.ts"; import * as CliService from "./service.ts"; const jsonFlag = Flag.Boolean("json").pipe( @@ -180,7 +180,11 @@ function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): Rea } } -function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { +function formatCloudStatus( + status: CloudCliStatus, + commands: { readonly link: string; readonly serviceStatus: string }, + options?: { readonly json?: boolean }, +): string { if (options?.json) { return JSON.stringify(status, null, 2); } @@ -193,11 +197,11 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b ? "pending server startup" : "not provisioned"; const nextStep = !status.authenticated - ? "Run `t3 connect link` to authorize and enable T3 Connect." + ? `Run \`${commands.link}\` to authorize and enable T3 Connect.` : status.revoked - ? "Run `t3 connect link` to link this environment again." + ? `Run \`${commands.link}\` to link this environment again.` : !status.desired - ? "Run `t3 connect link` to enable T3 Connect." + ? `Run \`${commands.link}\` to enable T3 Connect.` : !status.linked ? "Start T3 to provision the environment link and launch its managed tunnel." : undefined; @@ -215,7 +219,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b : status.reachability === null ? [ "", - "No running server answered, so reachability was not checked. Check the background service with `t3 service status`.", + `No running server answered, so reachability was not checked. Check the background service with \`${commands.serviceStatus}\`.`, ] : status.reachability.status === "reachable" ? [ @@ -655,7 +659,7 @@ const connectLinkCommand = Command.make("link", { yield* Console.log("T3 Connect\n"); const linked = yield* linkEnvironmentForConnect(flags); if (linked) { - const serveCommand = yield* resolveCliCommand("serve"); + const serveCommand = yield* resolveHostCliHint("serve", flags.baseDir); yield* Console.log( flags.publishOnly ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).` @@ -720,7 +724,11 @@ const connectStatusCommand = Command.make("status", { linkMode, reachability, }; - yield* Console.log(formatCloudStatus(status, { json: flags.json })); + const commands = { + link: yield* resolveHostCliHint("connect link", flags.baseDir), + serviceStatus: yield* resolveHostCliHint("service status", flags.baseDir), + }; + yield* Console.log(formatCloudStatus(status, commands, { json: flags.json })); }), { quietLogs: flags.json, @@ -770,8 +778,9 @@ const connectPublishCommand = Command.make("publish", { } if (!(yield* tokens.hasCredential)) { + const loginCommand = yield* resolveHostCliHint("connect login", flags.baseDir); yield* Console.log( - "Run `t3 connect login` first so this environment can be authorized to publish.", + `Run \`${loginCommand}\` first so this environment can be authorized to publish.`, ); return; } diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 283da3d041d2..4e6a7e46c45a 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -173,6 +173,17 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand return { command: withArgs(`${call} ${subcommand}`), launcher: true }; }); +/** A hint's command, run against the caller's `--base-dir` when one was given. */ +export const resolveHostCliHint = ( + subcommand: string, + baseDir: Option.Option, + args: ReadonlyArray = [], +) => + resolveHostCliCommand(subcommand, [ + ...Option.match(baseDir, { onNone: () => [], onSome: (dir) => ["--base-dir", dir] }), + ...args, + ]).pipe(Effect.map(({ command }) => command)); + /** * `sudo` resets PATH on most distributions, which drops a user-installed Node * (nvm, fnm, a tarball) and with it `npx` or a global `t3`, so the command diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 4ba17aa17c6d..e047dbd18b0f 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -16,7 +16,7 @@ import { compareExactServiceVersions, SERVICE_ENV_FILE } from "../cloud/serviceP import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; -import { resolveEntryPath } from "./invocation.ts"; +import { resolveEntryPath, resolveHostCliHint } from "./invocation.ts"; export const layer = (config: ServerConfig.ServerConfig["Service"]) => BootService.layer({ @@ -144,13 +144,14 @@ function formatProtection( function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, + commands: { readonly install: string; readonly update: string }, protection?: ServiceProtection.ServiceProtectionState, ): string { if (!status.supported) { 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`."; + return `T3 Code service\n Status: not installed\n Next: Run \`${commands.install}\`.`; } const installedVersion = status.installedVersion ?? cliVersion; const problems = (status.problems ?? []).map( @@ -168,7 +169,7 @@ function formatServiceStatus( ` Logs: ${status.logPath}`, ...formatProtection(protection), ...problems, - ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, + ` Next: Run \`${commands.update}\` to match it, or pass \`--allow-downgrade\` to \`${commands.install}\` explicitly.`, ].join("\n"); } return [ @@ -178,7 +179,7 @@ function formatServiceStatus( ` Logs: ${status.logPath}`, ...formatProtection(protection), ...problems, - ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), + ...(status.current ? [] : [` Next: Run \`${commands.install}\` to repair it.`]), ].join("\n"); } @@ -216,7 +217,8 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip } yield* Console.log(formatServiceChange(result)); if (!result.previouslyInstalled) { - yield* Console.log("Run `t3 up` to choose how your other devices reach it."); + const upCommand = yield* resolveHostCliHint("up", flags.baseDir); + yield* Console.log(`Run \`${upCommand}\` to choose how your other devices reach it.`); } }), ), @@ -318,7 +320,13 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( Effect.provide(ProcessRunner.layer), ) : undefined; - yield* Console.log(formatServiceStatus(status, packageJson.version, protection)); + const commands = { + install: yield* resolveHostCliHint("service install", flags.baseDir), + update: yield* resolveHostCliHint("update", flags.baseDir, [ + status.installedVersion ?? packageJson.version, + ]), + }; + yield* Console.log(formatServiceStatus(status, packageJson.version, commands, protection)); }), ), ), From 71e6999f252a996be1f32efc8586031f95d3d021 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 04:00:31 +0000 Subject: [PATCH 3/3] fix(cli): t3 service status problem lines keep --base-dir The service-disabled, service-stopped and restart-pending lines named bare `t3 service install` and `t3 service restart`, contradicting the Next line for a custom home. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/service.test.ts | 14 +++++++++++++- apps/server/src/cli/service.ts | 5 +++-- apps/server/src/cloud/bootService.ts | 14 ++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 157cf418ddce..ada548ba2bf3 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -236,6 +236,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands args: [], }, { label: "a newer service", status: newerServiceStatus, hint: "update", args: ["999.0.0"] }, + { + label: "a stopped service", + status: { ...status, problems: ["service-stopped"] as const }, + hint: "service install", + args: [], + }, + { + label: "a restart-pending service", + status: { ...status, problems: ["restart-pending"] as const }, + hint: "service restart", + args: [], + }, ])("status of $label names its next step with --base-dir", (input) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -261,7 +273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands ), ), ); - expect(lines.join("\n")).toContain(`Next: Run \`${next.command}\``); + expect(lines.join("\n")).toContain(`\`${next.command}\``); }), ); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index badf417f8e17..4902c47d0617 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -174,7 +174,7 @@ function formatProtection( function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, - commands: { readonly install: string; readonly update: string }, + commands: { readonly install: string; readonly restart: string; readonly update: string }, protection?: ServiceProtection.ServiceProtectionState, ): string { if (!status.supported) { @@ -193,7 +193,7 @@ function formatServiceStatus( } const installedVersion = status.installedVersion ?? cliVersion; const problems = (status.problems ?? []).map( - (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, + (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem, commands)}`, ); if ( !status.current && @@ -362,6 +362,7 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( : undefined; const commands = { install: yield* resolveHostCliHint("service install", flags.baseDir), + restart: yield* resolveHostCliHint("service restart", flags.baseDir), update: yield* resolveHostCliHint("update", flags.baseDir, [ status.installedVersion ?? packageJson.version, ]), diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0a884948a97a..0f94aaad95ba 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -652,7 +652,13 @@ const BootServiceProblem = Schema.Literals([ ]); type BootServiceProblem = typeof BootServiceProblem.Type; -export function formatBootServiceProblem(problem: BootServiceProblem): string { +export function formatBootServiceProblem( + problem: BootServiceProblem, + commands: { readonly install: string; readonly restart: string } = { + install: "t3 service install", + restart: "t3 service restart", + }, +): string { switch (problem) { case "user-manager-unavailable": return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo."; @@ -661,11 +667,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { case "linger-disabled": return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.'; case "service-disabled": - return "The service is not enabled to start automatically. Run `t3 service install` to repair it."; + return `The service is not enabled to start automatically. Run \`${commands.install}\` to repair it.`; case "service-stopped": - return "The service is not running. Check the service log (on Linux, also `systemctl --user status t3code.service`), then run `t3 service install`."; + return `The service is not running. Check the service log (on Linux, also \`systemctl --user status t3code.service\`), then run \`${commands.install}\`.`; case "restart-pending": - return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch."; + return `A newer version is installed but the service is still running the previous one. Run \`${commands.restart}\` to switch.`; } }