terminfo.dev 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "0.7.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",
@@ -32,6 +32,9 @@
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
+ "dependencies": {
36
+ "commander": "^14.0.0"
37
+ },
35
38
  "engines": {
36
39
  "node": ">=23.6.0"
37
40
  }
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,60 +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
- /** OSC 8 hyperlink — clickable link in supporting terminals */
46
+ /** OSC 8 hyperlink */
48
47
  function link(url: string, text: string): string {
49
48
  return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
50
49
  }
51
50
 
52
- interface ResultEntry {
53
- terminal: string
54
- terminalVersion: string
55
- os: string
56
- osVersion: string
57
- source: "community"
58
- generated: string
51
+ interface ProbeResults {
52
+ terminal: ReturnType<typeof detectTerminal>
59
53
  results: Record<string, boolean>
60
54
  notes: Record<string, string>
61
55
  responses: Record<string, string>
56
+ passed: number
57
+ total: number
62
58
  }
63
59
 
64
- async function main() {
65
- const args = process.argv.slice(2)
66
- const jsonMode = args.includes("--json")
67
- const submitMode = args.includes("--submit")
68
-
69
- // Detect terminal
60
+ async function runProbes(): Promise<ProbeResults> {
70
61
  const terminal = detectTerminal()
71
-
72
- if (!jsonMode) {
73
- console.log(`\x1b[1mterminfo.dev\x1b[0m — can your terminal do that?\n`)
74
- console.log(` Terminal: \x1b[1m${terminal.name}\x1b[0m${terminal.version ? ` ${terminal.version}` : ""}`)
75
- console.log(` Platform: ${terminal.os}${terminal.osVersion ? ` ${terminal.osVersion}` : ""}`)
76
- console.log(` Probes: ${ALL_PROBES.length} features across ${new Set(ALL_PROBES.map(p => p.id.split(".")[0])).size} categories`)
77
- console.log(` Website: https://terminfo.dev`)
78
- console.log(``)
79
- console.log(`\x1b[2mResults are compared against ${ALL_PROBES.length} terminal features from the`)
80
- console.log(`ECMA-48, VT100/VT510, xterm, and Kitty specifications.`)
81
- console.log(`Run with --submit to contribute your results to the database.\x1b[0m\n`)
82
- console.log(`Running probes...`)
83
- }
84
-
85
62
  const results: Record<string, boolean> = {}
86
63
  const notes: Record<string, string> = {}
87
64
  const responses: Record<string, string> = {}
88
65
  let passed = 0
89
- let failed = 0
90
66
 
91
67
  await withRawMode(async () => {
92
- // Enter alt screen inside raw mode so all probe output stays contained
93
- process.stdout.write("\x1b[?1049h") // alt screen
94
- process.stdout.write("\x1b[2J\x1b[H") // clear + home
95
-
68
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H")
96
69
  for (const probe of ALL_PROBES) {
97
- // Clear screen before each probe to prevent leaking output
98
70
  process.stdout.write("\x1b[2J\x1b[H")
99
71
  try {
100
72
  const result = await probe.run()
@@ -102,43 +74,34 @@ async function main() {
102
74
  if (result.note) notes[probe.id] = result.note
103
75
  if (result.response) responses[probe.id] = result.response
104
76
  if (result.pass) passed++
105
- else failed++
106
77
  } catch (err) {
107
78
  results[probe.id] = false
108
79
  notes[probe.id] = `error: ${err instanceof Error ? err.message : String(err)}`
109
- failed++
110
80
  }
111
81
  }
112
-
113
- // Exit alt screen while still in raw mode
114
82
  process.stdout.write("\x1b[?1049l")
115
83
  })
116
84
 
117
- const total = ALL_PROBES.length
118
- const pct = Math.round((passed / total) * 100)
85
+ return { terminal, results, notes, responses, passed, total: ALL_PROBES.length }
86
+ }
119
87
 
120
- const entry: ResultEntry = {
121
- terminal: terminal.name,
122
- terminalVersion: terminal.version,
123
- os: terminal.os,
124
- osVersion: terminal.osVersion,
125
- source: "community",
126
- generated: new Date().toISOString(),
127
- results,
128
- notes,
129
- responses,
130
- }
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
+ }
131
96
 
132
- if (jsonMode) {
133
- console.log(JSON.stringify(entry, null, 2))
134
- return
135
- }
97
+ function printResults(data: ProbeResults) {
98
+ const { passed, total } = data
99
+ const pct = Math.round((passed / total) * 100)
100
+ const slugs = loadFeatureSlugs()
136
101
 
137
- // Display results
138
- console.log(`\n\x1b[1mResults: ${passed}/${total} (${pct}%)\x1b[0m\n`)
102
+ printHeader(data.terminal)
103
+ console.log(` Score: \x1b[1m${passed}/${total} (${pct}%)\x1b[0m\n`)
139
104
 
140
- // Show categories with OSC 8 hyperlinks
141
- const slugs = loadFeatureSlugs()
142
105
  const categories = new Map<string, Array<{ id: string; name: string; pass: boolean; note?: string }>>()
143
106
  for (const probe of ALL_PROBES) {
144
107
  const cat = probe.id.split(".")[0]!
@@ -146,13 +109,13 @@ async function main() {
146
109
  categories.get(cat)!.push({
147
110
  id: probe.id,
148
111
  name: probe.name,
149
- pass: results[probe.id] ?? false,
150
- note: notes[probe.id],
112
+ pass: data.results[probe.id] ?? false,
113
+ note: data.notes[probe.id],
151
114
  })
152
115
  }
153
116
 
154
117
  for (const [cat, probes] of categories) {
155
- const catPassed = probes.filter((p) => p.pass).length
118
+ const catPassed = probes.filter(p => p.pass).length
156
119
  const color = catPassed === probes.length ? "\x1b[32m" : catPassed > 0 ? "\x1b[33m" : "\x1b[31m"
157
120
  const catLink = link(`https://terminfo.dev/${cat}`, cat)
158
121
  console.log(`${color}${catLink}\x1b[0m (${catPassed}/${probes.length})`)
@@ -160,25 +123,84 @@ async function main() {
160
123
  const icon = p.pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"
161
124
  const note = p.note ? ` \x1b[2m— ${p.note}\x1b[0m` : ""
162
125
  const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
163
- const cat = p.id.split(".")[0]!
164
- const featureLink = link(`https://terminfo.dev/${cat}/${slug}`, p.name)
126
+ const fCat = p.id.split(".")[0]!
127
+ const featureLink = link(`https://terminfo.dev/${fCat}/${slug}`, p.name)
165
128
  console.log(` ${icon} ${featureLink}${note}`)
166
129
  }
167
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)
163
+ })
164
+
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)
168
171
 
169
- if (submitMode) {
170
172
  console.log(`\nSubmitting results to terminfo.dev...`)
171
- 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
+ })
172
183
  if (url) {
173
- console.log(`\x1b[32m✓ Issue created:\x1b[0m ${url}`)
184
+ console.log(`\x1b[32m✓ Issue created:\x1b[0m ${link(url, url)}`)
174
185
  }
