@oscbacon/tungsten 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repo.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { readFileSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { join } from "node:path"
5
+
6
+ // Repository resolution mirrors gh (cli/cli git/client.go, git/url.go, internal/ghrepo,
7
+ // pkg/cmd/factory/remote_resolver.go; cli/go-gh pkg/ssh, pkg/auth, pkg/config)
8
+ // so we pick the same repo `gh pr list` would.
9
+
10
+ export interface Repo {
11
+ host: string
12
+ owner: string
13
+ name: string
14
+ }
15
+
16
+ export const DEFAULT_HOST = "github.com"
17
+
18
+ const normalizeHost = (h: string) => h.toLowerCase().replace(/^www\./, "")
19
+
20
+ // Accepts gh's [HOST/]OWNER/REPO format.
21
+ export function parseRepo(s: string, fallbackHost = DEFAULT_HOST): Repo {
22
+ const parts = s.split("/")
23
+ if ((parts.length === 2 || parts.length === 3) && parts.every(Boolean)) {
24
+ const [owner, name] = parts.slice(-2) as [string, string]
25
+ return { host: normalizeHost(parts.length === 3 ? parts[0]! : fallbackHost), owner, name }
26
+ }
27
+ throw new Error(`expected the "[HOST/]OWNER/REPO" format, got "${s}"`)
28
+ }
29
+
30
+ // Like gh, only show the host when it isn't the default.
31
+ export const repoLabel = (r: Repo) =>
32
+ `${r.host === DEFAULT_HOST ? "" : r.host + "/"}${r.owner}/${r.name}`
33
+
34
+ export interface GhHosts {
35
+ known: string[] // hosts whose remotes gh will consider
36
+ defaultHost: string
37
+ source: "GH_HOST" | "hosts" | "default"
38
+ }
39
+
40
+ function configDir(env: NodeJS.ProcessEnv) {
41
+ if (env.GH_CONFIG_DIR) return env.GH_CONFIG_DIR
42
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "gh")
43
+ if (process.platform === "win32" && env.AppData) return join(env.AppData, "GitHub CLI")
44
+ return join(homedir(), ".config", "gh")
45
+ }
46
+
47
+ // Top-level keys of hosts.yml. Only key lines are matched, so tokens are never read.
48
+ function configHosts(env: NodeJS.ProcessEnv): string[] {
49
+ try {
50
+ return readFileSync(join(configDir(env), "hosts.yml"), "utf8")
51
+ .split("\n")
52
+ .map(line => line.match(/^(["']?)([^\s#"':][^"':]*)\1:/)?.[2])
53
+ .filter((h): h is string => !!h)
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
59
+ export function ghHosts(env = process.env): GhHosts {
60
+ const fromConfig = configHosts(env)
61
+ const known = [...new Set([env.GH_HOST, ...fromConfig, DEFAULT_HOST].filter((h): h is string => !!h).map(normalizeHost))]
62
+ if (env.GH_HOST) return { known, defaultHost: normalizeHost(env.GH_HOST), source: "GH_HOST" }
63
+ if (fromConfig.length === 1) return { known, defaultHost: normalizeHost(fromConfig[0]!), source: "hosts" }
64
+ return { known, defaultHost: DEFAULT_HOST, source: "default" }
65
+ }
66
+
67
+ const POSSIBLE_PROTOCOL = /^(ssh|git\+ssh|git|http|git\+https|https|ftp|ftps|file):/
68
+
69
+ // `sshHostname` resolves ~/.ssh/config aliases; it is only applied to ssh URLs.
70
+ export function parseRemoteUrl(raw: string, sshHostname: (host: string) => string = h => h): Repo | null {
71
+ // scp-like syntax (git@host:owner/repo) is ssh; a backslash means a Windows path.
72
+ const s = !POSSIBLE_PROTOCOL.test(raw) && raw.includes(":") && !raw.includes("\\") ? "ssh://" + raw.replace(":", "/") : raw
73
+ let u: URL
74
+ try {
75
+ u = new URL(s)
76
+ } catch {
77
+ return null
78
+ }
79
+ if (!u.hostname) return null
80
+
81
+ let host = u.hostname
82
+ if (u.protocol.replace(/^git\+/, "") === "ssh:") {
83
+ host = sshHostname(host)
84
+ if (host.toLowerCase() === "ssh.github.com") host = DEFAULT_HOST
85
+ }
86
+
87
+ const parts = decodeURIComponent(u.pathname).replace(/^\/+|\/+$/g, "").split("/")
88
+ if (parts.length !== 2) return null
89
+ const [owner, name] = parts as [string, string]
90
+ return { host: normalizeHost(host), owner, name: name.replace(/\.git$/, "") }
91
+ }
92
+
93
+ const sshCache = new Map<string, string>()
94
+
95
+ function sshHostname(host: string): string {
96
+ const key = host.toLowerCase()
97
+ let resolved = sshCache.get(key)
98
+ if (resolved === undefined) {
99
+ try {
100
+ const out = execFileSync("ssh", ["-G", "--", host], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
101
+ resolved = out.match(/^hostname (.+)$/m)?.[1] ?? host
102
+ } catch {
103
+ resolved = host // no ssh, or it failed: keep the hostname unchanged
104
+ }
105
+ sshCache.set(key, resolved)
106
+ }
107
+ return resolved
108
+ }
109
+
110
+ export interface Remote {
111
+ name: string
112
+ fetchUrl?: string
113
+ pushUrl?: string
114
+ resolved?: string // set by `gh repo set-default` or cloning a fork: "base" or "OWNER/REPO"
115
+ }
116
+
117
+ export function readRemotes(cwd: string): Remote[] {
118
+ const git = (...args: string[]) => {
119
+ try {
120
+ return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
121
+ } catch {
122
+ return "" // not a git repo, or no gh-resolved entries (git config exits 1)
123
+ }
124
+ }
125
+
126
+ // `git remote -v` rather than raw config, so pushurl and insteadOf rewrites apply.
127
+ const remotes: Remote[] = []
128
+ for (const line of git("remote", "-v").split("\n")) {
129
+ const m = line.match(/(.+)\s+(.+)\s+\((push|fetch)\)/)
130
+ if (!m) continue
131
+ const [name, url, kind] = [m[1]!.trim(), m[2]!.trim(), m[3]]
132
+ let remote = remotes.at(-1)
133
+ if (remote?.name !== name) remotes.push((remote = { name }))
134
+ if (kind === "fetch") remote.fetchUrl = url
135
+ else remote.pushUrl = url
136
+ }
137
+
138
+ for (const line of git("config", "--get-regexp", String.raw`^remote\..*\.gh-resolved$`).split("\n")) {
139
+ const space = line.indexOf(" ")
140
+ if (space < 0) continue
141
+ const name = line.slice(0, space).split(".")[1] // gh takes the second dot-separated segment
142
+ const remote = remotes.find(r => r.name === name)
143
+ if (remote) remote.resolved = line.slice(space + 1)
144
+ }
145
+ return remotes
146
+ }
147
+
148
+ const PRIORITY = ["upstream", "github", "origin"]
149
+ const rank = (name: string) => {
150
+ const i = PRIORITY.indexOf(name.toLowerCase())
151
+ return i < 0 ? PRIORITY.length : i
152
+ }
153
+
154
+ export function resolveRepo(remotes: Remote[], hosts: GhHosts, translate: (host: string) => string = h => h): Repo {
155
+ if (remotes.length === 0) throw new Error("no git remotes found; run inside a clone or pass --repo")
156
+
157
+ const parse = (url?: string) => (url ? parseRemoteUrl(url, translate) : null)
158
+ let candidates = remotes
159
+ .map(r => ({ ...r, repo: parse(r.fetchUrl) ?? parse(r.pushUrl) }))
160
+ .filter((c): c is typeof c & { repo: Repo } => c.repo !== null && hosts.known.includes(c.repo.host))
161
+ .sort((a, b) => rank(a.name) - rank(b.name))
162
+
163
+ // A default host from GH_HOST is strict; one from hosts.yml only wins when a remote matches it.
164
+ if (hosts.source !== "default") {
165
+ const onDefault = candidates.filter(c => c.repo.host === hosts.defaultHost)
166
+ if (hosts.source === "GH_HOST" || onDefault.length > 0) candidates = onDefault
167
+ }
168
+
169
+ if (candidates.length === 0) {
170
+ throw new Error(hosts.source === "GH_HOST"
171
+ ? "none of the git remotes configured for this repository correspond to the GH_HOST environment variable; add a matching remote, unset the variable, or pass --repo"
172
+ : "none of the git remotes configured for this repository point to a known GitHub host; run `gh auth login` or pass --repo")
173
+ }
174
+
175
+ const resolved = candidates.find(c => c.resolved)
176
+ if (resolved) return resolved.resolved === "base" ? resolved.repo : parseRepo(resolved.resolved!, hosts.defaultHost)
177
+ return candidates[0]!.repo
178
+ }
179
+
180
+ export function detectRepo(cwd = process.cwd(), hosts = ghHosts()): Repo {
181
+ return resolveRepo(readRemotes(cwd), hosts, sshHostname)
182
+ }
@@ -0,0 +1,28 @@
1
+ import { expect, test } from "bun:test"
2
+ import { run } from "./run"
3
+
4
+ // Prints the prompt switches and whether the shell leads its own session (sid == pid).
5
+ const probe = ["-c", 'echo "$GIT_TERMINAL_PROMPT/$GH_PROMPT_DISABLED"; [ "$(ps -o sid= -p $$ | tr -d " ")" = "$$" ] && echo own-session || echo shared-session']
6
+
7
+ test.skipIf(process.platform === "win32")("noPrompts runs without a controlling terminal and with prompts disabled", async () => {
8
+ const { code, stdout } = await run("sh", probe, { noPrompts: true })
9
+ expect(code).toBe(0)
10
+ expect(stdout).toBe("0/1\nown-session\n")
11
+ })
12
+
13
+ test.skipIf(process.platform === "win32")("by default the command keeps the app's session and environment", async () => {
14
+ const saved = [process.env.GIT_TERMINAL_PROMPT, process.env.GH_PROMPT_DISABLED]
15
+ delete process.env.GIT_TERMINAL_PROMPT
16
+ delete process.env.GH_PROMPT_DISABLED
17
+ try {
18
+ expect((await run("sh", probe)).stdout).toBe("/\nshared-session\n")
19
+ } finally {
20
+ if (saved[0] !== undefined) process.env.GIT_TERMINAL_PROMPT = saved[0]
21
+ if (saved[1] !== undefined) process.env.GH_PROMPT_DISABLED = saved[1]
22
+ }
23
+ })
24
+
25
+ test("run reports exit codes and output, and rejects when the command is missing", async () => {
26
+ expect(await run("sh", ["-c", "echo out; echo err >&2; exit 3"])).toEqual({ code: 3, stdout: "out\n", stderr: "err\n" })
27
+ await expect(run("tungsten-no-such-command", [])).rejects.toMatchObject({ code: "ENOENT" })
28
+ })
package/src/run.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { spawn } from "node:child_process"
2
+
3
+ export interface RunResult {
4
+ code: number
5
+ stdout: string
6
+ stderr: string
7
+ }
8
+
9
+ // `interactive` marks commands that may prompt (ssh passphrases, git credentials); the caller
10
+ // decides whether they get the terminal or must fail instead, see index.tsx.
11
+ export type Run = (cmd: string, args: string[], opts?: { interactive?: boolean }) => Promise<RunResult>
12
+
13
+ export interface RunOptions {
14
+ terminal?: boolean // it has the terminal: stdin, and stderr shown as it runs
15
+ noPrompts?: boolean // the app has the terminal: fail rather than prompt on top of it
16
+ }
17
+
18
+ // Runs a command to completion with its output captured. Spawn failures (say ENOENT) reject.
19
+ export const run = (cmd: string, args: string[], { terminal = false, noPrompts = false }: RunOptions = {}) =>
20
+ new Promise<RunResult>((resolve, reject) => {
21
+ const child = spawn(cmd, args, {
22
+ stdio: [terminal ? "inherit" : "ignore", "pipe", "pipe"],
23
+ // A new session has no controlling terminal, so ssh can't open /dev/tty to ask for a
24
+ // passphrase or host key and fails instead. Unlike GIT_SSH_COMMAND, this keeps core.sshCommand.
25
+ detached: noPrompts && process.platform !== "win32",
26
+ env: noPrompts ? { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1" } : process.env,
27
+ })
28
+ let stdout = "", stderr = ""
29
+ child.stdout.on("data", d => (stdout += d))
30
+ child.stderr.on("data", d => {
31
+ stderr += d
32
+ if (terminal) process.stderr.write(d) // progress and errors stay visible while it has the terminal
33
+ })
34
+ child.on("error", reject)
35
+ child.on("close", code => resolve({ code: code ?? 1, stdout, stderr }))
36
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": [
4
+ "ESNext",
5
+ "DOM"
6
+ ],
7
+ "target": "ESNext",
8
+ "module": "ESNext",
9
+ "moduleResolution": "bundler",
10
+ "jsx": "react-jsx",
11
+ "jsxImportSource": "@opentui/react",
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "types": [
15
+ "bun"
16
+ ]
17
+ }
18
+ }