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
36 changes: 36 additions & 0 deletions apps/server/src/cli/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +23,7 @@ import {
persistServerRuntimeState,
} from "../serverRuntimeState.ts";
import { acquireRelayClientForLink } from "./connect.ts";
import { resolveHostCliCommand } from "./invocation.ts";

const managedExecutable = {
status: "available",
Expand Down Expand Up @@ -59,6 +61,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 and status",
(it) => {
Expand Down
27 changes: 18 additions & 9 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
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(
Expand Down Expand Up @@ -184,7 +184,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);
}
Expand All @@ -198,11 +202,11 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
: "not provisioned";
const publishOnly = status.desired && status.linkMode === "publish_only";
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
? publishOnly
? "Start T3 to provision the environment link."
Expand All @@ -227,7 +231,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"
? [
Expand Down Expand Up @@ -667,7 +671,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).`
Expand Down Expand Up @@ -732,7 +736,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,
Expand Down Expand Up @@ -782,8 +790,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;
}
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/cli/invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,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<string>,
args: ReadonlyArray<string> = [],
) =>
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
Expand Down
58 changes: 57 additions & 1 deletion apps/server/src/cli/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -135,11 +136,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(
Expand All @@ -149,6 +153,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.`],
);
}),
);

Expand Down Expand Up @@ -221,6 +228,55 @@ 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"] },
{
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;
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.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: [] },
Expand Down
23 changes: 16 additions & 7 deletions apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -174,13 +174,14 @@ function formatProtection(
function formatServiceStatus(
status: BootService.BootServiceStatus,
cliVersion: string,
commands: { readonly install: string; readonly restart: 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, Windows with Task Scheduler";
}
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}\`.`;
}
if (status.otherBaseDir !== undefined) {
return [
Expand All @@ -192,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 &&
Expand All @@ -206,7 +207,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 [
Expand All @@ -216,7 +217,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");
}

Expand Down Expand Up @@ -254,7 +255,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.`);
}
}),
),
Expand Down Expand Up @@ -358,7 +360,14 @@ 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),
restart: yield* resolveHostCliHint("service restart", flags.baseDir),
update: yield* resolveHostCliHint("update", flags.baseDir, [
status.installedVersion ?? packageJson.version,
]),
};
yield* Console.log(formatServiceStatus(status, packageJson.version, commands, protection));
}),
),
),
Expand Down
14 changes: 10 additions & 4 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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.`;
}
}

Expand Down
Loading