terminfo.dev 3.0.0 → 3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Test your terminal's feature support and contribute to terminfo.dev",
5
5
  "keywords": [
6
6
  "ansi",
package/src/index.ts CHANGED
@@ -238,6 +238,67 @@ program
238
238
  }
239
239
  })
240
240
 
241
+ program
242
+ .command("serve")
243
+ .description("Start daemon — accepts remote probe requests from other sessions")
244
+ .option("-p, --port <port>", "Port to listen on (default: auto)", parseInt)
245
+ .action(async (opts) => {
246
+ const { startDaemon } = await import("./serve.ts")
247
+ await startDaemon(opts.port ?? 0)
248
+ })
249
+
250
+ program
251
+ .command("test-all")
252
+ .description("Run probes on all running daemons (started with 'serve')")
253
+ .action(async () => {
254
+ const { listDaemons } = await import("./serve.ts")
255
+ const daemons = listDaemons()
256
+
257
+ if (daemons.length === 0) {
258
+ console.log(`\x1b[33mNo daemons found.\x1b[0m`)
259
+ console.log(`Start a daemon in each terminal: \x1b[1mnpx terminfo.dev serve\x1b[0m`)
260
+ return
261
+ }
262
+
263
+ console.log(`\x1b[1mterminfo.dev\x1b[0m — testing ${daemons.length} terminal(s)\n`)
264
+
265
+ for (const d of daemons) {
266
+ const label = `${d.terminal}${d.terminalVersion ? ` ${d.terminalVersion}` : ""}`
267
+ process.stdout.write(` ${label.padEnd(25)} `)
268
+
269
+ try {
270
+ const res = await fetch(`http://127.0.0.1:${d.port}/probe`, { signal: AbortSignal.timeout(120000) })
271
+ if (!res.ok) {
272
+ console.log(`\x1b[31m✗ HTTP ${res.status}\x1b[0m`)
273
+ continue
274
+ }
275
+ const data = await res.json() as any
276
+ const passed = Object.values(data.results).filter((v: any) => v).length
277
+ const total = Object.keys(data.results).length
278
+ const pct = Math.round(passed / total * 100)
279
+ const color = pct >= 98 ? "\x1b[32m" : pct >= 90 ? "\x1b[33m" : "\x1b[31m"
280
+ console.log(`${color}${passed}/${total} (${pct}%)\x1b[0m`)
281
+
282
+ // Save results
283
+ const { mkdirSync, writeFileSync } = await import("node:fs")
284
+ const dir = "docs/data/results/app"
285
+ mkdirSync(dir, { recursive: true })
286
+ const name = data.terminal.replace(/[^a-z0-9-]/g, "-")
287
+ const ver = (data.terminalVersion || "unknown").replace(/[^a-z0-9.-]/g, "-")
288
+ writeFileSync(`${dir}/${name}-${ver}-${data.os}.json`, JSON.stringify(data, null, 2))
289
+ } catch (err) {
290
+ const msg = err instanceof Error ? err.message : String(err)
291
+ if (msg.includes("ECONNREFUSED")) {
292
+ console.log(`\x1b[31m✗ not running (stale daemon file)\x1b[0m`)
293
+ } else {
294
+ console.log(`\x1b[31m✗ ${msg}\x1b[0m`)
295
+ }
296
+ }
297
+ }
298
+
299
+ console.log(`\nResults saved to docs/data/results/app/`)
300
+ })
301
+
241
302
  // Default action: show terminal info + help
