1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
9import * as CliError from "effect/cli/CliError";
10
11import packageJson from "../../package.json" with { type: "json" };
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import * as BootService from "../cloud/bootService.ts";
14import * as CliState from "../cloud/CliState.ts";
15import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINK_REVOKED } from "../cloud/config.ts";
16import { hasCloudPublicConfig } from "../cloud/publicConfig.ts";
17import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts";
18import * as ServerConfig from "../config.ts";
19import * as ManagedLimits from "../policy/managedLimits.ts";
20import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
21import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
22import {
23 connectSubcommands,
24 disconnectCloud,
25 headlessFlag,
26 linkEnvironmentForConnect,
27 requireConnectAllowed,
28 runCloudCommand,
29 waitForReachability,
30} from "./connect.ts";
31import { resolveHostCliCommand } from "./invocation.ts";
32import * as CliService from "./service.ts";
33
34type Route = "connect" | "lan";
35type Routes = Readonly<Record<Route, boolean>>;
36
37/** How other devices reach this machine now. */
38interface Setup {
39 readonly service: BootService.BootServiceStatus;
40 /** Null in builds without T3 Connect. Off when T3 Connect revoked the link. */
41 readonly connect: boolean | null;
42 /** T3 Connect revoked the link; `t3 connect` links again. */
43 readonly connectRevoked: boolean;
44 readonly lan: boolean;
45 /** An installed service with no T3CODE_LAN or T3CODE_HOST keeps the old default: on. */
46 readonly lanFromOldDefault: boolean;
47 readonly host: string | undefined;
48}
49
50/**
51 * T3 Connect is on when this CLI asked for a managed link, or when the desktop or
52 * web app made one: that saves the tunnel config but no CLI marker. Revoked is off.
53 */
54export const connectEnabled = (link: {
55 readonly cliDesired: boolean;
56 readonly cliMode: CliState.CliDesiredLinkMode;
57 readonly managedTunnel: boolean;
58 readonly revoked: boolean;
59}) =>
60 // The server starts no tunnel for a CLI publish-only link, even with an old tunnel config.
61 !link.revoked && (link.cliDesired ? link.cliMode === "managed" : link.managedTunnel);
62
63const readSetup = Effect.fn("cli.up.read_setup")(function* () {
64 const { baseDir } = yield* ServerConfig.ServerConfig;
65 const service = yield* (yield* BootService.BootService).status;
66 const { entries } = yield* CliService.readServiceEnv(baseDir);
67 // Parsed as the server parses it.
68 const lan = yield* Config.Boolean("T3CODE_LAN")
69 .pipe(Config.option)
70 .parse(ConfigProvider.fromEnv({ env: entries }));
71 const secrets = yield* ServerSecretStore.ServerSecretStore;
72 const connectRevoked =
73 hasCloudPublicConfig && Option.isSome(yield* secrets.get(CLOUD_LINK_REVOKED));
74 const connect = hasCloudPublicConfig
75 ? connectEnabled({
76 cliDesired: yield* CliState.readCliDesiredCloudLink,
77 cliMode: yield* CliState.readCliDesiredLinkMode,
78 managedTunnel: Option.isSome(yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)),
79 revoked: connectRevoked,
80 })
81 : null;
82 // Another home's service doesn't read this home's service.env.
83 const servesThisHome = service.installed && service.otherBaseDir === undefined;
84 return {
85 service,
86 connect,
87 connectRevoked,
88 lan: Option.getOrElse(lan, () => servesThisHome),
89 lanFromOldDefault: Option.isNone(lan) && servesThisHome && entries.T3CODE_HOST === undefined,
90 host: entries.T3CODE_HOST,
91 } satisfies Setup;
92});
93
94/**
95 * The routes `t3 up` starts from. With no route flag it asks about every
96 * route, starting a machine with no service from T3 Connect. Any route flag
97 * means it asks nothing and keeps the routes not named as they are.
98 */
99export const planRoutes = (
100 setup: { readonly installed: boolean; readonly connect: boolean | null; readonly lan: boolean },
101 flags: { readonly connect: Option.Option<boolean>; readonly lan: Option.Option<boolean> },
102) => {
103 const ask = Option.isNone(flags.connect) && Option.isNone(flags.lan);
104 const start = {
105 connect: setup.connect === true || (ask && !setup.installed && setup.connect !== null),
106 lan: setup.lan,
107 };
108 return {
109 routes: {
110 connect: Option.getOrElse(flags.connect, () => start.connect),
111 lan: Option.getOrElse(flags.lan, () => start.lan),
112 } satisfies Routes,
113 ask: ask
114 ? (["connect", "lan"] as const).filter((r) => r !== "connect" || setup.connect !== null)
115 : [],
116 };
117};
118
119/** A running service reads T3CODE_LAN and its T3 Connect link only when it starts. */
120const needsRestart = (before: Routes, after: Routes) =>
121 after.lan !== before.lan || (after.connect && !before.connect);
122
123const CHOICES = {
124 connect: { title: "T3 Connect", description: "Creates a tunnel to control your PC." },
125 lan: { title: "Local network", description: "LAN and tailnet. Pairing code required." },
126} satisfies Record<Route, { readonly title: string; readonly description: string }>;
127
128const formatSetup = (setup: Setup) => {
129 const { service } = setup;
130 // This home's access settings reach no service, so only the service is shown.
131 if (service.otherBaseDir !== undefined) {
132 return [
133 ` ${"Service".padEnd(15)}installed for another T3 home · ${service.otherBaseDir}`,
134 "",
135 `Pass \`${BootService.formatBootServiceBaseDirOption(service.otherBaseDir)}\` to manage it.`,
136 ].join("\n");
137 }
138 const rows: ReadonlyArray<readonly [string, string]> = [
139 [
140 "Service",
141 !service.supported
142 ? "unavailable"
143 : service.installed
144 ? `installed · t3@${service.installedVersion ?? packageJson.version}`
145 : "not installed",
146 ],
147 ...(setup.connect === null
148 ? []
149 : [
150 [
151 "T3 Connect",
152 setup.connect
153 ? "on · your T3 account"
154 : setup.connectRevoked
155 ? "off · link revoked. Run `t3 connect`."
156 : "off",
157 ] as const,
158 ]),
159 [
160 "Local network",
161 setup.host !== undefined
162 ? `overridden · T3CODE_HOST=${setup.host}`
163 : setup.lanFromOldDefault
164 ? "on · kept from an older install"
165 : setup.lan
166 ? "on · pairing code only"
167 : "off",
168 ],
169 ];
170 const notes = [
171 ...(setup.host === undefined && setup.lan
172 ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."]
173 : []),
174 ...(setup.lanFromOldDefault
175 ? ["Older installs keep LAN access on. Turn it off: `t3 up --no-lan`."]
176 : []),
177 ];
178 return [
179 ...rows.map(([label, value]) => ` ${label.padEnd(15)}${value}`),
180 ...(notes.length === 0 ? [] : ["", ...notes]),
181 ].join("\n");
182};
183
184const formatNextSteps = (routes: Routes, serviceInstalled: boolean, pairCommand: string) =>
185 [
186 ...(routes.connect
187 ? ["On your other device, sign in to T3 Connect with the same account."]
188 : []),
189 ...(routes.lan ? [`Run \`${pairCommand}\`. Use the code on your other device.`] : []),
190 ...(routes.connect || routes.lan
191 ? []
192 : [
193 serviceInstalled
194 ? "Remote access is off. T3 Code still runs here in the background."
195 : "Remote access is off. Run `t3` to use T3 Code here.",
196 ]),
197 ].join("\n");
198
199const interactiveTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
200
201const setUp = Effect.fn("cli.up")(function* (flags: {
202 readonly baseDir: Option.Option<string>;
203 readonly connect: Option.Option<boolean>;
204 readonly lan: Option.Option<boolean>;
205 readonly yes: boolean;
206 readonly headless: boolean;
207}) {
208 const { baseDir } = yield* ServerConfig.ServerConfig;
209 const service = yield* BootService.BootService;
210 const baseDirArgs = Option.match(flags.baseDir, {
211 onNone: () => [],
212 onSome: (dir) => ["--base-dir", dir],
213 });
214 const { command: pairCommand } = yield* resolveHostCliCommand("pair", baseDirArgs);
215 const before = yield* readSetup();
216 // Before any question or write: install would refuse only after both.
217 if (before.service.otherBaseDir !== undefined) {
218 return yield* new BootService.BootServiceOtherBaseDirError({
219 installedBaseDir: before.service.otherBaseDir,
220 baseDir,
221 });
222 }
223 if (before.connect === null && Option.getOrElse(flags.connect, () => false)) {
224 return yield* new CliError.UserError({
225 cause: "connect",
226 userMessage: "T3 Connect is unavailable in this build.",
227 });
228 }
229
230 const current = { connect: before.connect === true, lan: before.lan };
231 // A publish-only link is not "on", but a rollback must put it back.
232 const priorPublishOnly =
233 hasCloudPublicConfig &&
234 (yield* CliState.readCliDesiredCloudLink) &&
235 (yield* CliState.readCliDesiredLinkMode) === "publish_only";
236 const plan = planRoutes({ ...before, installed: before.service.installed }, flags);
237 const interactive = !flags.yes && interactiveTerminal();
238 let routes = plan.routes;
239 if (plan.ask.length > 0 && interactive) {
240 if (before.service.installed) yield* Console.log(`Current setup\n${formatSetup(before)}\n`);
241 const picked = yield* Prompt.run(
242 Prompt.MultiSelect({
243 message: "Allow connections through:",
244 selectAll: "Select all",
245 selectNone: "Clear all",
246 inverseSelection: "Invert selection",
247 choices: plan.ask.map((route) => ({
248 ...CHOICES[route],
249 value: route,
250 selected: plan.routes[route],
251 })),
252 }),
253 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(null) }));
254 if (picked === null) return yield* Console.log("Nothing changed.");
255 routes = { connect: picked.includes("connect"), lan: picked.includes("lan") };
256 } else if (plan.ask.length > 0 && !flags.yes && !before.service.installed) {
257 return yield* new CliError.UserError({
258 cause: "routes",
259 userMessage: `No terminal. Choose access with ${
260 before.connect === null ? "--lan" : "--connect or --lan"
261 }, or use --yes for the default. See \`t3 up --help\`.`,
262 });
263 }
264 if (routes.connect && !current.connect) yield* requireConnectAllowed;
265 if (routes.lan && !current.lan) {
266 const policy = yield* ManagedPolicy.ManagedPolicy;
267 if (!ManagedLimits.allowsRemoteClients(policy)) {
268 return yield* ManagedLimits.refuseRemoteClients(policy);
269 }
270 }
271 // Nothing to run in the background, but a route left on still gets turned off.
272 if (!before.service.installed && !routes.connect && !routes.lan) {
273 if (current.connect || before.connectRevoked) {
274 yield* disconnectCloud({ clearAuthorization: false });
275 }
276 if (current.lan) yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: "false" });
277 return yield* Console.log(formatNextSteps(routes, false, pairCommand));
278 }
279 const { installedVersion } = before.service;
280 const newer =
281 installedVersion !== undefined &&
282 compareExactServiceVersions(installedVersion, packageJson.version) > 0;
283 // Asked before anything changes, so a declined restart leaves service.env
284 // matching what the running service read when it started.
285 // A stopped service of this version is not current, so reconcile starts it;
286 // a newer one is left alone and needs this restart to start.
287 const stopped = before.service.problems?.includes("service-stopped") === true;
288 const restart =
289 before.service.installed &&
290 (before.service.current || newer) &&
291 (needsRestart(current, routes) || stopped);
292 // An older or broken service is reinstalled, which restarts it too.
293 const update = before.service.installed && !before.service.current && !newer && !stopped;
294 if ((restart || update) && interactive && !stopped) {
295 const confirmed = yield* Prompt.run(
296 Prompt.Confirm({
297 message: update
298 ? "Update and restart T3 Code? Running agent turns will stop."
299 : "Apply changes and restart? Running agent turns will stop.",
300 initial: true,
301 }),
302 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
303 if (!confirmed) return yield* Console.log("Nothing changed.");
304 }
305
306 if (routes.connect && !current.connect) {
307 const linked = yield* linkEnvironmentForConnect({
308 headless: flags.headless,
309 installRelayClient: true,
310 });
311 if (linked === null) return;
312 yield* Console.log(
313 `✓ Signed in to T3 Connect${linked.identity ? ` as ${linked.identity}` : ""}`,
314 );
315 }
316 // A running server unlinks live; when that fails, only a restart stops its tunnel.
317 const unlinkFailed =
318 !routes.connect && (current.connect || before.connectRevoked)
319 ? (yield* disconnectCloud({ clearAuthorization: false })).status === "failed"
320 : false;
321 const previousLan = (yield* CliService.readServiceEnv(baseDir)).entries.T3CODE_LAN;
322 // Also with no service manager: the file records the choice that `t3 status` shows.
323 yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: String(routes.lan) });
324 // When the service step fails, an installed service still runs with the old
325 // value, so the file goes back to match it.
326 // A new T3 Connect link is undone too, with any tunnel the failed start saved, so a
327 // rerun sees it as a change and restarts. The sign-in stays.
328 const restoreServiceEnv = before.service.installed
329 ? CliService.writeServiceEnv(baseDir, { T3CODE_LAN: previousLan }).pipe(
330 Effect.andThen(
331 !routes.connect || current.connect
332 ? Effect.void
333 : priorPublishOnly
334 ? CliState.setCliDesiredCloudLink(true, "publish_only")
335 : disconnectCloud({ clearAuthorization: false }).pipe(Effect.asVoid),
336 ),
337 )
338 : Effect.void;
339 // A failed update or restart may still have started the service with the new
340 // settings, so restart it once more on the old ones.
341 const rollBack = before.service.installed
342 ? restoreServiceEnv.pipe(Effect.andThen(service.restart.pipe(Effect.ignore)))
343 : Effect.void;
344 if (before.host !== undefined) {
345 yield* Console.warn(
346 `T3CODE_HOST=${before.host} in service.env overrides the network choice. Remove it to apply it.`,
347 );
348 }
349
350 const printServeCommand = Effect.fn(function* (reason: string) {
351 const { command } = yield* resolveHostCliCommand("serve", [
352 ...baseDirArgs,
353 // Explicit either way, so a T3CODE_LAN in the shell cannot override the choice.
354 routes.lan ? "--lan" : "--no-lan",
355 ]);
356 yield* Console.log(`${reason} Keep this command running:\n ${command}`);
357 // A host wins over --lan and --no-lan, as it does in service.env.
358 const shellHost = (yield* HostProcess.Environment).T3CODE_HOST;
359 if (shellHost !== undefined) {
360 yield* Console.warn(
361 `T3CODE_HOST=${shellHost} in this shell overrides that choice. Unset it before you run the command.`,
362 );
363 }
364 });
365 let started = false;
366 if (!before.service.supported) {
367 yield* printServeCommand("Background service unavailable.");
368 } else {
369 if (newer) {
370 yield* Console.log(`Keeping installed service version t3@${installedVersion}.`);
371 }
372 if (update && !interactive) {
373 yield* Console.log("Updating T3 Code. Running agent turns will stop.");
374 }
375 const result = newer
376 ? ({ changed: false } as const)
377 : yield* CliService.reconcileService().pipe(
378 Effect.catchTags({
379 BootServicePrerequisiteError: (error) => Effect.succeed({ failed: error.message }),
380 BootServiceUnsupportedError: (error) => Effect.succeed({ failed: error.message }),
381 }),
382 Effect.tapError(() => rollBack),
383 );
384 if ("failed" in result && before.service.installed) {
385 // The old service still runs, so a foreground server would clash with it.
386 yield* restoreServiceEnv;
387 const { command } = yield* resolveHostCliCommand("up", baseDirArgs);
388 yield* Console.log(
389 `Service setup failed: ${result.failed} Fix that, then run \`${command}\` again.`,
390 );
391 } else if ("failed" in result) {
392 yield* printServeCommand(`Service setup failed: ${result.failed}`);
393 } else if (result.changed) {
394 yield* Console.log(CliService.formatServiceChange(result));
395 started = true;
396 } else if (restart || (unlinkFailed && before.service.installed)) {
397 const confirmed =
398 restart ||
399 !interactive ||
400 (yield* Prompt.run(
401 Prompt.Confirm({
402 message:
403 "The running service still has T3 Connect on. Restart it? Running agent turns will stop.",
404 initial: true,
405 }),
406 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })));
407 if (!confirmed) {
408 const { command } = yield* resolveHostCliCommand("service restart", baseDirArgs);
409 yield* Console.log(`Run \`${command}\` to turn T3 Connect off.`);
410 } else {
411 if (!interactive && !stopped) {
412 yield* Console.log("Restarting T3 Code. Running agent turns will stop.");
413 }
414 const outcome = yield* service.restart.pipe(Effect.tapError(() => rollBack));
415 if (outcome === "queued") {
416 yield* Console.log("Restart queued. This session will disconnect.");
417 }
418 if (outcome === "skipped") {
419 yield* Console.log(
420 "The installed service was not restarted. Run `t3 service status` to see why.",
421 );
422 }
423 started = outcome === "restarted";
424 }
425 }
426 }
427 if (started && routes.connect && !current.connect) yield* waitForReachability();
428
429 const after = yield* readSetup();
430 yield* Console.log(
431 // From what took effect: a failed service step can undo a choice.
432 `\nThis machine\n${formatSetup(after)}\n\n${formatNextSteps(
433 { connect: after.connect === true, lan: after.lan },
434 after.service.installed,
435 pairCommand,
436 )}`,
437 );
438});
439
440export const upCommand = Command.make("up", {
441 ...projectLocationFlags,
442 connect: Flag.Boolean("connect").pipe(
443 Flag.withDescription(
444 "Allow access through T3 Connect with your T3 account. Turn off with --no-connect.",
445 ),
446 Flag.optional,
447 ),
448 lan: Flag.Boolean("lan").pipe(
449 Flag.withDescription(
450 "Allow LAN and tailnet connections. Anyone there can reach the port; a pairing code is the only lock. Turn off with --no-lan.",
451 ),
452 Flag.optional,
453 ),
454 yes: Flag.Boolean("yes").pipe(
455 Flag.withDescription(
456 "Ask nothing. Keep the access settings; a new machine gets T3 Connect. A restart stops running agent turns.",
457 ),
458 Flag.withDefault(false),
459 ),
460 headless: headlessFlag,
461}).pipe(
462 Command.withShortDescription("Set up remote access and the T3 Code background service."),
463 Command.withDescription(
464 "Set up remote access and the T3 Code background service. Access flags skip the question, and routes not named keep their settings. Without a terminal, a needed restart runs without asking and stops running agent turns.",
465 ),
466 Command.withExamples([
467 { command: "t3 up", description: "Choose remote access." },
468 { command: "t3 up --connect", description: "Turn on T3 Connect. Keep other settings." },
469 { command: "t3 up --lan", description: "Allow LAN access with pairing codes." },
470 { command: "t3 up --no-lan", description: "Stop LAN access." },
471 { command: "t3 up --yes", description: "Take the defaults with no questions." },
472 { command: "t3 status --json", description: "Read the settings from a script." },
473 ]),
474 Command.withHandler((flags) => runCloudCommand(flags, setUp(flags), { quietLogs: true })),
475);
476
477export const connectCommand = Command.make("connect", {
478 ...projectLocationFlags,
479 headless: headlessFlag,
480}).pipe(
481 Command.withDescription("Set up T3 Connect for this machine. Same as `t3 up --connect`."),
482 Command.withHandler((flags) =>
483 runCloudCommand(
484 flags,
485 setUp({ ...flags, connect: Option.some(true), lan: Option.none(), yes: false }),
486 { quietLogs: true },
487 ),
488 ),
489 Command.withSubcommands(connectSubcommands),
490);
491
492/** `t3 status --json`, for scripts and agents. */
493const setupJson = (setup: Setup) =>
494 JSON.stringify(
495 {
496 service: {
497 supported: setup.service.supported,
498 installed: setup.service.installed,
499 installedVersion: setup.service.installedVersion ?? null,
500 otherBaseDir: setup.service.otherBaseDir ?? null,
501 },
502 connect:
503 setup.connect === null ? null : { enabled: setup.connect, revoked: setup.connectRevoked },
504 lan: {
505 enabled: setup.lan,
506 inheritedDefault: setup.lanFromOldDefault,
507 hostOverride: setup.host ?? null,
508 },
509 },
510 null,
511 2,
512 );
513
514export const statusCommand = Command.make("status", {
515 ...projectLocationFlags,
516 json: Flag.Boolean("json").pipe(
517 Flag.withDescription("Print the settings as JSON. Does not test connections."),
518 Flag.withDefault(false),
519 ),
520}).pipe(
521 Command.withDescription("Show service setup and remote access settings."),
522 Command.withExamples([
523 { command: "t3 status", description: "Show the settings." },
524 { command: "t3 status --json", description: "Print them for scripts and agents." },
525 ]),
526 Command.withHandler(
527 Effect.fn("cli.status")(function* (flags) {
528 const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel, {
529 quietLogs: flags.json,
530 });
531 // Only what readSetup reads: no database, unlike the T3 Connect commands.
532 const setup = yield* readSetup().pipe(
533 Effect.provide(
534 Layer.merge(CliService.layer(config), ServerSecretStore.layer).pipe(
535 Layer.provideMerge(ServerConfig.layer(config)),
536 ),
537 ),
538 );
539 yield* Console.log(
540 flags.json
541 ? setupJson(setup)
542 : setup.service.otherBaseDir !== undefined
543 ? `This machine\n${formatSetup(setup)}`
544 : `This machine\n${formatSetup(setup)}\n\nChange access: \`t3 up\`.`,
545 );
546 }),
547 ),
548);