1import { SERVER_VERSION } from "../version.ts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7
8import type { ServerInstallation } from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { isCommandAvailable } from "@t3tools/shared/shell";
11
12export type CliRunner = "npx" | "pnpm dlx" | "bunx";
13
14/**
15 * How the CLI was launched, judged by where its entry script lives. Each
16 * package runner executes out of a distinctive cache/temp layout:
17 *
18 * npx ~/.npm/_npx/<hash>/node_modules/...
19 * pnpm dlx ~/.cache/pnpm/dlx/..., $PNPM_HOME/.pnpm/dlx/...,
20 * or %LOCALAPPDATA%/pnpm-cache/dlx/... on Windows
21 * bunx ~/.bun/install/cache/... or $TMPDIR/bunx-<uid>-<spec>/...
22 *
23 * Global installs and repo checkouts match none of these and return null.
24 * Detection is best-effort; callers must fail closed to a plain `t3` command.
25 */
26function detectCliRunner(entryPath: string): CliRunner | null {
27 const path = entryPath.replaceAll("\\", "/");
28 if (path.includes("/_npx/")) {
29 return "npx";
30 }
31 if (
32 path.includes("/pnpm/dlx/") ||
33 path.includes("/.pnpm/dlx/") ||
34 path.includes("/pnpm-cache/dlx/")
35 ) {
36 return "pnpm dlx";
37 }
38 if (path.includes("/.bun/install/cache/") || path.includes("/bunx-")) {
39 return "bunx";
40 }
41 return null;
42}
43
44const InstallManifest = Schema.Struct({
45 name: Schema.String,
46 version: Schema.String,
47 bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })),
48 optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
49});
50const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest));
51
52/** Prove the running package and its global bin belong together before suggesting an update. */
53export const resolveServerInstallation = Effect.gen(function* () {
54 const fs = yield* FileSystem.FileSystem;
55 const path = yield* Path.Path;
56 const args = yield* HostProcess.Arguments;
57 const executable = yield* HostProcess.IsExecutable;
58 const executablePath = yield* HostProcess.ExecutablePath;
59 const platform = yield* HostProcess.Platform;
60 const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? ""));
61 const match =
62 /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec(
63 entry,
64 );
65 if (!match) {
66 const runner = detectCliRunner(entry);
67 return runner === null
68 ? null
69 : ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation);
70 }
71 // A global prefix can contain runner-like names; prove its ownership first.
72 // Windows shims and other package managers need their own ownership proof.
73 if (platform === "win32") return null;
74 const prefix = match[1] || "/";
75 if (
76 prefix.includes("/node_modules/") ||
77 /\/(?:Cellar|Caskroom)\//i.test(prefix) ||
78 /\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix)
79 )
80 return null;
81
82 const packageRoot = path.join(prefix, "lib/node_modules/t3");
83 const manifest = yield* fs
84 .readFileString(path.join(packageRoot, "package.json"))
85 .pipe(Effect.flatMap(decodeInstallManifest));
86 if (manifest.name !== "t3" || !manifest.bin) return null;
87 const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3));
88 const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3"));
89 if (globalBin !== bin) return null;
90 if (executable) {
91 const nativeManifest = yield* fs
92 .readFileString(path.join(path.dirname(entry), "package.json"))
93 .pipe(Effect.flatMap(decodeInstallManifest));
94 if (
95 manifest.bin.t3 !== "./bin/t3.js" ||
96 manifest.optionalDependencies?.[nativeManifest.name] !== nativeManifest.version ||
97 nativeManifest.version !== manifest.version
98 )
99 return null;
100 } else if (bin !== entry) {
101 return null;
102 }
103 return { kind: "npm-global", prefix } satisfies ServerInstallation;
104}).pipe(Effect.orElseSucceed(() => null));
105
106/**
107 * The `t3` package spec to suggest. The literal spec the user typed (e.g.
108 * `t3@nightly`) is resolved away before our process starts, so re-derive it
109 * from the running version: nightly builds re-suggest the nightly channel,
110 * anything else suggests the bare package.
111 */
112function suggestedPackageSpec(version: string): string {
113 const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1];
114 return channel === undefined ? "t3" : `t3@${channel}`;
115}
116
117/**
118 * Render a `t3 <subcommand>` suggestion that matches how this process was
119 * launched, so copy/pasting it actually works: `npx t3 connect` suggests
120 * `npx t3 serve`, a global install suggests `t3 serve`, and a nightly build
121 * keeps the `@nightly` tag.
122 */
123export function formatCliCommand(input: {
124 readonly subcommand: string;
125 readonly entryPath: string;
126 readonly version: string;
127}): string {
128 const runner = detectCliRunner(input.entryPath);
129 if (runner === null) {
130 return `t3 ${input.subcommand}`;
131 }
132 return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`;
133}
134
135/** `formatCliCommand` against this process's real entry path and version. */
136export const resolveCliCommand = (subcommand: string) =>
137 Effect.map(HostProcess.Arguments, (processArguments) =>
138 formatCliCommand({
139 subcommand,
140 entryPath: processArguments[1] ?? "",
141 version: SERVER_VERSION,
142 }),
143 );
144
145/** Quotes `value` for a POSIX shell when it needs it. */
146const shellWord = (value: string) =>
147 /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
148
149/**
150 * The launcher a person can type to run this install when `t3` is not on
151 * PATH: the desktop app's `t3` shim, which the app and the shim itself name in
152 * `T3CODE_CLI_PATH`, or a standalone binary's own path. Script installs (a
153 * repo checkout) have no single launcher and keep plain `t3`.
154 */
155const resolveInstallLauncher = Effect.gen(function* () {
156 const fs = yield* FileSystem.FileSystem;
157 const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim();
158 if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) {
159 return Option.some(shim);
160 }
161 return (yield* HostProcess.IsExecutable)
162 ? Option.some(yield* HostProcess.ExecutablePath)
163 : Option.none<string>();
164});
165
166/**
167 * `t3 <subcommand>` for a person to run on this host: `t3` when it is on PATH,
168 * the package runner this process came from, or else the absolute path of the
169 * launcher for this install, such as the one the desktop app installs.
170 */
171const resolveHostCliCommand = (subcommand: string) =>
172 Effect.gen(function* () {
173 const command = yield* resolveCliCommand(subcommand);
174 if (command !== `t3 ${subcommand}`) return { command, launcher: false };
175 if (yield* isCommandAvailable("t3")) return { command, launcher: false };
176 const launcher = yield* resolveInstallLauncher;
177 return Option.isSome(launcher)
178 ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true }
179 : { command, launcher: false };
180 });
181
182/**
183 * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo`
184 * resets PATH on most distributions, which drops a user-installed Node (nvm,
185 * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries
186 * PATH through unless Node is on root's PATH too. An absolute launcher needs
187 * neither.
188 */
189export const resolveRootCliCommand = (subcommand: string) =>
190 Effect.gen(function* () {
191 const { command, launcher } = yield* resolveHostCliCommand(subcommand);
192 if (launcher) return `sudo ${command}`;
193 const executablePath = yield* HostProcess.ExecutablePath;
194 const systemNode = ROOT_PATH_DIRECTORIES.some((directory) =>
195 executablePath.startsWith(`${directory}/`),
196 );
197 return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`;
198 });
199
200/** Debian and Ubuntu's sudo `secure_path`, minus snap. */
201const ROOT_PATH_DIRECTORIES = [
202 "/usr/local/sbin",
203 "/usr/local/bin",
204 "/usr/sbin",
205 "/usr/bin",
206 "/sbin",
207 "/bin",
208];