242
303
  program.action(() => {
243
304
  const terminal = detectTerminal()
@@ -248,12 +309,14 @@ program.action(() => {
248
309
  console.log(`submitted to the community database at terminfo.dev.\x1b[0m`)
249
310
  console.log(``)
250
311
  console.log(`Commands:`)
251
- console.log(` \x1b[1mprobe\x1b[0m Run all probes and display results`)
252
- console.log(` \x1b[1msubmit\x1b[0m Run probes and submit to terminfo.dev`)
312
+ console.log(` \x1b[1mprobe\x1b[0m Run all probes and display results`)
313
+ console.log(` \x1b[1msubmit\x1b[0m Run probes and submit to terminfo.dev`)
314
+ console.log(` \x1b[1mserve\x1b[0m Start daemon for remote testing`)
315
+ console.log(` \x1b[1mtest-all\x1b[0m Run probes on all daemons`)
253
316
  console.log(``)
254
317
  console.log(`Options:`)
255
- console.log(` \x1b[1m--json\x1b[0m Output results as JSON (with probe command)`)
256
- console.log(` \x1b[1m--help\x1b[0m Show this help`)
318
+ console.log(` \x1b[1m--json\x1b[0m Output results as JSON (with probe command)`)
319
+ console.log(` \x1b[1m--help\x1b[0m Show this help`)
257
320
  })
258
321
 
259
322
  program.parse()
@@ -1352,35 +1352,28 @@ export const ALL_PROBES: Probe[] = [
1352
1352
  },
1353
1353
  } satisfies Probe,
1354
1354
 
1355
- // Reflow: write long line, resize terminal smaller, check if text wraps
1355
+ // Reflow: test if terminal supports text reflow by writing a long line,
1356
+ // wrapping naturally, then checking cursor position is consistent.
1357
+ // Can't programmatically resize (some terminals block it or need permission),
1358
+ // so we test the prerequisite: auto-wrap + cursor tracking across wraps.
1356
1359
  {
1357
1360
  id: "extensions.reflow",
1358
1361
  name: "Text reflow on resize",
1359
1362
  async run() {
1360
- // Use XTWINOPS to query current size, resize smaller, check cursor, resize back
1361
- // CSI 18 t reports terminal size: ESC [ 8 ; rows ; cols t
1363
+ // Check if terminal reports its size (needed for reflow to work)
1362
1364
  const sizeMatch = await query("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/, 1000)
1363
- if (!sizeMatch) return { pass: false, note: "Terminal doesn't report size (XTWINOPS 18)" }
1364
- const origRows = parseInt(sizeMatch[1]!, 10)
1365
- const origCols = parseInt(sizeMatch[2]!, 10)
1366
- // Write a line that's exactly origCols wide
1367
- const testLine = "R".repeat(Math.min(origCols, 40))
1368
- process.stdout.write("\x1b[1;1H\x1b[2J") // clear
1369
- process.stdout.write(testLine)
1370
- // Resize to half width
1371
- const halfCols = Math.floor(origCols / 2)
1372
- process.stdout.write(`\x1b[8;${origRows};${halfCols}t`)
1373
- await new Promise(r => setTimeout(r, 200)) // wait for resize
1374
- // Query cursor — if reflow happened, cursor should be on row 2+
1365
+ if (!sizeMatch) return { pass: false, note: "No XTWINOPS 18 response (can't report size)" }
1366
+ const cols = parseInt(sizeMatch[2]!, 10)
1367
+ // Write a line longer than terminal width — verify it wraps correctly
1368
+ process.stdout.write("\x1b[1;1H\x1b[2J")
1369
+ const longLine = "W".repeat(cols + 5) // 5 chars past the edge
1370
+ process.stdout.write(longLine)
1375
1371
  const pos = await queryCursorPosition()
1376
- // Restore original size
1377
- process.stdout.write(`\x1b[8;${origRows};${origCols}t`)
1378
- await new Promise(r => setTimeout(r, 100))
1379
- if (!pos) return { pass: false, note: "No cursor response after resize" }
1380
- // If text reflowed to half width, cursor or content should span 2+ rows
1372
+ if (!pos) return { pass: false, note: "No cursor response" }
1373
+ // If auto-wrap works and terminal tracks wrapped content, cursor is on row 2, col 6
1381
1374
  return {
1382
- pass: pos[0] >= 2,
1383
- note: pos[0] >= 2 ? undefined : "Text didn't reflow after resize",
1375
+ pass: pos[0] === 2 && pos[1] === 6,
1376
+ note: pos[0] === 2 && pos[1] === 6 ? undefined : `cursor at ${pos[0]};${pos[1]}, expected 2;6`,
1384
1377
  }
1385
1378
  },
1386
1379
  } satisfies Probe,
@@ -1390,17 +1383,23 @@ export const ALL_PROBES: Probe[] = [
1390
1383
  id: "scrollback.accumulate",
1391
1384
  name: "Scrollback accumulates",
1392
1385
  async run() {
1386
+ // Get terminal height first
1387
+ const sizeMatch = await query("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/, 1000)
1388
+ const rows = sizeMatch ? parseInt(sizeMatch[1]!, 10) : 24
1393
1389
  process.stdout.write("\x1b[2J\x1b[H") // clear + home
1394
- // Write 30 lines (more than typical 24-row screen)
1395
- for (let i = 0; i < 30; i++) {
1390
+ // Write more lines than the screen can hold
1391
+ const lineCount = rows + 10
1392
+ for (let i = 0; i < lineCount; i++) {
1396
1393
  process.stdout.write(`line-${i}\n`)
1397
1394
  }
1398
- // Query cursor — should be near bottom
1399
1395
  const pos = await queryCursorPosition()
1400
1396
  if (!pos) return { pass: false, note: "No cursor response" }
1401
- // If scrollback works, cursor row should be <= screen height (content scrolled up)
1402
- // The fact that we wrote 30 lines and cursor isn't at row 31 means scrollback absorbed some
1403
- return { pass: pos[0] <= 25, note: pos[0] <= 25 ? undefined : `cursor at row ${pos[0]}` }
1397
+ // Cursor should be at or near the bottom row (content scrolled into scrollback)
1398
+ // NOT at lineCount+1 (which would mean terminal expanded instead of scrolling)
1399
+ return {
1400
+ pass: pos[0] <= rows,
1401
+ note: pos[0] <= rows ? undefined : `cursor at row ${pos[0]}, expected <= ${rows}`,
1402
+ }
1404
1403
  },
1405
1404
  } satisfies Probe,
1406
1405
 
package/src/serve.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Daemon mode — run inside a terminal, accept remote probe commands.
3
+ *
4
+ * Usage: npx terminfo.dev serve
5
+ *
6
+ * Starts an HTTP server that accepts probe requests. Run this in each
7
+ * terminal you want to test, then use `terminfo.dev test-all` or
8
+ * curl to run probes remotely.
9
+ *
10
+ * Discovery: writes terminal info + port to ~/.terminfo-dev/daemons/
11
+ * so clients can find all running daemons automatically.
12
+ */
13
+
14
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
15
+ import { mkdirSync, writeFileSync, unlinkSync, readdirSync, readFileSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import { homedir } from "node:os"
18
+ import { detectTerminal } from "./detect.ts"
19
+ import { ALL_PROBES } from "./probes/index.ts"
20
+ import { withRawMode, drainStdin } from "./tty.ts"
21
+
22
+ const DAEMON_DIR = join(homedir(), ".terminfo-dev", "daemons")
23
+
24
+ interface DaemonInfo {
25
+ pid: number
26
+ port: number
27
+ terminal: string
28
+ terminalVersion: string
29
+ os: string
30
+ osVersion: string
31
+ started: string
32
+ }
33
+
34
+ function register(info: DaemonInfo): string {
35
+ mkdirSync(DAEMON_DIR, { recursive: true })
36
+ const filename = `${info.terminal}-${info.pid}.json`
37
+ const filepath = join(DAEMON_DIR, filename)
38
+ writeFileSync(filepath, JSON.stringify(info, null, 2))
39
+ return filepath
40
+ }
41
+
42
+ function unregister(filepath: string) {
43
+ try { unlinkSync(filepath) } catch {}
44
+ }
45
+
46
+ export function listDaemons(): DaemonInfo[] {
47
+ try {
48
+ const files = readdirSync(DAEMON_DIR).filter(f => f.endsWith(".json"))
49
+ const daemons: DaemonInfo[] = []
50
+ for (const f of files) {
51
+ try {
52
+ const data = JSON.parse(readFileSync(join(DAEMON_DIR, f), "utf-8"))
53
+ daemons.push(data)
54
+ } catch {}
55
+ }
56
+ return daemons
57
+ } catch {
58
+ return []
59
+ }
60
+ }
61
+
62
+ export async function startDaemon(port = 0): Promise<void> {
63
+ const terminal = detectTerminal()
64
+
65
+ // Run all probes once synchronously, then serve results
66
+ // For live probing, we need the server to run probes on-demand in the terminal context
67
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
68
+ res.setHeader("Content-Type", "application/json")
69
+ res.setHeader("Access-Control-Allow-Origin", "*")
70
+
71
+ const url = new URL(req.url ?? "/", `http://localhost`)
72
+
73
+ if (url.pathname === "/info") {
74
+ res.end(JSON.stringify({
75
+ terminal: terminal.name,
76
+ terminalVersion: terminal.version,
77
+ os: terminal.os,
78
+ osVersion: terminal.osVersion,
79
+ probeCount: ALL_PROBES.length,
80
+ }))
81
+ return
82
+ }
83
+
84
+ if (url.pathname === "/probe") {
85
+ // Run all probes in this terminal
86
+ console.log(`\x1b[2m[${new Date().toISOString()}] Running ${ALL_PROBES.length} probes...\x1b[0m`)
87
+
88
+ const results: Record<string, boolean> = {}
89
+ const notes: Record<string, string> = {}
90
+ const responses: Record<string, string> = {}
91
+
92
+ await withRawMode(async () => {
93
+ for (const probe of ALL_PROBES) {
94
+ process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
95
+ try {
96
+ const result = await probe.run()
97
+ results[probe.id] = result.pass
98
+ if (result.note) notes[probe.id] = result.note
99
+ if (result.response) responses[probe.id] = result.response
100
+ } catch (err) {
101
+ results[probe.id] = false
102
+ notes[probe.id] = `error: ${err instanceof Error ? err.message : String(err)}`
103
+ }
104
+ }
105
+ process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
106
+ await drainStdin(1000)
107
+ })
108
+
109
+ // Reset terminal after probes
110
+ process.stdout.write("\x1bc")
111
+
112
+ const passed = Object.values(results).filter(v => v).length
113
+ const total = Object.keys(results).length
114
+ console.log(`\x1b[32m✓\x1b[0m ${passed}/${total} (${Math.round(passed / total * 100)}%)`)
115
+
116
+ res.end(JSON.stringify({
117
+ terminal: terminal.name,
118
+ terminalVersion: terminal.version,
119
+ os: terminal.os,
120
+ osVersion: terminal.osVersion,
121
+ source: "daemon",
122
+ generated: new Date().toISOString(),
123
+ results,
124
+ notes,
125
+ responses,
126
+ }))
127
+ return
128
+ }
129
+
130
+ if (url.pathname === "/probe/single") {
131
+ const probeId = url.searchParams.get("id")
132
+ if (!probeId) {
133
+ res.statusCode = 400
134
+ res.end(JSON.stringify({ error: "Missing ?id= parameter" }))
135
+ return
136
+ }
137
+ const probe = ALL_PROBES.find(p => p.id === probeId)
138
+ if (!probe) {
139
+ res.statusCode = 404
140
+ res.end(JSON.stringify({ error: `Unknown probe: ${probeId}` }))
141
+ return
142
+ }
143
+
144
+ await withRawMode(async () => {
145
+ process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
146
+ try {
147
+ const result = await probe.run()
148
+ process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
149
+ await drainStdin(500)
150
+ res.end(JSON.stringify({ id: probeId, ...result }))
151
+ } catch (err) {
152
+ process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
153
+ await drainStdin(500)
154
+ res.end(JSON.stringify({ id: probeId, pass: false, note: String(err) }))
155
+ }
156
+ })
157
+ process.stdout.write("\x1bc")
158
+ return
159
+ }
160
+
161
+ // Default: show help
162
+ res.end(JSON.stringify({
163
+ endpoints: {
164
+ "/info": "Terminal info",
165
+ "/probe": "Run all probes",
166
+ "/probe/single?id=sgr.bold": "Run single probe",
167
+ },
168
+ terminal: terminal.name,
169
+ version: terminal.version,
170
+ }))
171
+ })
172
+
173
+ server.listen(port, "127.0.0.1", () => {
174
+ const addr = server.address()
175
+ if (!addr || typeof addr === "string") return
176
+ const actualPort = addr.port
177
+
178
+ const info: DaemonInfo = {
179
+ pid: process.pid,
180
+ port: actualPort,
181
+ terminal: terminal.name,
182
+ terminalVersion: terminal.version,
183
+ os: terminal.os,
184
+ osVersion: terminal.osVersion,
185
+ started: new Date().toISOString(),
186
+ }
187
+
188
+ const filepath = register(info)
189
+
190
+ console.log(`\x1b[1mterminfo.dev\x1b[0m daemon running\n`)
191
+ console.log(` Terminal: \x1b[1m${terminal.name}\x1b[0m ${terminal.version}`)
192
+ console.log(` Port: \x1b[1m${actualPort}\x1b[0m`)
193
+ console.log(` Probes: ${ALL_PROBES.length}`)
194
+ console.log(``)
195
+ console.log(` Test: curl http://localhost:${actualPort}/probe`)
196
+ console.log(` Info: curl http://localhost:${actualPort}/info`)
197
+ console.log(` Single: curl http://localhost:${actualPort}/probe/single?id=sgr.bold`)
198
+ console.log(``)
199
+ console.log(`\x1b[2mPress Ctrl+C to stop.\x1b[0m`)
200
+
201
+ // Clean up on exit
202
+ const cleanup = () => {
203
+ unregister(filepath)
204
+ server.close()
205
+ process.exit(0)
206
+ }
207
+ process.on("SIGINT", cleanup)
208
+ process.on("SIGTERM", cleanup)
209
+ })
210
+ }