Skip to content
Source
Sign inSign up
Open History to see the last commit

invocation.ts

229 lines8.8 KB

1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6​
7import type { ServerInstallation } from "@t3tools/contracts";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { resolveCommandPath } from "@t3tools/shared/shell";
10​
11import packageJson from "../../package.json" with { type: "json" };
12import { resolveLauncherPath } from "../cloud/pinnedRuntime.ts";
13​
14const PINNED_RUNTIME_ENTRY = /^(.*)\/runtime\/versions\/[^/]+\/t3$/;
15​
16export type CliRunner = "npx" | "pnpm dlx" | "bunx";
17​
18/** Detection is best-effort; callers must fail closed to a plain `t3` command. */
19export function detectCliRunner(entryPath: string): CliRunner | null {
20 const path = entryPath.replaceAll("\\", "/");
21 if (path.includes("/_npx/")) {
22 return "npx";
23 }
24 if (
25 path.includes("/pnpm/dlx/") ||
26 path.includes("/.pnpm/dlx/") ||
27 path.includes("/pnpm-cache/dlx/")
28 ) {
29 return "pnpm dlx";
30 }
31 if (path.includes("/.bun/install/cache/") || path.includes("/bunx-")) {
32 return "bunx";
33 }
34 return null;
36​
37const InstallManifest = Schema.Struct({
38 name: Schema.String,
39 version: Schema.String,
40 bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })),
41});
42const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest));
43​
44/**
45 * `T3CODE_NPM_LAUNCHER_PATH` is inherited by child shells, so accept it only for this package
46 * version to avoid claiming another `t3` invocation is the npm install.
47 */
48export const resolveEntryPath = Effect.gen(function* () {
49 const npmLauncher = (yield* HostProcess.Environment).T3CODE_NPM_LAUNCHER_PATH?.trim();
50 if (npmLauncher) {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const manifest = yield* fs
54 .readFileString(path.join(path.dirname(npmLauncher), "../package.json"))
55 .pipe(Effect.flatMap(decodeInstallManifest), Effect.option);
56 if (
57 Option.isSome(manifest) &&
58 manifest.value.name === "t3" &&
59 manifest.value.version === packageJson.version
60 ) {
61 return npmLauncher;
62 }
63 }
64 if (yield* HostProcess.IsExecutable) return yield* HostProcess.ExecutablePath;
65 return (yield* HostProcess.Arguments)[1] ?? "";
66});
67​
68export const resolveServerInstallation = Effect.gen(function* () {
69 const fs = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;
71 const platform = yield* HostProcess.Platform;
72 const entry = yield* fs.realPath(yield* resolveEntryPath);
73 const runtime = PINNED_RUNTIME_ENTRY.exec(entry);
74 if (runtime) {
75 const launcher = yield* resolveLauncherPath;
76 if (launcher === undefined) return null;
77 // Fails for a runtime started directly, as SSH launches do, or through a copy.
78 const link = yield* fs.readLink(launcher);
79 // Keep the home as the link spells it; `t3 update` only repoints links into that spelling.
80 const linked = PINNED_RUNTIME_ENTRY.exec(path.resolve(path.dirname(launcher), link));
81 if (!linked || (yield* fs.realPath(linked[1] || "/")) !== (runtime[1] || "/")) return null;
82 return {
83 kind: "t3-launcher",
84 launcher,
85 baseDir: linked[1] || "/",
86 } satisfies ServerInstallation;
87 }
88 const match = /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.mjs)$/.exec(entry);
89 if (!match) {
90 const runner = detectCliRunner(entry);
91 return runner === null
92 ? null
93 : ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation);
94 }
95 if (platform === "win32") return null;
96 const prefix = match[1] || "/";
97 if (
98 prefix.includes("/node_modules/") ||
99 /\/(?:Cellar|Caskroom)\//i.test(prefix) ||
100 /\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix)
101 )
102 return null;
103​
104 const packageRoot = path.join(prefix, "lib/node_modules/t3");
105 const manifest = yield* fs
106 .readFileString(path.join(packageRoot, "package.json"))
107 .pipe(Effect.flatMap(decodeInstallManifest));
108 if (manifest.name !== "t3" || !manifest.bin) return null;
109 const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3));
110 const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3"));
111 if (globalBin !== bin || bin !== entry) return null;
112 return { kind: "npm-global", prefix } satisfies ServerInstallation;
113}).pipe(Effect.orElseSucceed(() => null));
114​
115/** The original package spec is unavailable at startup; infer its channel from the version. */
116function suggestedPackageSpec(version: string): string {
117 const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1];
118 return channel === undefined ? "t3" : `t3@${channel}`;
120​
121function formatCliCommand(input: {
122 readonly subcommand: string;
123 readonly entryPath: string;
124 readonly version: string;
125}): string {
126 const runner = detectCliRunner(input.entryPath);
127 if (runner === null) {
128 return `t3 ${input.subcommand}`;
129 }
130 return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`;
132​
133export const resolveCliCommand = (subcommand: string) =>
134 Effect.map(resolveEntryPath, (entryPath) =>
135 formatCliCommand({ subcommand, entryPath, version: packageJson.version }),
136 );
137​
138const shellWord = (value: string) =>
139 /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
140​
141/** PowerShell also treats typographic quotes as delimiters; escape them and suppress expansion. */
142const powershellWord = (value: string) =>
143 `"${value
144 .replaceAll("`", "``")
145 .replaceAll("$", "`$")
146 .replace(/["\u201C\u201D\u201E]/g, "$&$&")}"`;
147​
148export const hostShellWord = (platform: NodeJS.Platform, value: string) =>
149 platform === "win32" ? powershellWord(value) : shellWord(value);
150​
151/** Script installs have no single launcher; desktop shims and standalone binaries do. */
152const resolveInstallLauncher = Effect.gen(function* () {
153 const fs = yield* FileSystem.FileSystem;
154 const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim();
155 if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) {
156 return Option.some(shim);
157 }
158 return (yield* HostProcess.IsExecutable)
159 ? Option.some(yield* HostProcess.ExecutablePath)
160 : Option.none<string>();
161});
162​
163/**
164 * Uses this install's `t3` on PATH, the originating runner, or its absolute launcher path.
165 * Arguments are quoted for POSIX shells or PowerShell on Windows.
166 */
167export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand")(function* (
168 subcommand: string,
169 args: ReadonlyArray<string> = [],
170) {
171 const platform = yield* HostProcess.Platform;
172 const withArgs = (prefix: string) =>
173 [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" ");
174 const command = yield* resolveCliCommand(subcommand);
175 if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false };
176 const onPath = yield* resolveCommandPath("t3");
177 if (onPath !== null) {
178 const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim();
179 const fs = yield* FileSystem.FileSystem;
180 const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null));
181 // Windows `t3.cmd` shims are text files that never realpath to the executable they run.
182 const own = shim
183 ? yield* real(shim)
184 : platform !== "win32" && (yield* HostProcess.IsExecutable)
185 ? yield* resolveEntryPath.pipe(
186 Effect.flatMap((entry) =>
187 real(entry).pipe(Effect.map((resolved) => resolved ?? entry)),
188 ),
189 )
190 : null;
191 if (own === null || own === (yield* real(onPath))) {
192 return { command: withArgs(command), launcher: false };
193 }
194 }
195 const launcher = yield* resolveInstallLauncher;
196 if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false };
197 // PowerShell runs a quoted path only through the call operator.
198 const quoted = hostShellWord(platform, launcher.value);
199 const call = platform === "win32" ? `& ${quoted}` : quoted;
200 return { command: withArgs(`${call} ${subcommand}`), launcher: true };
201});
202​
203/**
204 * `sudo` resets PATH on most distributions, which drops a user-installed Node
205 * (nvm, fnm, a tarball) and with it `npx` or a global `t3`, so the command
206 * carries PATH through unless Node is on root's PATH too. An absolute launcher
207 * needs neither.
208 */
209export const resolveRootCliCommand = Effect.fn("invocation.resolveRootCliCommand")(function* (
210 subcommand: string,
211 args: ReadonlyArray<string> = [],
212) {
213 const { command, launcher } = yield* resolveHostCliCommand(subcommand, args);
214 if (launcher) return `sudo ${command}`;
215 const executablePath = yield* HostProcess.ExecutablePath;
216 const systemNode = ROOT_PATH_DIRECTORIES.some((directory) =>
217 executablePath.startsWith(`${directory}/`),
218 );
219 return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`;
220});
221​
222const ROOT_PATH_DIRECTORIES = [
223 "/usr/local/sbin",
224 "/usr/local/bin",
225 "/usr/sbin",
226 "/usr/bin",
227 "/sbin",
228 "/bin",
229];