175
- } else {
176
- console.log(`\n\x1b[2mSubmit results to terminfo.dev: npx terminfo --submit\x1b[0m`)
177
- console.log(`\x1b[2mJSON output: npx terminfo --json\x1b[0m`)
178
- }
179
- }
186
+ })
180
187
 
181
- main().catch((err) => {
182
- console.error(err)
183
- 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`)
184
204
  })
205
+
206
+ program.parse()
package/src/report.tsx ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Silvery-rendered CLI report for terminfo probe results.
3
+ */
4
+ import React from "react"
5
+ import { Box, Text } from "silvery"
6
+ import { renderString } from "silvery"
7
+
8
+ interface ProbeResult {
9
+ id: string
10
+ name: string
11
+ pass: boolean
12
+ note?: string
13
+ }
14
+
15
+ interface ReportProps {
16
+ terminal: string
17
+ terminalVersion: string
18
+ os: string
19
+ osVersion: string
20
+ probeCount: number
21
+ categoryCount: number
22
+ passed: number
23
+ total: number
24
+ categories: Map<string, ProbeResult[]>
25
+ }
26
+
27
+ /** OSC 8 hyperlink wrapper */
28
+ function osc8(url: string, text: string): string {
29
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
30
+ }
31
+
32
+ function featureUrl(id: string, slug: string): string {
33
+ const cat = id.split(".")[0]!
34
+ return `https://terminfo.dev/${cat}/${slug}`
35
+ }
36
+
37
+ function Header({ terminal, terminalVersion, os, osVersion, probeCount, categoryCount, passed, total }: ReportProps) {
38
+ const pct = Math.round((passed / total) * 100)
39
+ return (
40
+ <Box flexDirection="column" marginBottom={1}>
41
+ <Text bold>{osc8("https://terminfo.dev", "terminfo.dev")}</Text>
42
+ <Text dimColor>Can your terminal do that?</Text>
43
+ <Text> </Text>
44
+ <Box flexDirection="row" gap={2}>
45
+ <Box flexDirection="column" width={12}>
46
+ <Text dimColor>Terminal</Text>
47
+ <Text dimColor>Platform</Text>
48
+ <Text dimColor>Probes</Text>
49
+ <Text dimColor>Score</Text>
50
+ </Box>
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>
56
+ </Box>
57
+ </Box>
58
+ </Box>
59
+ )
60
+ }
61
+
62
+ function CategorySection({ name, probes, slugs }: { name: string; probes: ProbeResult[]; slugs: Record<string, string> }) {
63
+ const catPassed = probes.filter(p => p.pass).length
64
+ const allPassed = catPassed === probes.length
65
+ const catUrl = `https://terminfo.dev/${name}`
66
+
67
+ return (
68
+ <Box flexDirection="column">
69
+ <Text color={allPassed ? "green" : catPassed > 0 ? "yellow" : "red"}>
70
+ {osc8(catUrl, name)} ({catPassed}/{probes.length})
71
+ </Text>
72
+ {probes.map(p => {
73
+ const slug = slugs[p.id] ?? p.id.replaceAll(".", "-")
74
+ const url = featureUrl(p.id, slug)
75
+ const icon = p.pass ? "✓" : "✗"
76
+ return (
77
+ <Box key={p.id} flexDirection="row" paddingLeft={2}>
78
+ <Text color={p.pass ? "green" : "red"}>{icon} </Text>
79
+ <Text>{osc8(url, p.name)}</Text>
80
+ {p.note && <Text dimColor> — {p.note}</Text>}
81
+ </Box>
82
+ )
83
+ })}
84
+ </Box>
85
+ )
86
+ }
87
+
88
+ function Footer({ submitMode }: { submitMode: boolean }) {
89
+ if (submitMode) return null
90
+ return (
91
+ <Box flexDirection="column" marginTop={1}>
92
+ <Text dimColor>Submit: npx terminfo.dev --submit</Text>
93
+ <Text dimColor>JSON: npx terminfo.dev --json</Text>
94
+ </Box>
95
+ )
96
+ }
97
+
98
+ function Report(props: ReportProps & { slugs: Record<string, string>; submitMode: boolean }) {
99
+ return (
100
+ <Box flexDirection="column">
101
+ <Header {...props} />
102
+ {[...props.categories.entries()].map(([name, probes]) => (
103
+ <CategorySection key={name} name={name} probes={probes} slugs={props.slugs} />
104
+ ))}
105
+ <Footer submitMode={props.submitMode} />
106
+ </Box>
107
+ )
108
+ }
109
+
110
+ export async function renderReport(props: ReportProps & { slugs: Record<string, string>; submitMode: boolean }): Promise<string> {
111
+ const width = process.stdout.columns ?? 80
112
+ return renderString(<Report {...props} />, { width })
113
+ }
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
  }