|
| 1 | +import { AuthMcpClientAccess, EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts"; |
| 2 | +import * as Console from "effect/Console"; |
| 3 | +import * as DateTime from "effect/DateTime"; |
| 4 | +import * as Effect from "effect/Effect"; |
| 5 | +import * as Layer from "effect/Layer"; |
| 6 | +import * as References from "effect/References"; |
| 7 | +import * as Schema from "effect/Schema"; |
| 8 | +import * as Argument from "effect/cli/Argument"; |
| 9 | +import * as Command from "effect/cli/Command"; |
| 10 | +import * as Flag from "effect/cli/Flag"; |
| 11 | +import * as GlobalFlag from "effect/cli/GlobalFlag"; |
| 12 | +import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 13 | + |
| 14 | +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 15 | +import * as ServerConfig from "../config.ts"; |
| 16 | +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 17 | +import * as PeerLinks from "../peer/PeerLinks.ts"; |
| 18 | +import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 19 | +import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 20 | + |
| 21 | +/** |
| 22 | + * Links live in this environment's database and secret store, so these |
| 23 | + * commands work whether or not its server is running. |
| 24 | + */ |
| 25 | +const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* <A, E>( |
| 26 | + flags: CliAuthLocationFlags, |
| 27 | + run: (links: PeerLinks.PeerLinks["Service"]) => Effect.Effect<A, E>, |
| 28 | + options?: { readonly quietLogs?: boolean }, |
| 29 | +) { |
| 30 | + const logLevel = yield* GlobalFlag.LogLevel; |
| 31 | + const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 32 | + return yield* PeerLinks.PeerLinks.pipe( |
| 33 | + Effect.flatMap(run), |
| 34 | + Effect.provide( |
| 35 | + PeerLinks.layer.pipe( |
| 36 | + Layer.provide( |
| 37 | + Layer.mergeAll( |
| 38 | + ServerSecretStore.layer, |
| 39 | + SqlitePersistence.layerConfig, |
| 40 | + FetchHttpClient.layer, |
| 41 | + ), |
| 42 | + ), |
| 43 | + Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))), |
| 44 | + Layer.provide(ServerConfig.layer(config)), |
| 45 | + Layer.provide( |
| 46 | + Layer.succeed(References.MinimumLogLevel, options?.quietLogs ? "Error" : config.logLevel), |
| 47 | + ), |
| 48 | + ), |
| 49 | + ), |
| 50 | + ); |
| 51 | +}); |
| 52 | + |
| 53 | +const jsonFlag = Flag.Boolean("json").pipe( |
| 54 | + Flag.withDescription("Emit JSON instead of human-readable output."), |
| 55 | + Flag.withDefault(false), |
| 56 | +); |
| 57 | + |
| 58 | +const encodeLinks = Schema.encodeEffect( |
| 59 | + Schema.fromJsonString(Schema.Array(Schema.toEncoded(Schema.Unknown))), |
| 60 | +); |
| 61 | + |
| 62 | +const formatLinks = (links: ReadonlyArray<PeerLinkSummary>) => |
| 63 | + links.length === 0 |
| 64 | + ? "No linked environments.\n" |
| 65 | + : `${links |
| 66 | + .map( |
| 67 | + (link) => |
| 68 | + `${link.label} (${link.environmentId})\n ${link.status}, access ${link.access}, expires ${DateTime.formatIso(link.expiresAt)}\n ${link.urls.join(", ")}${link.lastError === null ? "" : `\n last error: ${link.lastError}`}`, |
| 69 | + ) |
| 70 | + .join("\n\n")}\n`; |
| 71 | + |
| 72 | +const environmentLinkCommand = Command.make("link", { |
| 73 | + ...authLocationFlags, |
| 74 | + url: Argument.String("url").pipe( |
| 75 | + Argument.withDescription( |
| 76 | + "Where the other environment answers: its T3 Connect, Tailscale, LAN or loopback address.", |
| 77 | + ), |
| 78 | + ), |
| 79 | + alternateUrl: Flag.String("url").pipe( |
| 80 | + Flag.withDescription("Another address for the same environment, tried after the first."), |
| 81 | + Flag.atLeast(0), |
| 82 | + ), |
| 83 | + pairingCode: Flag.String("pairing-code").pipe( |
| 84 | + Flag.withDescription("A pairing code from the other environment (`t3 pair` there)."), |
| 85 | + ), |
| 86 | + access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe( |
| 87 | + Flag.withDescription( |
| 88 | + "What this environment's agents may do there: read-only, or the broadest runtime mode.", |
| 89 | + ), |
| 90 | + Flag.withDefault("approval-required" as const), |
| 91 | + ), |
| 92 | +}).pipe( |
| 93 | + Command.withDescription( |
| 94 | + "Link another T3 Code environment, so agents here can work there. It shows up there under Settings → Connections, where it can be revoked.", |
| 95 | + ), |
| 96 | + Command.withHandler((flags) => |
| 97 | + runWithPeerLinks(flags, (links) => |
| 98 | + Effect.gen(function* () { |
| 99 | + const linked = yield* links.link({ |
| 100 | + url: flags.url, |
| 101 | + alternateUrls: flags.alternateUrl, |
| 102 | + pairingCode: flags.pairingCode, |
| 103 | + access: flags.access, |
| 104 | + }); |
| 105 | + yield* Console.log( |
| 106 | + `Linked ${linked.label} (${linked.environmentId}) with ${linked.access} access, until ${DateTime.formatIso(linked.expiresAt)}.\n`, |
| 107 | + ); |
| 108 | + }), |
| 109 | + ), |
| 110 | + ), |
| 111 | +); |
| 112 | + |
| 113 | +const environmentListCommand = Command.make("list", { ...authLocationFlags, json: jsonFlag }).pipe( |
| 114 | + Command.withDescription("List linked environments and whether each answers now."), |
| 115 | + Command.withHandler((flags) => |
| 116 | + runWithPeerLinks( |
| 117 | + flags, |
| 118 | + (links) => |
| 119 | + Effect.gen(function* () { |
| 120 | + const listed = yield* links.list; |
| 121 | + yield* Console.log(flags.json ? `${yield* encodeLinks(listed)}\n` : formatLinks(listed)); |
| 122 | + }), |
| 123 | + { quietLogs: flags.json }, |
| 124 | + ), |
| 125 | + ), |
| 126 | +); |
| 127 | + |
| 128 | +const environmentUnlinkCommand = Command.make("unlink", { |
| 129 | + ...authLocationFlags, |
| 130 | + environmentId: Argument.String("environment-id").pipe( |
| 131 | + Argument.withDescription("The linked environment's id, from `t3 environment list`."), |
| 132 | + ), |
| 133 | +}).pipe( |
| 134 | + Command.withDescription( |
| 135 | + "Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.", |
| 136 | + ), |
| 137 | + Command.withHandler((flags) => |
| 138 | + runWithPeerLinks(flags, (links) => |
| 139 | + Effect.gen(function* () { |
| 140 | + const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId)); |
| 141 | + yield* Console.log( |
| 142 | + removed |
| 143 | + ? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n` |
| 144 | + : `No linked environment ${flags.environmentId}.\n`, |
| 145 | + ); |
| 146 | + }), |
| 147 | + ), |
| 148 | + ), |
| 149 | +); |
| 150 | + |
| 151 | +export const environmentCommand = Command.make("environment").pipe( |
| 152 | + Command.withDescription("Link other T3 Code environments, so agents here can work there."), |
| 153 | + Command.withSubcommands([ |
| 154 | + environmentLinkCommand, |
| 155 | + environmentListCommand, |
| 156 | + environmentUnlinkCommand, |
| 157 | + ]), |
| 158 | +); |
0 commit comments