shell.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. export * as Shell from "./shell"
  2. import path from "path"
  3. import { spawn, type ChildProcess } from "child_process"
  4. import { readFile } from "fs/promises"
  5. import { statSync } from "fs"
  6. import { setTimeout as sleep } from "node:timers/promises"
  7. import { Flag } from "./flag/flag"
  8. import { FSUtil } from "./fs-util"
  9. import { which } from "./util/which"
  10. const SIGKILL_TIMEOUT_MS = 200
  11. const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
  12. bash: { login: true, posix: true },
  13. dash: { login: true, posix: true },
  14. fish: { deny: true, login: true },
  15. ksh: { login: true, posix: true },
  16. nu: { deny: true },
  17. powershell: { ps: true },
  18. pwsh: { ps: true },
  19. sh: { login: true, posix: true },
  20. zsh: { login: true, posix: true },
  21. }
  22. export type Item = {
  23. path: string
  24. name: string
  25. acceptable: boolean
  26. }
  27. export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
  28. const pid = proc.pid
  29. if (!pid || opts?.exited?.()) return
  30. if (process.platform === "win32") {
  31. await new Promise<void>((resolve) => {
  32. const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
  33. stdio: "ignore",
  34. windowsHide: true,
  35. })
  36. killer.once("exit", () => resolve())
  37. killer.once("error", () => resolve())
  38. })
  39. return
  40. }
  41. try {
  42. process.kill(-pid, "SIGTERM")
  43. await sleep(SIGKILL_TIMEOUT_MS)
  44. if (!opts?.exited?.()) {
  45. process.kill(-pid, "SIGKILL")
  46. }
  47. } catch {
  48. proc.kill("SIGTERM")
  49. await sleep(SIGKILL_TIMEOUT_MS)
  50. if (!opts?.exited?.()) {
  51. proc.kill("SIGKILL")
  52. }
  53. }
  54. }
  55. function stat(file: string) {
  56. return statSync(file, { throwIfNoEntry: false }) ?? undefined
  57. }
  58. function full(file: string) {
  59. if (process.platform !== "win32") return file
  60. const shell = FSUtil.windowsPath(file)
  61. if (path.win32.dirname(shell) !== ".") {
  62. if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
  63. return shell
  64. }
  65. if (name(shell) === "bash") return gitbash() || which(shell) || shell
  66. return which(shell) || shell
  67. }
  68. function meta(file: string) {
  69. return META[name(file)]
  70. }
  71. function ok(file: string) {
  72. return meta(file)?.deny !== true
  73. }
  74. function rooted(file: string) {
  75. return path.isAbsolute(FSUtil.windowsPath(file))
  76. }
  77. function resolve(file: string) {
  78. const shell = full(file)
  79. if (rooted(shell)) {
  80. if (stat(shell)?.isFile()) return shell
  81. return
  82. }
  83. return which(shell) ?? undefined
  84. }
  85. function win() {
  86. return Array.from(
  87. new Set(
  88. [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
  89. .filter((item): item is string => Boolean(item))
  90. .map(full),
  91. ),
  92. )
  93. }
  94. async function unix() {
  95. const text = await readFile("/etc/shells", "utf8").catch(() => "")
  96. if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
  97. return ["/bin/bash", "/bin/zsh", "/bin/sh"]
  98. }
  99. function select(file: string | undefined, opts?: { acceptable?: boolean }) {
  100. if (file && (!opts?.acceptable || ok(file))) {
  101. const shell = resolve(file)
  102. if (shell) return shell
  103. }
  104. if (process.platform === "win32") return win()[0]
  105. return fallback()
  106. }
  107. export function gitbash() {
  108. if (process.platform !== "win32") return
  109. if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
  110. const git = which("git")
  111. if (!git) return
  112. const file = path.join(git, "..", "..", "bin", "bash.exe")
  113. if (stat(file)?.size) return file
  114. }
  115. function fallback() {
  116. if (process.platform === "darwin") return "/bin/zsh"
  117. const bash = which("bash")
  118. if (bash) return bash
  119. return "/bin/sh"
  120. }
  121. export function name(file: string) {
  122. if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
  123. return path.basename(file).toLowerCase()
  124. }
  125. export function login(file: string) {
  126. return meta(file)?.login === true
  127. }
  128. export function posix(file: string) {
  129. return meta(file)?.posix === true
  130. }
  131. export function ps(file: string) {
  132. return meta(file)?.ps === true
  133. }
  134. function info(file: string): Item {
  135. const item = full(file)
  136. const n = name(item)
  137. return {
  138. path: item,
  139. name: resolve(n) ? n : item,
  140. acceptable: ok(item),
  141. }
  142. }
  143. export function args(file: string, command: string, cwd: string) {
  144. const n = name(file)
  145. if (n === "nu" || n === "fish") return ["-c", command]
  146. if (n === "zsh") {
  147. return [
  148. "-l",
  149. "-c",
  150. `
  151. [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
  152. [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
  153. cd -- "$1"
  154. eval ${JSON.stringify(command)}
  155. `,
  156. "opencode",
  157. cwd,
  158. ]
  159. }
  160. if (n === "bash") {
  161. return [
  162. "-l",
  163. "-c",
  164. `
  165. shopt -s expand_aliases
  166. [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
  167. cd -- "$1"
  168. eval ${JSON.stringify(command)}
  169. `,
  170. "opencode",
  171. cwd,
  172. ]
  173. }
  174. if (n === "cmd") return ["/c", command]
  175. if (ps(file)) return ["-NoProfile", "-Command", command]
  176. return ["-c", command]
  177. }
  178. let defaultPreferred: string | undefined
  179. let defaultAcceptable: string | undefined
  180. export function preferred(configShell?: string) {
  181. if (configShell) return select(configShell)
  182. defaultPreferred ??= select(process.env.SHELL)
  183. return defaultPreferred
  184. }
  185. preferred.reset = () => {
  186. defaultPreferred = undefined
  187. }
  188. export function acceptable(configShell?: string) {
  189. if (configShell) return select(configShell, { acceptable: true })
  190. defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
  191. return defaultAcceptable
  192. }
  193. acceptable.reset = () => {
  194. defaultAcceptable = undefined
  195. }
  196. export async function list(): Promise<Item[]> {
  197. const shells = process.platform === "win32" ? win() : await unix()
  198. return shells.filter((s) => resolve(s)).map(info)
  199. }