terminfo.dev 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +122 -78
  3. package/src/submit.ts +41 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Test your terminal's feature support and contribute to terminfo.dev",
5
5
  "keywords": [
6
6
  "ansi",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "silvery": "^0.4.3"
36
+ "commander": "^14.0.0"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=23.6.0"
package/src/index.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * terminfo CLI — test your terminal's feature support.
3
+ * terminfo.dev CLI — can your terminal do that?
4
4
  *
5
- * Runs probes against the real terminal you're using (not a headless library),
6
- * shows results, and optionally submits them to terminfo.dev.
5
+ * Test your terminal's feature support and contribute results to terminfo.dev.
7
6
  *
8
7
  * @example
9
8
  * ```bash
10
- * npx terminfo # Run all probes
11
- * npx terminfo --json # Output JSON results
12
- * npx terminfo --submit # Submit results to terminfo.dev
9
+ * npx terminfo.dev # Show terminal info + help
10
+ * npx terminfo.dev probe # Run all probes
11
+ * npx terminfo.dev submit # Run probes + submit results
13
12
  * ```
14
13
  */
15
14
 
15
+ import { Command } from "commander"
16
16
  import { readFileSync } from "node:fs"
17
17
  import { dirname, join } from "node:path"
18
18
  import { fileURLToPath } from "node:url"
@@ -25,10 +25,9 @@ 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
- // Try repo-local path first, then npm-installed path
29
28
  const candidates = [
30
- join(__dirname, "..", "..", "features.json"), // repo: cli/src/ -> features.json
31
- join(__dirname, "..", "..", "..", "features.json"), // npm: node_modules/terminfo.dev/src/ -> features.json
29
+ join(__dirname, "..", "..", "features.json"),
30
+ join(__dirname, "..", "..", "..", "features.json"),
32
31
  ]
33
32
  for (const path of candidates) {
34
33
  try {
@@ -41,42 +40,33 @@ function loadFeatureSlugs(): Record<string, string> {
41
40
  return slugs
42
41
  } catch {}
43
42
  }
44
- return {} // fallback: featureSlug() will use id.replaceAll(".", "-")
43
+ return {}
45
44
  }
46
45
 
47
- interface ResultEntry {
48
- terminal: string
49
- terminalVersion: string
50
- os: string
51
- osVersion: string
52
- source: "community"
53
- generated: string
46
+ /** OSC 8 hyperlink */
47
+ function link(url: string, text: string): string {
48
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
49
+ }
50
+
51
+ interface ProbeResults {
52
+ terminal: ReturnType<typeof detectTerminal>
54
53
  results: Record<string, boolean>
55
54
  notes: Record<string, string>
56
55
  responses: Record<string, string>
56
+ passed: number
57
+ total: number
57
58
  }
58
59
 
59
- async function main() {
60
- const args = process.argv.slice(2)
61
- const jsonMode = args.includes("--json")
62
- const submitMode = args.includes("--submit")
63
-
64
- // Detect terminal
60
+ async function runProbes(): Promise<ProbeResults> {
65
61
  const terminal = detectTerminal()
66
-
67
62
  const results: Record<string, boolean> = {}
68
63
  const notes: Record<string, string> = {}
69
64
  const responses: Record<string, string> = {}
70
65
  let passed = 0
71
- let failed = 0
72
66
 
73
67
  await withRawMode(async () => {
74
- // Enter alt screen inside raw mode so all probe output stays contained
75
- process.stdout.write("\x1b[?1049h") // alt screen
76
- process.stdout.write("\x1b[2J\x1b[H") // clear + home
77
-
68
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H")
78
69
  for (const probe of ALL_PROBES) {
79
- // Clear screen before each probe to prevent leaking output
80
70
  process.stdout.write("\x1b[2J\x1b[H")
81
71
  try {
82
72
  const result = await probe.run()
@@ -84,40 +74,34 @@ async function main() {
84
74
  if (result.note) notes[probe.id] = result.note
85
75
  if (result.response) responses[probe.id] = result.response
86
76
  if (result.pass) passed++
87
- else failed++
88
77
  } catch (err) {
89
78
  results[probe.id] = false
90
79
  notes[probe.id] = `error: ${err instanceof Error ? err.message : String(err)}`
91
- failed++
92
80
  }
93
81
  }
94
-
95
- // Exit alt screen while still in raw mode
96
82
  process.stdout.write("\x1b[?1049l")
97
83
  })
98
84
 
99
- const total = ALL_PROBES.length
100
- const pct = Math.round((passed / total) * 100)
101
-
102
- const entry: ResultEntry = {
103
- terminal: terminal.name,
104
- terminalVersion: terminal.version,
105
- os: terminal.os,
106
- osVersion: terminal.osVersion,
107
- source: "community",
108
- generated: new Date().toISOString(),
109
- results,
110
- notes,
111
- responses,
112
- }
85
+ return { terminal, results, notes, responses, passed, total: ALL_PROBES.length }
86
+ }
113
87
 
114
- if (jsonMode) {
115
- console.log(JSON.stringify(entry, null, 2))
116
- return
117
- }
88
+ function printHeader(terminal: ReturnType<typeof detectTerminal>) {
89
+ const siteLink = link("https://terminfo.dev", "terminfo.dev")
90
+ console.log(`\x1b[1m${siteLink}\x1b[0m — can your terminal do that?\n`)
91
+ console.log(` Terminal: \x1b[1m${terminal.name}\x1b[0m${terminal.version ? ` ${terminal.version}` : ""}`)
92
+ 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`)
94
+ console.log(` Website: ${link("https://terminfo.dev", "https://terminfo.dev")}`)
95
+ }
118
96
 
119
- // Build category data for report
97
+ function printResults(data: ProbeResults) {
98
+ const { passed, total } = data
99
+ const pct = Math.round((passed / total) * 100)
120
100
  const slugs = loadFeatureSlugs()
101
+
102
+ printHeader(data.terminal)
103
+ console.log(` Score: \x1b[1m${passed}/${total} (${pct}%)\x1b[0m\n`)
104
+
121
105
  const categories = new Map<string, Array<{ id: string; name: string; pass: boolean; note?: string }>>()
122
106
  for (const probe of ALL_PROBES) {
123
107
  const cat = probe.id.split(".")[0]!
@@ -125,38 +109,98 @@ async function main() {
125
109
  categories.get(cat)!.push({
126
110
  id: probe.id,
127
111
  name: probe.name,
128
- pass: results[probe.id] ?? false,
129
- note: notes[probe.id],
112
+ pass: data.results[probe.id] ?? false,
113
+ note: data.notes[probe.id],
130
114
  })
131
115
  }
132
116
 
133
- // Render with silvery
134
- const { renderReport } = await import("./report.tsx")
135
- const output = await renderReport({
136
- terminal: terminal.name,
137
- terminalVersion: terminal.version,
138
- os: terminal.os,
139
- osVersion: terminal.osVersion,
140
- probeCount: total,
141
- categoryCount: new Set(ALL_PROBES.map(p => p.id.split(".")[0])).size,
142
- passed,
143
- total,
144
- categories,
145
- slugs,
146
- submitMode,
117
+ for (const [cat, probes] of categories) {
118
+ const catPassed = probes.filter(p => p.pass).length
119
+ const color = catPassed === probes.length ? "\x1b[32m" : catPassed > 0 ? "\x1b[33m" : "\x1b[31m"
120
+ const catLink = link(`https://terminfo.dev/${cat}`, cat)
121
+ console.log(`${color}${catLink}\x1b[0m (${catPassed}/${probes.length})`)
122
+ for (const p of probes) {
123
+ const icon = p.pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"
124
+ const note = p.note ? ` \x1b[2m— ${p.note}\x1b[0m` : ""
125
+ const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
126
+ const fCat = p.id.split(".")[0]!
127
+ const featureLink = link(`https://terminfo.dev/${fCat}/${slug}`, p.name)
128
+ console.log(` ${icon} ${featureLink}${note}`)
129
+ }
130
+ }
131
+ }
132
+
133
+ // ── CLI ──
134
+
135
+ const program = new Command()
136
+ .name("terminfo.dev")
137
+ .description("Can your terminal do that? — test terminal feature support and contribute to terminfo.dev")
138
+ .version("1.1.0")
139
+
140
+ program
141
+ .command("probe")
142
+ .description("Run all probes against your terminal and display results")
143
+ .option("--json", "Output results as JSON")
144
+ .action(async (opts) => {
145
+ const data = await runProbes()
146
+
147
+ 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))
159
+ return
160
+ }
161
+
162
+ printResults(data)
147
163
  })
148
- console.log(output)
149
164
 
150
- if (submitMode) {
165
+ program
166
+ .command("submit")
167
+ .description("Run all probes and submit results to terminfo.dev via GitHub issue")
168
+ .action(async () => {
169
+ const data = await runProbes()
170
+ printResults(data)
171
+
151
172
  console.log(`\nSubmitting results to terminfo.dev...`)
152
- const url = await submitResults(entry)
173
+ const url = await submitResults({
174
+ terminal: data.terminal.name,
175
+ terminalVersion: data.terminal.version,
176
+ os: data.terminal.os,
177
+ osVersion: data.terminal.osVersion,
178
+ results: data.results,
179
+ notes: data.notes,
180
+ responses: data.responses,
181
+ generated: new Date().toISOString(),
182
+ })
153
183
  if (url) {
154
- console.log(`\x1b[32m✓ Issue created:\x1b[0m ${url}`)
184
+ console.log(`\x1b[32m✓ Issue created:\x1b[0m ${link(url, url)}`)
155
185
  }
156
- }
157
- }
186
+ })
158
187
 
159
- main().catch((err) => {
160
- console.error(err)
161
- process.exit(1)
188
+ // Default action: show terminal info + help
189
+ program.action(() => {
190
+ const terminal = detectTerminal()
191
+ printHeader(terminal)
192
+ console.log(``)
193
+ console.log(`\x1b[2mTest your terminal against ${ALL_PROBES.length} features from the ECMA-48,`)
194
+ console.log(`VT100/VT510, xterm, and Kitty specifications. Results can be`)
195
+ console.log(`submitted to the community database at terminfo.dev.\x1b[0m`)
196
+ console.log(``)
197
+ console.log(`Commands:`)
198
+ console.log(` \x1b[1mprobe\x1b[0m Run all probes and display results`)
199
+ console.log(` \x1b[1msubmit\x1b[0m Run probes and submit to terminfo.dev`)
200
+ console.log(``)
201
+ console.log(`Options:`)
202
+ console.log(` \x1b[1m--json\x1b[0m Output results as JSON (with probe command)`)
203
+ console.log(` \x1b[1m--help\x1b[0m Show this help`)
162
204
  })
205
+
206
+ program.parse()
package/src/submit.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Submit results to terminfo.dev via GitHub issue.
3
- *
4
- * Creates an issue on beorn/terminfo.dev with the JSON results
5
- * attached as a code block. Maintainers review and merge into
6
- * the results database.
7
3
  */
8
4
 
5
+ import { writeFileSync, unlinkSync } from "node:fs"
6
+ import { tmpdir } from "node:os"
7
+ import { join } from "node:path"
8
+ import { execFileSync } from "node:child_process"
9
+
9
10
  const REPO = "beorn/terminfo.dev"
10
- const ISSUE_LABEL = "community-results"
11
11
 
12
- interface SubmitResult {
12
+ interface SubmitData {
13
13
  terminal: string
14
14
  terminalVersion: string
15
15
  os: string
@@ -20,11 +20,7 @@ interface SubmitResult {
20
20
  generated: string
21
21
  }
22
22
 
23
- /**
24
- * Submit results by creating a GitHub issue.
25
- * Requires `gh` CLI to be installed and authenticated.
26
- */
27
- export async function submitResults(data: SubmitResult): Promise<string | null> {
23
+ export async function submitResults(data: SubmitData): Promise<string | null> {
28
24
  const passed = Object.values(data.results).filter(Boolean).length
29
25
  const total = Object.keys(data.results).length
30
26
  const pct = Math.round((passed / total) * 100)
@@ -41,10 +37,12 @@ export async function submitResults(data: SubmitResult): Promise<string | null>
41
37
  | Score | ${passed}/${total} (${pct}%) |
42
38
  | Generated | ${data.generated} |
43
39
 
44
- ### Results
40
+ ### Summary
41
+
42
+ ${formatSummary(data)}
45
43
 
46
44
  <details>
47
- <summary>Full JSON (click to expand)</summary>
45
+ <summary>Full JSON</summary>
48
46
 
49
47
  \`\`\`json
50
48
  ${JSON.stringify(data, null, 2)}
@@ -52,55 +50,48 @@ ${JSON.stringify(data, null, 2)}
52
50
 
53
51
  </details>
54
52
 
55
- ### Summary
56
-
57
- ${formatSummary(data)}
58
-
59
53
  ---
60
- *Submitted via \`npx terminfo\`*`
61
-
62
- // Try gh CLI first
63
- if (await hasGhCli()) {
64
- try {
65
- const { execSync } = await import("node:child_process")
66
- const result = execSync(
67
- `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --label ${ISSUE_LABEL}`,
68
- { encoding: "utf-8", timeout: 30000 },
69
- )
70
- const url = result.trim()
71
- return url
72
- } catch (err) {
73
- console.error(`\x1b[31mFailed to create issue via gh CLI\x1b[0m`)
74
- console.error(err instanceof Error ? err.message : String(err))
75
- return null
76
- }
54
+ *Submitted via \`npx terminfo.dev\`*`
55
+
56
+ if (!hasGhCli()) {
57
+ const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
58
+ writeFileSync(filename, JSON.stringify(data, null, 2))
59
+ console.log(`\n\x1b[33mgh CLI not found. Results saved to ${filename}\x1b[0m`)
60
+ console.log(`To submit: https://github.com/${REPO}/issues/new`)
61
+ return null
77
62
  }
78
63
 
79
- // Fallback: write to file and give instructions
80
- const { writeFileSync } = await import("node:fs")
81
- const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
82
- writeFileSync(filename, JSON.stringify(data, null, 2))
83
- console.log(`\n\x1b[33mgh CLI not found. Results saved to ${filename}\x1b[0m`)
84
- console.log(`To submit manually:`)
85
- console.log(` 1. Go to https://github.com/${REPO}/issues/new`)
86
- console.log(` 2. Title: ${title}`)
87
- console.log(` 3. Paste the contents of ${filename}`)
88
- return null
64
+ // Write body to temp file to avoid shell escaping issues
65
+ const bodyFile = join(tmpdir(), `terminfo-submit-${Date.now()}.md`)
66
+ try {
67
+ writeFileSync(bodyFile, body)
68
+ const result = execFileSync("gh", [
69
+ "issue", "create",
70
+ "--repo", REPO,
71
+ "--title", title,
72
+ "--body-file", bodyFile,
73
+ ], { encoding: "utf-8", timeout: 30000 })
74
+ return result.trim()
75
+ } catch (err) {
76
+ console.error(`\x1b[31mFailed to create issue\x1b[0m`)
77
+ console.error(err instanceof Error ? err.message : String(err))
78
+ return null
79
+ } finally {
80
+ try { unlinkSync(bodyFile) } catch {}
81
+ }
89
82
  }
90
83
 
91
- async function hasGhCli(): Promise<boolean> {
84
+ function hasGhCli(): boolean {
92
85
  try {
93
- const { execSync } = await import("node:child_process")
94
- execSync("gh --version", { stdio: "ignore", timeout: 5000 })
86
+ execFileSync("gh", ["--version"], { stdio: "ignore", timeout: 5000 })
95
87
  return true
96
88
  } catch {
97
89
  return false
98
90
  }
99
91
  }
100
92
 
101
- function formatSummary(data: SubmitResult): string {
93
+ function formatSummary(data: SubmitData): string {
102
94
  const categories = new Map<string, { pass: number; fail: number; failList: string[] }>()
103
-
104
95
  for (const [id, pass] of Object.entries(data.results)) {
105
96
  const cat = id.split(".")[0]!
106
97
  if (!categories.has(cat)) categories.set(cat, { pass: 0, fail: 0, failList: [] })
@@ -112,14 +103,11 @@ function formatSummary(data: SubmitResult): string {
112
103
  entry.failList.push(note ? `- \`${id}\`: ${note}` : `- \`${id}\``)
113
104
  }
114
105
  }
115
-
116
106
  const lines: string[] = []
117
107
  for (const [cat, { pass, fail, failList }] of categories) {
118
- const total = pass + fail
119
108
  const icon = fail === 0 ? "✅" : "⚠️"
120
- lines.push(`${icon} **${cat}**: ${pass}/${total}`)
109
+ lines.push(`${icon} **${cat}**: ${pass}/${pass + fail}`)
121
110
  if (failList.length > 0) lines.push(...failList)
122
111
  }
123
-
124
112
  return lines.join("\n")
125
113
  }