Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Optional: T3 Cloud source builds
# Leave these unset to disable optional T3 Cloud features in local source builds.
# Optional: T3 Connect source builds
# Leave these unset to disable optional T3 Connect features in local source builds.
# Release builds inject their public values at build time. Do not add server-side
# secrets to this file.

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-relay.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Deploy T3 Cloud relay
name: Deploy T3 Connect relay

on:
push:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ jobs:
--github-output

relay_public_config:
name: Resolve T3 Cloud public config
name: Resolve T3 Connect public config
needs: preflight
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe("DesktopWindow", () => {
assert.isTrue(
DesktopWindow.isSameOriginRendererNavigation({
applicationUrl: "http://127.0.0.1:3773/",
navigationUrl: "http://127.0.0.1:3773/settings/cloud",
navigationUrl: "http://127.0.0.1:3773/settings/connections",
}),
);
assert.isFalse(
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This app has three variants:

Run commands from `apps/mobile`.

T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the
T3 Connect is optional and disabled in a fresh clone. Public configuration belongs in the
repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See
[`../../.env.example`](../../.env.example).

Expand Down
65 changes: 41 additions & 24 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}

const cloudCli = makeCli({ cloudEnabled: true });
const noCloudCli = makeCli({ cloudEnabled: false });
const connectCli = makeCli({ cloudEnabled: true });
const noConnectCli = makeCli({ cloudEnabled: false });
const runCli = (args: ReadonlyArray<string>, command = cli) =>
Command.runWith(command, { version: "0.0.0" })(args);
const runCloudCli = (args: ReadonlyArray<string>) => runCli(args, cloudCli);
const runConnectCli = (args: ReadonlyArray<string>) => runCli(args, connectCli);
const runCliWithRuntime = (args: ReadonlyArray<string>) =>
runCli(args).pipe(Effect.provide(CliRuntimeLayer));

Expand Down Expand Up @@ -179,30 +179,45 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
}),
);

it.effect("rejects cloud commands when public cloud configuration is missing", () =>
it.effect("rejects connect commands when public configuration is missing", () =>
Effect.gen(function* () {
const error = yield* runCli(["cloud", "status"], noCloudCli).pipe(Effect.flip);
const error = yield* runCli(["connect", "status"], noConnectCli).pipe(Effect.flip);

if (!CliError.isCliError(error)) {
assert.fail(`Expected CliError, got ${String(error)}`);
}
if (error._tag !== "ShowHelp") {
assert.fail(`Expected ShowHelp, got ${error._tag}`);
}
assert.deepEqual(error.commandPath, ["t3", "cloud"]);
assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration");
assert.deepEqual(error.commandPath, ["t3", "connect"]);
assert.include(error.errors[0]?.message ?? "", "missing T3 Connect public configuration");

const output = (yield* TestConsole.errorLines).join("\n");
assert.include(output, "ERROR");
assert.include(output, "missing T3 Cloud public configuration");
assert.include(output, "missing T3 Connect public configuration");
}).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))),
);

it.effect("reports fresh headless cloud state without requiring local configuration", () =>
it.effect("rejects the removed cloud command group", () =>
Effect.gen(function* () {
const error = yield* runCli(["cloud", "status"], connectCli).pipe(Effect.flip);

if (!CliError.isCliError(error)) {
assert.fail(`Expected CliError, got ${String(error)}`);
}
if (error._tag !== "ShowHelp") {
assert.fail(`Expected ShowHelp, got ${error._tag}`);
}
assert.deepEqual(error.commandPath, ["t3", "cloud"]);
assert.include(error.errors[0]?.message ?? "", "command was removed");
}).pipe(Effect.provide(CliRuntimeLayer)),
);

it.effect("reports fresh headless connect state without requiring local configuration", () =>
Effect.gen(function* () {
const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-"));
const { output } = yield* captureStdout(
runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),
runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
);
// @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
const status = JSON.parse(output) as {
Expand All @@ -221,21 +236,21 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
}),
);

it.effect("reports actionable human-readable headless cloud state", () =>
it.effect("reports actionable human-readable headless connect state", () =>
Effect.gen(function* () {
const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-"));
const { output } = yield* captureStdout(
runCloudCli(["cloud", "status", "--base-dir", baseDir]),
runConnectCli(["connect", "status", "--base-dir", baseDir]),
);

assert.include(output, "T3 Cloud\n Exposure: disabled");
assert.include(output, "T3 Connect\n Exposure: disabled");
assert.include(output, " Authorization: missing");
assert.include(output, " Environment link: not provisioned");
assert.include(output, "Next: Run `t3 cloud link` to authorize and enable cloud exposure.");
assert.include(output, "Next: Run `t3 connect link` to authorize and enable T3 Connect.");
}),
);

it.effect("logs in to headless cloud without enabling exposure", () =>
it.effect("logs in to headless connect without enabling access", () =>
Effect.gen(function* () {
const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-"));
const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
Expand All @@ -250,34 +265,36 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
}),
);

const login = yield* captureStdout(runCloudCli(["cloud", "login", "--base-dir", baseDir]));
const login = yield* captureStdout(
runConnectCli(["connect", "login", "--base-dir", baseDir]),
);
const status = yield* captureStdout(
runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]),
runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
);
// @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
const decoded = JSON.parse(status.output) as {
readonly desired: boolean;
readonly authenticated: boolean;
};

assert.equal(login.output, "Signed in to T3 Cloud.");
assert.equal(login.output, "Signed in to T3 Connect.");
assert.isFalse(decoded.desired);
assert.isTrue(decoded.authenticated);
}),
);

