Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
# Get this from your relay deployment. `infra/relay` deploys update it automatically.
# T3CODE_RELAY_URL=https://relay.example.com

# Optional: hosted app origin used by the CLI paste-code connect flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes

# Public, ingest-only mobile OpenTelemetry configuration.
# T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
Expand Down
257 changes: 257 additions & 0 deletions .plans/t3-connect-remote-setup.html

Large diffs are not rendered by default.

237 changes: 200 additions & 37 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import {
type RelayClientInstallProgressStage,
} from "@t3tools/contracts";
import { RelayOkResponse } from "@t3tools/contracts/relay";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import * as RelayClient from "@t3tools/shared/relayClient";
import * as Terminal from "effect/Terminal";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import * as Cause from "effect/Cause";
import * as Console from "effect/Console";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
import {
FetchHttpClient,
Expand All @@ -25,8 +29,10 @@ import {
} from "effect/unstable/http";
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";

import packageJson from "../../package.json" with { type: "json" };
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as BootService from "../cloud/bootService.ts";
import * as CliState from "../cloud/CliState.ts";
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
import {
Expand All @@ -46,6 +52,66 @@ const jsonFlag = Flag.boolean("json").pipe(
Flag.withDefault(false),
);

const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);

const pasteFlag = Flag.boolean("paste").pipe(
Flag.withDescription(
"Authorize by pasting a code from the hosted app instead of a local browser callback.",
),
Flag.withDefault(false),
);

/**
* Inside an SSH session there is no local browser to complete the loopback
* OAuth callback, so the paste-code flow is the only one that can work.
*/
const detectHeadlessSession = Effect.map(
HostProcessEnvironment,
(env) => env.SSH_CONNECTION !== undefined || env.SSH_TTY !== undefined,
);

const promptForPastedCode = ({ authorizeUrl, validate }: CliTokenManager.PasteCodePromptInput) =>
Console.log(`To set up T3 Connect, open this URL and sign in:\n ${authorizeUrl}\n`).pipe(
Effect.andThen(
Prompt.run(Prompt.text({ message: "Paste your authentication code here", validate })),
),
);

/** Returns the connected account identity, if the flow could determine one. */
const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
readonly paste: boolean;
}) {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
const paste = options.paste || (yield* detectHeadlessSession);
if (!paste) {
yield* tokens.get;
return null;
}
// A stored credential whose refresh fails (revoked, expired grant) must
// fall through to a fresh paste login, not dead-end the command.
const existing = yield* tokens.getExisting.pipe(
Effect.catchTag("CloudCliCredentialRefreshError", () =>
Console.log(
"The stored T3 Connect credential could not be refreshed; signing in again.",
).pipe(Effect.as(Option.none())),
),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
);
if (Option.isSome(existing)) {
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
const { token, identity } = yield* CliTokenManager.pasteCodeLogin(promptForPastedCode).pipe(
Effect.mapError((cause) =>
// Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
// cancels quietly instead of dumping an authorization error.
Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
? cause
: new CliTokenManager.CloudCliAuthorizationError({ cause }),
),
);
yield* tokens.store(token);
return identity;
});

function bytesToString(value: Uint8Array): string {
return new TextDecoder().decode(value);
}
Expand Down Expand Up @@ -313,6 +379,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
if (options.clearAuthorization) {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.clear;

// uninstall itself no-ops when nothing is installed (and on non-Linux),
// so no status pre-check that could mask a real removal failure.
const bootService = yield* BootService.BootService;
yield* bootService.uninstall.pipe(
Effect.tap((removed) =>
removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
),
Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Effect.catch((error) =>
Console.warn(`Could not remove the background service: ${error.message}`).pipe(
Effect.as(false),
),
),
);
}

