terminfo.dev 1.1.0 → 1.3.0
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/package.json +1 -1
- package/src/detect.ts +76 -26
- package/src/index.ts +25 -17
- package/src/report.tsx +28 -9
- package/src/submit.ts +60 -2
package/package.json
CHANGED
package/src/detect.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal detection — identify the running terminal emulator.
|
|
3
3
|
*
|
|
4
|
-
* Uses environment variables,
|
|
4
|
+
* Uses environment variables, macOS bundle metadata, and fallback heuristics.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { release } from "node:os"
|
|
8
|
+
import { execFileSync } from "node:child_process"
|
|
8
9
|
|
|
9
10
|
export interface TerminalInfo {
|
|
10
11
|
name: string
|
|
@@ -14,7 +15,7 @@ export interface TerminalInfo {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Known terminal detection via environment variables */
|
|
17
|
-
const ENV_DETECTORS: Array<{ env: string; name: string
|
|
18
|
+
const ENV_DETECTORS: Array<{ env: string; name: string }> = [
|
|
18
19
|
{ env: "GHOSTTY_RESOURCES_DIR", name: "ghostty" },
|
|
19
20
|
{ env: "KITTY_WINDOW_ID", name: "kitty" },
|
|
20
21
|
{ env: "WEZTERM_EXECUTABLE", name: "wezterm" },
|
|
@@ -33,46 +34,97 @@ const TERM_PROGRAM_MAP: Record<string, string> = {
|
|
|
33
34
|
WarpTerminal: "warp",
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Known macOS bundle IDs for version lookup */
|
|
38
|
+
const BUNDLE_IDS: Record<string, string> = {
|
|
39
|
+
ghostty: "com.mitchellh.ghostty",
|
|
40
|
+
kitty: "net.kovidgoyal.kitty",
|
|
41
|
+
iterm2: "com.googlecode.iterm2",
|
|
42
|
+
"terminal-app": "com.apple.Terminal",
|
|
43
|
+
wezterm: "org.wezfurlong.wezterm",
|
|
44
|
+
alacritty: "org.alacritty",
|
|
45
|
+
warp: "dev.warp.Warp-Stable",
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export function detectTerminal(): TerminalInfo {
|
|
37
49
|
const os = detectOS()
|
|
38
50
|
const osVersion = detectOSVersion()
|
|
39
51
|
|
|
52
|
+
let name = "unknown"
|
|
53
|
+
let version = ""
|
|
54
|
+
|
|
40
55
|
// Check specific env vars first
|
|
41
|
-
for (const { env, name } of ENV_DETECTORS) {
|
|
56
|
+
for (const { env, name: n } of ENV_DETECTORS) {
|
|
42
57
|
if (process.env[env]) {
|
|
43
|
-
|
|
58
|
+
name = n
|
|
59
|
+
break
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
// Check $TERM_PROGRAM
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
if (name === "unknown") {
|
|
65
|
+
const termProgram = process.env.TERM_PROGRAM
|
|
66
|
+
if (termProgram) {
|
|
67
|
+
name = TERM_PROGRAM_MAP[termProgram] ?? termProgram.toLowerCase()
|
|
68
|
+
version = process.env.TERM_PROGRAM_VERSION ?? ""
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check $TERMINAL_EMULATOR (Linux)
|
|
73
|
+
if (name === "unknown") {
|
|
74
|
+
const termEmu = process.env.TERMINAL_EMULATOR
|
|
75
|
+
if (termEmu) name = termEmu.toLowerCase()
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return { name: termEmu.toLowerCase(), version: "", os, osVersion }
|
|
78
|
+
// Fallback: $TERM
|
|
79
|
+
if (name === "unknown") {
|
|
80
|
+
name = process.env.TERM ?? "unknown"
|
|
59
81
|
}
|
|
60
82
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
// On macOS, get version from app bundle if we don't have it yet
|
|
84
|
+
if (!version && os === "macos") {
|
|
85
|
+
version = getMacOSAppVersion(name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { name, version, os, osVersion }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get app version from macOS bundle metadata.
|
|
93
|
+
* Uses $__CFBundleIdentifier → mdfind → PlistBuddy.
|
|
94
|
+
*/
|
|
95
|
+
function getMacOSAppVersion(terminalName: string): string {
|
|
96
|
+
try {
|
|
97
|
+
// Try __CFBundleIdentifier first (set by the running app)
|
|
98
|
+
let bundleId = process.env.__CFBundleIdentifier
|
|
99
|
+
if (!bundleId) bundleId = BUNDLE_IDS[terminalName]
|
|
100
|
+
if (!bundleId) return ""
|
|
101
|
+
|
|
102
|
+
// Find app path from bundle ID
|
|
103
|
+
const appPath = execFileSync("mdfind", [`kMDItemCFBundleIdentifier == '${bundleId}'`], {
|
|
104
|
+
encoding: "utf-8",
|
|
105
|
+
timeout: 3000,
|
|
106
|
+
}).trim().split("\n")[0]
|
|
107
|
+
|
|
108
|
+
if (!appPath) return ""
|
|
109
|
+
|
|
110
|
+
// Read version from Info.plist
|
|
111
|
+
const version = execFileSync("/usr/libexec/PlistBuddy", [
|
|
112
|
+
"-c", "Print :CFBundleShortVersionString",
|
|
113
|
+
`${appPath}/Contents/Info.plist`,
|
|
114
|
+
], { encoding: "utf-8", timeout: 2000 }).trim()
|
|
115
|
+
|
|
116
|
+
return version
|
|
117
|
+
} catch {
|
|
118
|
+
return ""
|
|
119
|
+
}
|
|
64
120
|
}
|
|
65
121
|
|
|
66
122
|
function detectOS(): string {
|
|
67
123
|
switch (process.platform) {
|
|
68
|
-
case "darwin":
|
|
69
|
-
|
|
70
|
-
case "
|
|
71
|
-
|
|
72
|
-
case "win32":
|
|
73
|
-
return "windows"
|
|
74
|
-
default:
|
|
75
|
-
return process.platform
|
|
124
|
+
case "darwin": return "macos"
|
|
125
|
+
case "linux": return "linux"
|
|
126
|
+
case "win32": return "windows"
|
|
127
|
+
default: return process.platform
|
|
76
128
|
}
|
|
77
129
|
}
|
|
78
130
|
|
|
@@ -86,8 +138,6 @@ function detectOSVersion(): string {
|
|
|
86
138
|
|
|
87
139
|
/**
|
|
88
140
|
* Query terminal identity via DA2 (Secondary Device Attributes).
|
|
89
|
-
* Sends CSI > 0 c and parses the response.
|
|
90
|
-
*
|
|
91
141
|
* Must be called with raw mode enabled on stdin.
|
|
92
142
|
*/
|
|
93
143
|
export async function queryDA2(
|
package/src/index.ts
CHANGED
|
@@ -25,10 +25,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
25
25
|
|
|
26
26
|
/** Load feature slugs from features.json for OSC 8 hyperlinks */
|
|
27
27
|
function loadFeatureSlugs(): Record<string, string> {
|
|
28
|
-
const candidates = [
|
|
29
|
-
join(__dirname, "..", "..", "features.json"),
|
|
30
|
-
join(__dirname, "..", "..", "..", "features.json"),
|
|
31
|
-
]
|
|
28
|
+
const candidates = [join(__dirname, "..", "..", "features.json"), join(__dirname, "..", "..", "..", "features.json")]
|
|
32
29
|
for (const path of candidates) {
|
|
33
30
|
try {
|
|
34
31
|
const raw = JSON.parse(readFileSync(path, "utf-8"))
|
|
@@ -90,7 +87,9 @@ function printHeader(terminal: ReturnType<typeof detectTerminal>) {
|
|
|
90
87
|
console.log(`\x1b[1m${siteLink}\x1b[0m — can your terminal do that?\n`)
|
|
91
88
|
console.log(` Terminal: \x1b[1m${terminal.name}\x1b[0m${terminal.version ? ` ${terminal.version}` : ""}`)
|
|
92
89
|
console.log(` Platform: ${terminal.os} ${terminal.osVersion}`)
|
|
93
|
-
console.log(
|
|
90
|
+
console.log(
|
|
91
|
+
` Probes: ${ALL_PROBES.length} features across ${new Set(ALL_PROBES.map((p) => p.id.split(".")[0])).size} categories`,
|
|
92
|
+
)
|
|
94
93
|
console.log(` Website: ${link("https://terminfo.dev", "https://terminfo.dev")}`)
|
|
95
94
|
}
|
|
96
95
|
|
|
@@ -115,7 +114,7 @@ function printResults(data: ProbeResults) {
|
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
for (const [cat, probes] of categories) {
|
|
118
|
-
const catPassed = probes.filter(p => p.pass).length
|
|
117
|
+
const catPassed = probes.filter((p) => p.pass).length
|
|
119
118
|
const color = catPassed === probes.length ? "\x1b[32m" : catPassed > 0 ? "\x1b[33m" : "\x1b[31m"
|
|
120
119
|
const catLink = link(`https://terminfo.dev/${cat}`, cat)
|
|
121
120
|
console.log(`${color}${catLink}\x1b[0m (${catPassed}/${probes.length})`)
|
|
@@ -145,21 +144,28 @@ program
|
|
|
145
144
|
const data = await runProbes()
|
|
146
145
|
|
|
147
146
|
if (opts.json) {
|
|
148
|
-
console.log(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
console.log(
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
terminal: data.terminal.name,
|
|
151
|
+
terminalVersion: data.terminal.version,
|
|
152
|
+
os: data.terminal.os,
|
|
153
|
+
osVersion: data.terminal.osVersion,
|
|
154
|
+
source: "community",
|
|
155
|
+
generated: new Date().toISOString(),
|
|
156
|
+
results: data.results,
|
|
157
|
+
notes: data.notes,
|
|
158
|
+
responses: data.responses,
|
|
159
|
+
},
|
|
160
|
+
null,
|
|
161
|
+
2,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
159
164
|
return
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
printResults(data)
|
|
168
|
+
console.log(`\n\x1b[2mContribute these results: \x1b[0m\x1b[1mnpx terminfo.dev submit\x1b[0m`)
|
|
163
169
|
})
|
|
164
170
|
|
|
165
171
|
program
|
|
@@ -179,6 +185,8 @@ program
|
|
|
179
185
|
notes: data.notes,
|
|
180
186
|
responses: data.responses,
|
|
181
187
|
generated: new Date().toISOString(),
|
|
188
|
+
cliVersion: "1.3.0",
|
|
189
|
+
probeCount: ALL_PROBES.length,
|
|
182
190
|
})
|
|
183
191
|
if (url) {
|
|
184
192
|
console.log(`\x1b[32m✓ Issue created:\x1b[0m ${link(url, url)}`)
|
package/src/report.tsx
CHANGED
|
@@ -49,18 +49,35 @@ function Header({ terminal, terminalVersion, os, osVersion, probeCount, category
|
|
|
49
49
|
<Text dimColor>Score</Text>
|
|
50
50
|
</Box>
|
|
51
51
|
<Box flexDirection="column">
|
|
52
|
-
<Text bold>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
<Text bold>
|
|
53
|
+
{terminal}
|
|
54
|
+
{terminalVersion ? ` ${terminalVersion}` : ""}
|
|
55
|
+
</Text>
|
|
56
|
+
<Text>
|
|
57
|
+
{os} {osVersion}
|
|
58
|
+
</Text>
|
|
59
|
+
<Text>
|
|
60
|
+
{probeCount} features, {categoryCount} categories
|
|
61
|
+
</Text>
|
|
62
|
+
<Text bold color={pct === 100 ? "green" : pct >= 90 ? "yellow" : "red"}>
|
|
63
|
+
{passed}/{total} ({pct}%)
|
|
64
|
+
</Text>
|
|
56
65
|
</Box>
|
|
57
66
|
</Box>
|
|
58
67
|
</Box>
|
|
59
68
|
)
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
function CategorySection({
|
|
63
|
-
|
|
71
|
+
function CategorySection({
|
|
72
|
+
name,
|
|
73
|
+
probes,
|
|
74
|
+
slugs,
|
|
75
|
+
}: {
|
|
76
|
+
name: string
|
|
77
|
+
probes: ProbeResult[]
|
|
78
|
+
slugs: Record<string, string>
|
|
79
|
+
}) {
|
|
80
|
+
const catPassed = probes.filter((p) => p.pass).length
|
|
64
81
|
const allPassed = catPassed === probes.length
|
|
65
82
|
const catUrl = `https://terminfo.dev/${name}`
|
|
66
83
|
|
|
@@ -69,7 +86,7 @@ function CategorySection({ name, probes, slugs }: { name: string; probes: ProbeR
|
|
|
69
86
|
<Text color={allPassed ? "green" : catPassed > 0 ? "yellow" : "red"}>
|
|
70
87
|
{osc8(catUrl, name)} ({catPassed}/{probes.length})
|
|
71
88
|
</Text>
|
|
72
|
-
{probes.map(p => {
|
|
89
|
+
{probes.map((p) => {
|
|
73
90
|
const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
|
|
74
91
|
const url = featureUrl(p.id, slug)
|
|
75
92
|
const icon = p.pass ? "✓" : "✗"
|
|
@@ -90,7 +107,7 @@ function Footer({ submitMode }: { submitMode: boolean }) {
|
|
|
90
107
|
return (
|
|
91
108
|
<Box flexDirection="column" marginTop={1}>
|
|
92
109
|
<Text dimColor>Submit: npx terminfo.dev --submit</Text>
|
|
93
|
-
<Text dimColor>JSON:
|
|
110
|
+
<Text dimColor>JSON: npx terminfo.dev --json</Text>
|
|
94
111
|
</Box>
|
|
95
112
|
)
|
|
96
113
|
}
|
|
@@ -107,7 +124,9 @@ function Report(props: ReportProps & { slugs: Record<string, string>; submitMode
|
|
|
107
124
|
)
|
|
108
125
|
}
|
|
109
126
|
|
|
110
|
-
export async function renderReport(
|
|
127
|
+
export async function renderReport(
|
|
128
|
+
props: ReportProps & { slugs: Record<string, string>; submitMode: boolean },
|
|
129
|
+
): Promise<string> {
|
|
111
130
|
const width = process.stdout.columns ?? 80
|
|
112
131
|
return renderString(<Report {...props} />, { width })
|
|
113
132
|
}
|
package/src/submit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { writeFileSync, unlinkSync } from "node:fs"
|
|
|
6
6
|
import { tmpdir } from "node:os"
|
|
7
7
|
import { join } from "node:path"
|
|
8
8
|
import { execFileSync } from "node:child_process"
|
|
9
|
+
import { createInterface } from "node:readline"
|
|
9
10
|
|
|
10
11
|
const REPO = "beorn/terminfo.dev"
|
|
11
12
|
|
|
@@ -18,9 +19,46 @@ interface SubmitData {
|
|
|
18
19
|
notes: Record<string, string>
|
|
19
20
|
responses: Record<string, string>
|
|
20
21
|
generated: string
|
|
22
|
+
cliVersion?: string
|
|
23
|
+
probeCount?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function prompt(question: string, defaultValue?: string): Promise<string> {
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
28
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : ""
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
31
|
+
rl.close()
|
|
32
|
+
resolve(answer.trim() || defaultValue || "")
|
|
33
|
+
})
|
|
34
|
+
})
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
export async function submitResults(data: SubmitData): Promise<string | null> {
|
|
38
|
+
// Confirm/fill terminal info
|
|
39
|
+
console.log(`\n\x1b[1mConfirm submission details:\x1b[0m`)
|
|
40
|
+
data.terminal = await prompt(" Terminal name", data.terminal)
|
|
41
|
+
data.terminalVersion = await prompt(" Terminal version", data.terminalVersion || undefined)
|
|
42
|
+
data.os = await prompt(" Operating system", data.os)
|
|
43
|
+
|
|
44
|
+
if (!data.terminalVersion) {
|
|
45
|
+
console.log(`\x1b[33m ⚠ No version specified — results will be less useful\x1b[0m`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check for duplicates
|
|
49
|
+
if (hasGhCli()) {
|
|
50
|
+
const existing = checkDuplicate(data.terminal, data.terminalVersion, data.os)
|
|
51
|
+
if (existing) {
|
|
52
|
+
console.log(`\n\x1b[33m⚠ A submission already exists for ${data.terminal}${data.terminalVersion ? ` ${data.terminalVersion}` : ""} on ${data.os}:\x1b[0m`)
|
|
53
|
+
console.log(` ${existing}`)
|
|
54
|
+
const proceed = await prompt(" Submit anyway? (y/N)", "N")
|
|
55
|
+
if (proceed.toLowerCase() !== "y") {
|
|
56
|
+
console.log(`Skipped.`)
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
24
62
|
const passed = Object.values(data.results).filter(Boolean).length
|
|
25
63
|
const total = Object.keys(data.results).length
|
|
26
64
|
const pct = Math.round((passed / total) * 100)
|
|
@@ -35,6 +73,8 @@ export async function submitResults(data: SubmitData): Promise<string | null> {
|
|
|
35
73
|
| Version | ${data.terminalVersion || "unknown"} |
|
|
36
74
|
| OS | ${data.os} ${data.osVersion || ""} |
|
|
37
75
|
| Score | ${passed}/${total} (${pct}%) |
|
|
76
|
+
| CLI Version | ${data.cliVersion ?? "unknown"} |
|
|
77
|
+
| Probes | ${data.probeCount ?? total} |
|
|
38
78
|
| Generated | ${data.generated} |
|
|
39
79
|
|
|
40
80
|
### Summary
|
|
@@ -51,7 +91,7 @@ ${JSON.stringify(data, null, 2)}
|
|
|
51
91
|
</details>
|
|
52
92
|
|
|
53
93
|
---
|
|
54
|
-
*Submitted via \`npx terminfo.dev\`*`
|
|
94
|
+
*Submitted via \`npx terminfo.dev submit\`*`
|
|
55
95
|
|
|
56
96
|
if (!hasGhCli()) {
|
|
57
97
|
const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
|
|
@@ -61,7 +101,6 @@ ${JSON.stringify(data, null, 2)}
|
|
|
61
101
|
return null
|
|
62
102
|
}
|
|
63
103
|
|
|
64
|
-
// Write body to temp file to avoid shell escaping issues
|
|
65
104
|
const bodyFile = join(tmpdir(), `terminfo-submit-${Date.now()}.md`)
|
|
66
105
|
try {
|
|
67
106
|
writeFileSync(bodyFile, body)
|
|
@@ -90,6 +129,25 @@ function hasGhCli(): boolean {
|
|
|
90
129
|
}
|
|
91
130
|
}
|
|
92
131
|
|
|
132
|
+
function checkDuplicate(terminal: string, version: string, os: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
const search = `[census] ${terminal}${version ? ` ${version}` : ""} on ${os}`
|
|
135
|
+
const result = execFileSync("gh", [
|
|
136
|
+
"issue", "list",
|
|
137
|
+
"--repo", REPO,
|
|
138
|
+
"--search", search,
|
|
139
|
+
"--state", "all",
|
|
140
|
+
"--limit", "1",
|
|
141
|
+
"--json", "url,title",
|
|
142
|
+
"--jq", ".[0] | .title + \" \" + .url",
|
|
143
|
+
], { encoding: "utf-8", timeout: 10000 })
|
|
144
|
+
const trimmed = result.trim()
|
|
145
|
+
return trimmed || null
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
93
151
|
function formatSummary(data: SubmitData): string {
|
|
94
152
|
const categories = new Map<string, { pass: number; fail: number; failList: string[] }>()
|
|
95
153
|
for (const [id, pass] of Object.entries(data.results)) {
|