it.effect("disables headless cloud exposure without a running server", () =>
it.effect("disables headless connect without a running server", () =>
Effect.gen(function* () {
const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-"));
const { output } = yield* captureStdout(
runCloudCli(["cloud", "unlink", "--base-dir", baseDir]),
runConnectCli(["connect", "unlink", "--base-dir", baseDir]),
);

assert.equal(output, "T3 Cloud exposure is disabled locally.");
assert.equal(output, "T3 Connect is disabled locally.");
}),
);

it.effect("logs out of headless cloud and removes the stored CLI authorization", () =>
it.effect("logs out of headless connect and removes the stored CLI authorization", () =>
Effect.gen(function* () {
const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-"));
const { secretsDir } = yield* deriveServerPaths(baseDir, undefined);
Expand All @@ -286,10 +303,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
writeFileSync(tokenPath, "invalid persisted token");

const { output } = yield* captureStdout(
runCloudCli(["cloud", "logout", "--base-dir", baseDir]),
runConnectCli(["connect", "logout", "--base-dir", baseDir]),
);

assert.equal(output, "Signed out of T3 Cloud locally.");
assert.equal(output, "Signed out of T3 Connect locally.");
assert.isFalse(existsSync(tokenPath));
}),
);
Expand Down
39 changes: 30 additions & 9 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,51 @@ import * as CliError from "effect/unstable/cli/CliError";
import * as NetService from "@t3tools/shared/Net";
import packageJson from "../package.json" with { type: "json" };
import { authCommand } from "./cli/auth.ts";
import { cloudCommand } from "./cli/cloud.ts";
import { connectCommand } from "./cli/connect.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { sharedServerCommandFlags } from "./cli/config.ts";
import { projectCommand } from "./cli/project.ts";
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);

const cloudPublicConfigMissingMessage =
"T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration.";
const connectPublicConfigMissingMessage =
"T3 Connect commands are unavailable: this build is missing T3 Connect public configuration.";

class CloudPublicConfigMissingError extends CliError.UserError {
class ConnectPublicConfigMissingError extends CliError.UserError {
override get message() {
return cloudPublicConfigMissingMessage;
return connectPublicConfigMissingMessage;
}
}

const cloudUnavailableCommand = Command.make("cloud").pipe(
Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."),
const removedCloudCommandMessage = "The `t3 cloud` command was removed. Use `t3 connect`.";

class RemovedCloudCommandError extends CliError.UserError {
override get message() {
return removedCloudCommandMessage;
}
}

const removedCloudCommand = Command.make("cloud").pipe(
Command.withHidden,
Command.withHandler(() =>
Effect.fail(
new CliError.ShowHelp({
commandPath: ["t3", "cloud"],
errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })],
errors: [new RemovedCloudCommandError({ cause: removedCloudCommandMessage })],
}),
),
),
);

const connectUnavailableCommand = Command.make("connect").pipe(
Command.withDescription("T3 Connect is unavailable in builds without public configuration."),
Command.withHidden,
Command.withHandler(() =>
Effect.fail(
new CliError.ShowHelp({
commandPath: ["t3", "connect"],
errors: [new ConnectPublicConfigMissingError({ cause: connectPublicConfigMissingMessage })],
}),
),
),
Expand All @@ -47,7 +67,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
serveCommand,
authCommand,
projectCommand,
cloudEnabled ? cloudCommand : cloudUnavailableCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
removedCloudCommand,
]),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";

import { acquireRelayClientForLink } from "./cloud.ts";
import { acquireRelayClientForLink } from "./connect.ts";

const managedExecutable = {
status: "available",
Expand Down
Loading
Loading