| 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import type { ServerInstallation } from "@t3tools/contracts"; |
| 9 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 10 | import { isCommandAvailable } from "@t3tools/shared/shell"; |
| 11 | |
| 12 | export 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 | */ |
| 26 | function 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 | |
| 44 | const 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 | }); |
| 50 | const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest)); |
| 51 | |
| 52 | /** Prove the running package and its global bin belong together before suggesting an update. */ |
| 53 | export 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 | */ |
| 112 | function 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 | */ |
| 123 | export 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. */ |
| 136 | export 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. */ |
| 146 | const 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 | */ |
| 155 | const 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 | */ |
| 171 | const 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 | */ |
| 189 | export 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. */ |
| 201 | const ROOT_PATH_DIRECTORIES = [ |
| 202 | "/usr/local/sbin", |
| 203 | "/usr/local/bin", |
| 204 | "/usr/sbin", |
| 205 | "/usr/bin", |
| 206 | "/sbin", |
| 207 | "/bin", |
| 208 | ]; |