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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Test your terminal's feature support and contribute to terminfo.dev",
5
5
  "keywords": [
6
6
  "ansi",
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, DA2 response parsing, and fallback heuristics.
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; versionEnv?: 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
- return { name, version: "", os, osVersion }
58
+ name = n
59
+ break
44
60
  }
45
61
  }
46
62
 
47
63
  // Check $TERM_PROGRAM
48
- const termProgram = process.env.TERM_PROGRAM
49
- if (termProgram) {
50
- const name = TERM_PROGRAM_MAP[termProgram] ?? termProgram.toLowerCase()
51
- const version = process.env.TERM_PROGRAM_VERSION ?? ""
52
- return { name, version, os, osVersion }
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
- // Check $TERMINAL_EMULATOR (set by some Linux terminals)
56
- const termEmu = process.env.TERMINAL_EMULATOR
57
- if (termEmu) {
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
- // Fallback: use $TERM
62
- const term = process.env.TERM ?? "unknown"
63
- return { name: term, version: "", os, osVersion }
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
- return "macos"
70
- case "linux":
71
- return "linux"
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(` Probes: ${ALL_PROBES.length} features across ${new Set(ALL_PROBES.map(p => p.id.split(".")[0])).size} categories`)
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(JSON.stringify({
149
- terminal: data.terminal.name,
150
- terminalVersion: data.terminal.version,
151
- os: data.terminal.os,
152
- osVersion: data.terminal.osVersion,
153
- source: "community",
154
- generated: new Date().toISOString(),
155
- results: data.results,
156
- notes: data.notes,
157
- responses: data.responses,
158
- }, null, 2))
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>{terminal}{terminalVersion ? ` ${terminalVersion}` : ""}</Text>
53
- <Text>{os} {osVersion}</Text>
54
- <Text>{probeCount} features, {categoryCount} categories</Text>
55
- <Text bold color={pct === 100 ? "green" : pct >= 90 ? "yellow" : "red"}>{passed}/{total} ({pct}%)</Text>
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({ name, probes, slugs }: { name: string; probes: ProbeResult[]; slugs: Record<string, string> }) {
63
- const catPassed = probes.filter(p => p.pass).length
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: npx terminfo.dev --json</Text>
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(props: ReportProps & { slugs: Record<string, string>; submitMode: boolean }): Promise<string> {
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)) {