yield* reportCloudDisconnectResults({
Expand All @@ -335,6 +416,8 @@ const runCloudCommand = <A, E>(
| CliTokenManager.CloudCliTokenManager
| RelayClient.RelayClient
| EnvironmentAuth.EnvironmentAuth
| BootService.BootService
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Prompt.Environment
Expand All @@ -355,6 +438,11 @@ const runCloudCommand = <A, E>(
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
EnvironmentAuth.runtimeLayer,
ServerEnvironment.layer,
BootService.layer({
baseDir: config.baseDir,
logsDir: config.logsDir,
cliVersion: packageJson.version,
}),
headlessRelayClientTracingLayer,
).pipe(
Layer.provideMerge(FetchHttpClient.layer),
Expand All @@ -364,24 +452,57 @@ const runCloudCommand = <A, E>(
return yield* run.pipe(Effect.provide(runtimeLayer));
});

const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");

const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
readonly paste: boolean;
readonly publishOnly?: boolean;
}) {
const publishOnly = options.publishOnly ?? false;
if (!publishOnly) {
const relayClient = yield* RelayClient.RelayClient;
const installed = yield* acquireRelayClientForLink(
relayClient,
confirmRelayClientInstall,
reportRelayClientInstallProgress,
);
if (Option.isNone(installed)) {
yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
return null;
}
yield* Console.log(
`Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
);
}

const identity = yield* authorizeCli(options);
yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
if (publishOnly) {
const secrets = yield* ServerSecretStore.ServerSecretStore;
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
}
return { identity } as const;
});

const connectLoginCommand = Command.make("login", {
...projectLocationFlags,
paste: pasteFlag,
}).pipe(
Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Effect.gen(function* () {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.get;
yield* Console.log("Signed in to T3 Connect.");
const identity = yield* authorizeCli(flags);
yield* Console.log(`Signed in to T3 Connect${connectedAs(identity)}.`);
}),
),
),
);

const connectLinkCommand = Command.make("link", {
...projectLocationFlags,
paste: pasteFlag,
publishOnly: Flag.boolean("publish-only").pipe(
Flag.withDescription(
"Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
Expand All @@ -394,41 +515,14 @@ const connectLinkCommand = Command.make("link", {
runCloudCommand(
flags,
Effect.gen(function* () {
// A publish-only link needs no Cloudflare tunnel, so skip installing the
// relay client entirely.
if (!flags.publishOnly) {
const relayClient = yield* RelayClient.RelayClient;
const installed = yield* acquireRelayClientForLink(
relayClient,
confirmRelayClientInstall,
reportRelayClientInstallProgress,
);
if (Option.isNone(installed)) {
yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
return;
}
const linked = yield* linkEnvironmentForConnect(flags);
if (linked) {
yield* Console.log(
`Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
flags.publishOnly
? `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel).`
: `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will be available the next time T3 starts.`,
);
}

const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.get;
yield* CliState.setCliDesiredCloudLink(
true,
flags.publishOnly ? "publish_only" : "managed",
);
if (flags.publishOnly) {
// A publish-only link exists solely to publish; without the publish
// flag the link would be inert and the success message a lie.
const secrets = yield* ServerSecretStore.ServerSecretStore;
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
}
yield* Console.log(
flags.publishOnly
? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
: "This T3 environment will be available through T3 Connect the next time T3 starts.",
);
}),
),
),
Expand Down Expand Up @@ -566,8 +660,77 @@ const connectLogoutCommand = Command.make("logout", {
),
);

export const connectCommand = Command.make("connect").pipe(
Command.withDescription("Manage headless T3 Connect access."),
const offerBootService = Effect.gen(function* () {
const bootService = yield* BootService.BootService;
const { supported, installed, current } = yield* bootService.status;
if (!supported) {
// Don't prompt for something that can only fail; background setup is
// Linux/systemd-only for now.
return false;
}
if (installed && current) {
yield* Console.log("T3 Code is already set up to run in the background on this machine.");
return true;
}
const wanted = yield* Prompt.run(
Prompt.confirm({
message: installed
? "The installed T3 Code background service is from an older setup. Update it now?"
: "Run T3 Code in the background whenever this machine boots? " +
"It stays reachable through T3 Connect even after you log out.",
initial: true,
}),
);
if (!wanted) {
return false;
}
const plan = yield* bootService.install;
yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
return true;
});

export const connectCommand = Command.make("connect", {
...projectLocationFlags,
paste: pasteFlag,
}).pipe(
Command.withDescription("Set up T3 Connect for this machine."),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Effect.gen(function* () {
const linked = yield* linkEnvironmentForConnect(flags);
if (!linked) {
return;
}
// Show which account was linked so an unexpected identity (a pasted
// code that authorized a different account) is visible before the
// machine is brought online.
yield* Console.log(`\nConnected${connectedAs(linked.identity)}!`);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

// Connect itself already succeeded; a boot-service failure must not
// fail the command, just tell the user what happened and move on.
const background = yield* offerBootService.pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Effect.catchTags({
BootServiceUnsupportedError: (error) =>
Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
BootServiceCommandError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(
Effect.as(false),
),
BootServiceInstallError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(
Effect.as(false),
),
}),
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
yield* Console.log(
background
? "\nGreat, T3 Code is now set up and ready to go."
: "\nT3 Connect is set up. Start the server with `t3 serve` to make this machine reachable.",
);
}),
),
),
Command.withSubcommands([
connectLoginCommand,
connectLinkCommand,
Expand Down
Loading
Loading