@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/LICENSE +21 -0
- package/README.md +77 -0
- package/bun.lock +85 -0
- package/package.json +21 -0
- package/src/app.test.tsx +287 -0
- package/src/app.tsx +287 -0
- package/src/checkout.test.ts +145 -0
- package/src/checkout.ts +83 -0
- package/src/data.ts +57 -0
- package/src/github.test.ts +121 -0
- package/src/github.ts +222 -0
- package/src/index.tsx +95 -0
- package/src/repo.test.ts +91 -0
- package/src/repo.ts +182 -0
- package/src/run.test.ts +28 -0
- package/src/run.ts +36 -0
- package/tsconfig.json +18 -0
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
|
+
}
|
package/src/run.test.ts
ADDED
|
@@ -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
|
+
}
|