@ecmaos/coreutils 0.2.0 → 0.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.
Files changed (123) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +1 -1
  4. package/dist/commands/cal.js +2 -2
  5. package/dist/commands/cal.js.map +1 -1
  6. package/dist/commands/cat.js +2 -2
  7. package/dist/commands/cd.js +2 -2
  8. package/dist/commands/chmod.d.ts.map +1 -1
  9. package/dist/commands/chmod.js +16 -11
  10. package/dist/commands/chmod.js.map +1 -1
  11. package/dist/commands/cp.js +2 -2
  12. package/dist/commands/cp.js.map +1 -1
  13. package/dist/commands/date.js +2 -2
  14. package/dist/commands/date.js.map +1 -1
  15. package/dist/commands/echo.js +2 -2
  16. package/dist/commands/echo.js.map +1 -1
  17. package/dist/commands/false.js +2 -2
  18. package/dist/commands/fetch.d.ts +4 -0
  19. package/dist/commands/fetch.d.ts.map +1 -0
  20. package/dist/commands/fetch.js +210 -0
  21. package/dist/commands/fetch.js.map +1 -0
  22. package/dist/commands/format.d.ts +4 -0
  23. package/dist/commands/format.d.ts.map +1 -0
  24. package/dist/commands/format.js +178 -0
  25. package/dist/commands/format.js.map +1 -0
  26. package/dist/commands/hash.d.ts +4 -0
  27. package/dist/commands/hash.d.ts.map +1 -0
  28. package/dist/commands/hash.js +200 -0
  29. package/dist/commands/hash.js.map +1 -0
  30. package/dist/commands/head.js +2 -2
  31. package/dist/commands/id.js +2 -2
  32. package/dist/commands/id.js.map +1 -1
  33. package/dist/commands/less.d.ts.map +1 -1
  34. package/dist/commands/less.js +53 -2
  35. package/dist/commands/less.js.map +1 -1
  36. package/dist/commands/ls.d.ts.map +1 -1
  37. package/dist/commands/ls.js +120 -97
  38. package/dist/commands/ls.js.map +1 -1
  39. package/dist/commands/man.d.ts +4 -0
  40. package/dist/commands/man.d.ts.map +1 -0
  41. package/dist/commands/man.js +554 -0
  42. package/dist/commands/man.js.map +1 -0
  43. package/dist/commands/mkdir.js +2 -2
  44. package/dist/commands/mkdir.js.map +1 -1
  45. package/dist/commands/mktemp.d.ts +4 -0
  46. package/dist/commands/mktemp.d.ts.map +1 -0
  47. package/dist/commands/mktemp.js +229 -0
  48. package/dist/commands/mktemp.js.map +1 -0
  49. package/dist/commands/nc.js +2 -2
  50. package/dist/commands/nc.js.map +1 -1
  51. package/dist/commands/passkey.js +3 -3
  52. package/dist/commands/pwd.js +2 -2
  53. package/dist/commands/pwd.js.map +1 -1
  54. package/dist/commands/rm.d.ts.map +1 -1
  55. package/dist/commands/rm.js +57 -12
  56. package/dist/commands/rm.js.map +1 -1
  57. package/dist/commands/rmdir.js +2 -2
  58. package/dist/commands/rmdir.js.map +1 -1
  59. package/dist/commands/sockets.js +1 -1
  60. package/dist/commands/stat.d.ts.map +1 -1
  61. package/dist/commands/stat.js +37 -15
  62. package/dist/commands/stat.js.map +1 -1
  63. package/dist/commands/tail.js +2 -2
  64. package/dist/commands/tar.d.ts +4 -0
  65. package/dist/commands/tar.d.ts.map +1 -0
  66. package/dist/commands/tar.js +693 -0
  67. package/dist/commands/tar.js.map +1 -0
  68. package/dist/commands/touch.js +2 -2
  69. package/dist/commands/touch.js.map +1 -1
  70. package/dist/commands/true.js +2 -2
  71. package/dist/commands/unzip.d.ts +4 -0
  72. package/dist/commands/unzip.d.ts.map +1 -0
  73. package/dist/commands/unzip.js +443 -0
  74. package/dist/commands/unzip.js.map +1 -0
  75. package/dist/commands/user.d.ts +4 -0
  76. package/dist/commands/user.d.ts.map +1 -0
  77. package/dist/commands/user.js +427 -0
  78. package/dist/commands/user.js.map +1 -0
  79. package/dist/commands/whoami.js +2 -2
  80. package/dist/commands/whoami.js.map +1 -1
  81. package/dist/commands/zip.d.ts +4 -0
  82. package/dist/commands/zip.d.ts.map +1 -0
  83. package/dist/commands/zip.js +264 -0
  84. package/dist/commands/zip.js.map +1 -0
  85. package/dist/index.d.ts +9 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +28 -1
  88. package/dist/index.js.map +1 -1
  89. package/package.json +6 -4
  90. package/src/commands/cal.ts +2 -2
  91. package/src/commands/cat.ts +2 -2
  92. package/src/commands/cd.ts +2 -2
  93. package/src/commands/chmod.ts +19 -11
  94. package/src/commands/cp.ts +2 -2
  95. package/src/commands/date.ts +2 -2
  96. package/src/commands/echo.ts +2 -2
  97. package/src/commands/false.ts +2 -2
  98. package/src/commands/fetch.ts +205 -0
  99. package/src/commands/format.ts +204 -0
  100. package/src/commands/hash.ts +215 -0
  101. package/src/commands/head.ts +2 -2
  102. package/src/commands/id.ts +2 -2
  103. package/src/commands/less.ts +50 -2
  104. package/src/commands/ls.ts +131 -91
  105. package/src/commands/man.ts +643 -0
  106. package/src/commands/mkdir.ts +2 -2
  107. package/src/commands/mktemp.ts +235 -0
  108. package/src/commands/nc.ts +2 -2
  109. package/src/commands/passkey.ts +3 -3
  110. package/src/commands/pwd.ts +2 -2
  111. package/src/commands/rm.ts +54 -12
  112. package/src/commands/rmdir.ts +2 -2
  113. package/src/commands/sockets.ts +1 -1
  114. package/src/commands/stat.ts +44 -16
  115. package/src/commands/tail.ts +2 -2
  116. package/src/commands/tar.ts +737 -0
  117. package/src/commands/touch.ts +2 -2
  118. package/src/commands/true.ts +2 -2
  119. package/src/commands/unzip.ts +517 -0
  120. package/src/commands/user.ts +436 -0
  121. package/src/commands/whoami.ts +2 -2
  122. package/src/commands/zip.ts +319 -0
  123. package/src/index.ts +28 -1
@@ -0,0 +1,643 @@
1
+ import ansi from 'ansi-escape-sequences'
2
+ import path from 'path'
3
+
4
+ import type { Kernel, Process, Shell, Terminal } from '@ecmaos/types'
5
+ import type { IDisposable } from '@xterm/xterm'
6
+
7
+ import { TerminalCommand } from '../shared/terminal-command.js'
8
+ import { writelnStderr } from '../shared/helpers.js'
9
+
10
+ interface PackagePath {
11
+ scope?: string
12
+ package: string
13
+ topic?: string
14
+ }
15
+
16
+ interface Metadata {
17
+ [key: string]: unknown
18
+ }
19
+
20
+ function printUsage(process: Process | undefined, terminal: Terminal): void {
21
+ const usage = `Usage: man [OPTION]... [@scope/]package[/topic[/subtopic...]]
22
+ Display manual pages.
23
+
24
+ -l, --list list available topics for a package
25
+ --where PATH override default documentation path
26
+ --help display this help and exit
27
+
28
+ Examples:
29
+ man package-name display index for package-name
30
+ man @scope/package display index for @scope/package
31
+ man -l @scope/package list topics for @scope/package
32
+ man package-name/topic display topic from package-name
33
+ man @scope/package/docs display docs/index from @scope/package`
34
+ writelnStderr(process, terminal, usage)
35
+ }
36
+
37
+ function resolveManPath(shell: Shell, whereArg?: string): string[] {
38
+ if (whereArg) return [whereArg]
39
+
40
+ const manpath = shell.env.get('MANPATH')
41
+ if (manpath) return manpath.split(':').filter(p => p.length > 0)
42
+
43
+ return ['/usr/share/docs']
44
+ }
45
+
46
+ function parsePackagePath(pathStr: string): PackagePath | null {
47
+ if (!pathStr || pathStr.length === 0) return null
48
+
49
+ const parts = pathStr.split('/')
50
+ if (parts.length === 0) return null
51
+
52
+ if (parts[0]?.startsWith('@')) {
53
+ if (parts.length === 1) return null
54
+ const firstPart = parts[0]
55
+ const packageName = parts[1]
56
+ if (!firstPart || !packageName) return null
57
+ const scope = firstPart.slice(1)
58
+ const topic = parts.length > 2 ? parts.slice(2).join('/') : undefined
59
+ return { scope, package: packageName, topic }
60
+ } else {
61
+ const packageName = parts[0]
62
+ if (!packageName) return null
63
+ const topic = parts.length > 1 ? parts.slice(1).join('/') : undefined
64
+ return { package: packageName, topic }
65
+ }
66
+ }
67
+
68
+ function buildPackagePath(manpath: string, pkgPath: PackagePath): string {
69
+ if (pkgPath.scope) return path.join(manpath, `@${pkgPath.scope}`, pkgPath.package)
70
+ return path.join(manpath, pkgPath.package)
71
+ }
72
+
73
+ async function findDocument(
74
+ fs: Shell['context']['fs'],
75
+ packageDir: string,
76
+ topic?: string
77
+ ): Promise<string | null> {
78
+ const extensions = ['.md', '.txt', '.html']
79
+ const baseName = topic || 'index'
80
+
81
+ // First, check for a file with the topic name directly
82
+ for (const ext of extensions) {
83
+ const filePath = path.join(packageDir, `${baseName}${ext}`)
84
+ if (await fs.promises.exists(filePath)) return filePath
85
+ }
86
+
87
+ // If topic contains a path or is a directory, look for index files in that directory
88
+ if (topic) {
89
+ const topicDir = path.join(packageDir, topic)
90
+ try {
91
+ const stat = await fs.promises.stat(topicDir)
92
+ if (stat.isDirectory()) {
93
+ for (const ext of extensions) {
94
+ const indexPath = path.join(topicDir, `index${ext}`)
95
+ if (await fs.promises.exists(indexPath)) return indexPath
96
+ }
97
+ }
98
+ } catch {
99
+ // Directory doesn't exist, continue
100
+ }
101
+ }
102
+
103
+ return null
104
+ }
105
+
106
+ async function listTopics(
107
+ fs: Shell['context']['fs'],
108
+ packageDir: string,
109
+ prefix: string = ''
110
+ ): Promise<string[]> {
111
+ const topics: string[] = []
112
+
113
+ try {
114
+ if (!(await fs.promises.exists(packageDir))) return topics
115
+
116
+ const entries = await fs.promises.readdir(packageDir)
117
+
118
+ for (const entry of entries) {
119
+ if (entry === 'metadata.json' || entry === 'index.md' || entry === 'index.txt' || entry === 'index.html') {
120
+ continue
121
+ }
122
+
123
+ const fullPath = path.join(packageDir, entry)
124
+ const stat = await fs.promises.stat(fullPath)
125
+
126
+ if (stat.isFile()) {
127
+ const ext = path.extname(entry)
128
+ if (ext === '.md' || ext === '.txt' || ext === '.html') {
129
+ const baseName = path.basename(entry, ext)
130
+ if (baseName && baseName !== 'index') {
131
+ topics.push(prefix ? `${prefix}/${baseName}` : baseName)
132
+ }
133
+ }
134
+ } else if (stat.isDirectory()) {
135
+ // Check if subdirectory has an index file (making it a valid topic)
136
+ const subDir = fullPath
137
+ const hasIndex = await fs.promises.exists(path.join(subDir, 'index.md')) ||
138
+ await fs.promises.exists(path.join(subDir, 'index.txt')) ||
139
+ await fs.promises.exists(path.join(subDir, 'index.html'))
140
+
141
+ if (hasIndex) {
142
+ const topicName = prefix ? `${prefix}/${entry}` : entry
143
+ topics.push(topicName)
144
+ }
145
+
146
+ // Recursively list topics in subdirectory
147
+ const subTopics = await listTopics(fs, subDir, prefix ? `${prefix}/${entry}` : entry)
148
+ topics.push(...subTopics)
149
+ }
150
+ }
151
+ } catch {
152
+ }
153
+
154
+ return topics.sort()
155
+ }
156
+
157
+ async function parseMetadata(
158
+ fs: Shell['context']['fs'],
159
+ packageDir: string
160
+ ): Promise<Metadata | null> {
161
+ const metadataPath = path.join(packageDir, 'metadata.json')
162
+
163
+ try {
164
+ if (await fs.promises.exists(metadataPath)) {
165
+ const content = await fs.promises.readFile(metadataPath, 'utf-8')
166
+ return JSON.parse(content) as Metadata
167
+ }
168
+ } catch {}
169
+
170
+ return null
171
+ }
172
+
173
+ function convertMarkdownToText(
174
+ content: string,
175
+ _currentPackage?: PackagePath,
176
+ _currentManpath?: string
177
+ ): string {
178
+ let result = content
179
+
180
+ // Strip HTML comments: <!-- ... -->
181
+ // - Safe: Processed first to remove comments before any other processing
182
+ // - Safe: Uses non-greedy match to handle multiple comments
183
+ // - Safe: Can span multiple lines
184
+ result = result.replace(/<!--[\s\S]*?-->/g, '')
185
+
186
+ // Process code blocks FIRST to avoid processing markdown inside them
187
+ // Code blocks: /^```[\s\S]*?^```/gm
188
+ // - Pattern matches fenced code blocks (```...```)
189
+ // - Uses non-greedy match to handle multiple code blocks
190
+ // - Replace with placeholders that won't match any markdown patterns
191
+ const codeBlockPlaceholders: string[] = []
192
+ result = result.replace(/^```[\s\S]*?^```/gm, (match) => {
193
+ const code = match.replace(/^```[^\n]*\n/, '').replace(/\n```$/, '')
194
+ const formatted = '\n' + code.split('\n').map(line => ' ' + line).join('\n') + '\n'
195
+ const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`
196
+ codeBlockPlaceholders.push(formatted)
197
+ return placeholder
198
+ })
199
+
200
+ // Headings: /^(#{1,6})\s+(.+)$/gm
201
+ // - Safe: Uses ^ and $ with m flag (line anchors)
202
+ // - Safe: Requires whitespace after # characters
203
+ // - Safe: Won't match inside code blocks (already replaced with placeholders)
204
+ result = result.replace(/^(#{1,6})\s+(.+)$/gm, (_match, hashes, text) => {
205
+ const level = hashes.length
206
+ if (level === 1) return ansi.format(text, ['bold', 'underline']) + '\n'
207
+ else if (level === 2) return ansi.format(text, ['bold']) + '\n'
208
+ else return ansi.format(text, ['bold']) + '\n'
209
+ })
210
+
211
+ // Inline code: /`([^`]+)`/g
212
+ // - Safe: Processed after code blocks, so won't match inside code blocks
213
+ // - Safe: [^`]+ ensures it won't match empty content
214
+ // - Safe: Won't match across lines (backticks must be on same line)
215
+ // - Safe: Processed early to protect code snippets from other markdown
216
+ result = result.replace(/`([^`]+)`/g, (_, code) => ansi.format(code, ['cyan']))
217
+
218
+ // Restore code blocks
219
+ for (let i = 0; i < codeBlockPlaceholders.length; i++) {
220
+ const placeholder = codeBlockPlaceholders[i]
221
+ if (placeholder) {
222
+ result = result.replace(`__CODE_BLOCK_${i}__`, placeholder)
223
+ }
224
+ }
225
+
226
+ // Bold text: /\*\*(.+?)\*\*/g
227
+ // - Safe: Processed after code blocks, so won't match inside code blocks
228
+ // - Safe: Non-greedy (.+?) ensures it matches the shortest valid bold text
229
+ // - Safe: Requires at least one character between ** (won't match empty)
230
+ // - Safe: Processed BEFORE italic to avoid conflicts
231
+ // - Note: Can match inside headings (e.g., "## **Bold Heading**") which is valid markdown
232
+ result = result.replace(/\*\*(.+?)\*\*/g, (_, text) => ansi.format(text, ['bold']))
233
+
234
+ // Italic text: /\*(?![*])(.+?)\*/g
235
+ // - Safe: Processed after code blocks and bold, so won't match inside code blocks or **bold**
236
+ // - Safe: Negative lookahead (?![*]) ensures it doesn't match if followed by *
237
+ // - Safe: Non-greedy (.+?) ensures it matches the shortest valid italic text
238
+ // - Safe: Requires at least one character between * (won't match empty)
239
+ // - Safe: Can match inside bold text (e.g., "**bold *italic* bold**") which is valid markdown
240
+ // - Note: Using 'gray' color since many terminals don't render italic styling visibly
241
+ result = result.replace(/\*(?![*])(.+?)\*/g, (_, text) => ansi.format(text, ['gray']))
242
+
243
+
244
+ // URLs: Color any URLs (http://, https://, mailto:) as blue
245
+ // - Safe: Processed after code blocks, so won't match inside code blocks
246
+ // - Safe: Matches common URL patterns, stops at whitespace or common punctuation
247
+ // - Safe: Won't match URLs that are already inside formatted text (escape sequences)
248
+ // - Note: Pattern excludes trailing punctuation like ), ], but allows . (periods are valid in URLs)
249
+ const urlPattern = /(https?:\/\/[^\s<>"',;!)\]\)]+|mailto:[^\s<>"',;!)\]\)]+)/gi
250
+ result = result.replace(urlPattern, (url) => {
251
+ return ansi.format(url, ['blue'])
252
+ })
253
+
254
+ return result
255
+ }
256
+
257
+ function convertHtmlToText(
258
+ content: string,
259
+ _currentPackage?: PackagePath,
260
+ _currentManpath?: string
261
+ ): string {
262
+ let result = content
263
+
264
+ // Strip HTML comments: <!-- ... -->
265
+ result = result.replace(/<!--[\s\S]*?-->/g, '')
266
+
267
+ // Strip head, style, script, nav, footer tags and their content
268
+ result = result.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
269
+ result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
270
+ result = result.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
271
+ result = result.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
272
+ result = result.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
273
+
274
+ // Add line breaks before/after block elements
275
+ result = result.replace(/<br\s*\/?>/gi, '\n')
276
+ result = result.replace(/<\/p>/gi, '\n\n')
277
+ result = result.replace(/<\/div>/gi, '\n')
278
+ result = result.replace(/<\/li>/gi, '\n')
279
+ result = result.replace(/<\/tr>/gi, '\n')
280
+ result = result.replace(/<hr[^>]*>/gi, '\n---\n')
281
+
282
+ // List items - add bullet
283
+ result = result.replace(/<li[^>]*>/gi, ' • ')
284
+
285
+ result = result.replace(/<h1[^>]*>(.*?)<\/h1>/gi, (_, text) => {
286
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['bold', 'underline']) + '\n'
287
+ })
288
+
289
+ result = result.replace(/<h2[^>]*>(.*?)<\/h2>/gi, (_, text) => {
290
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['bold']) + '\n'
291
+ })
292
+
293
+ result = result.replace(/<h[3-6][^>]*>(.*?)<\/h[3-6]>/gi, (_, text) => {
294
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['bold']) + '\n'
295
+ })
296
+
297
+ result = result.replace(/<strong[^>]*>(.*?)<\/strong>/gi, (_, text) => {
298
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['bold'])
299
+ })
300
+
301
+ result = result.replace(/<b[^>]*>(.*?)<\/b>/gi, (_, text) => {
302
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['bold'])
303
+ })
304
+
305
+ result = result.replace(/<em[^>]*>(.*?)<\/em>/gi, (_, text) => {
306
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['gray'])
307
+ })
308
+
309
+ result = result.replace(/<i[^>]*>(.*?)<\/i>/gi, (_, text) => {
310
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['gray'])
311
+ })
312
+
313
+ result = result.replace(/<code[^>]*>(.*?)<\/code>/gi, (_, text) => {
314
+ return ansi.format(text.replace(/<[^>]+>/g, ''), ['cyan'])
315
+ })
316
+
317
+ result = result.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, text) => {
318
+ const code = text.replace(/<[^>]+>/g, '')
319
+ return '\n' + code.split('\n').map((line: string) => ' ' + line).join('\n') + '\n'
320
+ })
321
+
322
+ result = result.replace(/<[^>]+>/g, '')
323
+
324
+ // Decode HTML entities
325
+ result = result.replace(/&nbsp;/g, ' ')
326
+ result = result.replace(/&lt;/g, '<')
327
+ result = result.replace(/&gt;/g, '>')
328
+ result = result.replace(/&amp;/g, '&')
329
+ result = result.replace(/&quot;/g, '"')
330
+ result = result.replace(/&#39;/g, "'")
331
+ result = result.replace(/&#x27;/g, "'")
332
+ result = result.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
333
+
334
+ // Clean up whitespace
335
+ result = result.replace(/[ \t]+/g, ' ')
336
+ result = result.replace(/\n[ \t]+/g, '\n')
337
+ result = result.replace(/[ \t]+\n/g, '\n')
338
+ result = result.replace(/\n{3,}/g, '\n\n')
339
+ result = result.trim()
340
+
341
+ // URLs: Color any URLs (http://, https://, mailto:) as blue
342
+ const urlPattern = /(https?:\/\/[^\s<>"',;:!?)\]\)]+|mailto:[^\s<>"',;:!?)\]\)]+)/gi
343
+ result = result.replace(urlPattern, (url) => {
344
+ return ansi.format(url, ['blue'])
345
+ })
346
+
347
+ return result
348
+ }
349
+
350
+ async function displayManPage(
351
+ terminal: Terminal,
352
+ content: string,
353
+ documentName: string
354
+ ): Promise<void> {
355
+ const lines = content.split('\n')
356
+ let currentLine = 0
357
+ let horizontalOffset = 0
358
+ let keyListener: IDisposable | null = null
359
+ let linesRendered = 0
360
+
361
+ terminal.unlisten()
362
+ terminal.write('\n')
363
+ terminal.write(ansi.cursor.hide)
364
+
365
+ const rows = terminal.rows
366
+ const displayRows = rows - 1
367
+
368
+ const render = () => {
369
+ const cols = terminal.cols
370
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
371
+
372
+ const getVisibleSlice = (line: string, offset: number): string => {
373
+ const visibleLen = stripAnsi(line).length
374
+
375
+ if (offset < 0) offset = 0
376
+ if (offset > visibleLen) offset = visibleLen
377
+
378
+ let visible = 0
379
+ let result = ''
380
+ let inEscape = false
381
+ let charsSkipped = 0
382
+
383
+ for (const char of line) {
384
+ if (char === '\x1b') inEscape = true
385
+ if (inEscape) {
386
+ if (charsSkipped >= offset) {
387
+ result += char
388
+ }
389
+ if (/[a-zA-Z]/.test(char)) inEscape = false
390
+ } else {
391
+ if (charsSkipped < offset) {
392
+ charsSkipped++
393
+ } else {
394
+ if (visible >= cols) break
395
+ result += char
396
+ visible++
397
+ }
398
+ }
399
+ }
400
+ return result
401
+ }
402
+
403
+ const maxLine = Math.max(0, lines.length - displayRows)
404
+ if (currentLine > maxLine) currentLine = maxLine
405
+ if (currentLine < 0) currentLine = 0
406
+
407
+ const maxLineLength = Math.max(...lines.map(l => stripAnsi(l).length), 0)
408
+ const maxHorizontalOffset = Math.max(0, maxLineLength - cols)
409
+ if (horizontalOffset > maxHorizontalOffset) horizontalOffset = maxHorizontalOffset
410
+ if (horizontalOffset < 0) horizontalOffset = 0
411
+
412
+ if (linesRendered > 0) terminal.write(ansi.cursor.up(linesRendered))
413
+
414
+ const endLine = Math.min(currentLine + displayRows, lines.length)
415
+ linesRendered = 0
416
+
417
+ for (let i = currentLine; i < endLine; i++) {
418
+ terminal.write(ansi.erase.inLine(2))
419
+ const line = getVisibleSlice(lines[i] || '', horizontalOffset)
420
+ terminal.write(line)
421
+ linesRendered++
422
+ if (i < endLine - 1) terminal.write('\n')
423
+ }
424
+
425
+ for (let i = endLine - currentLine; i < displayRows; i++) {
426
+ terminal.write('\n')
427
+ terminal.write(ansi.erase.inLine(2))
428
+ linesRendered++
429
+ }
430
+
431
+ const percentage = lines.length > 0 ? Math.round(((endLine / lines.length) * 100)) : 100
432
+ const statusLine = `-- ${documentName} ${currentLine + 1}-${endLine} / ${lines.length} (${percentage}%)`
433
+ terminal.write('\n')
434
+ terminal.write(ansi.erase.inLine(2))
435
+ terminal.write(getVisibleSlice(statusLine, 0))
436
+ linesRendered++
437
+ }
438
+
439
+ render()
440
+
441
+ await new Promise<void>((resolve) => {
442
+ keyListener = terminal.onKey(async ({ domEvent }) => {
443
+ const keyName = domEvent.key
444
+
445
+ switch (keyName) {
446
+ case 'q':
447
+ case 'Q':
448
+ case 'Escape':
449
+ if (keyListener) {
450
+ keyListener.dispose()
451
+ keyListener = null
452
+ }
453
+ terminal.write(ansi.cursor.show)
454
+ terminal.write('\n')
455
+ terminal.listen()
456
+ resolve()
457
+ return
458
+ case 'ArrowUp':
459
+ if (currentLine > 0) {
460
+ currentLine--
461
+ render()
462
+ }
463
+ break
464
+ case 'ArrowDown':
465
+ case 'Enter':
466
+ currentLine++
467
+ render()
468
+ break
469
+ case 'ArrowLeft':
470
+ horizontalOffset = Math.max(0, horizontalOffset - Math.floor(terminal.cols / 2))
471
+ render()
472
+ break
473
+ case 'ArrowRight':
474
+ horizontalOffset += Math.floor(terminal.cols / 2)
475
+ render()
476
+ break
477
+ case 'PageDown':
478
+ case ' ':
479
+ currentLine = Math.min(currentLine + displayRows, Math.max(0, lines.length - displayRows))
480
+ render()
481
+ break
482
+ case 'PageUp':
483
+ case 'b':
484
+ case 'B':
485
+ currentLine = Math.max(0, currentLine - displayRows)
486
+ render()
487
+ break
488
+ case 'Home':
489
+ case 'g':
490
+ currentLine = 0
491
+ render()
492
+ break
493
+ case 'End':
494
+ case 'G':
495
+ currentLine = Math.max(0, lines.length - displayRows)
496
+ render()
497
+ break
498
+ }
499
+ })
500
+ })
501
+ }
502
+
503
+ export function createCommand(kernel: Kernel, shell: Shell, terminal: Terminal): TerminalCommand {
504
+ return new TerminalCommand({
505
+ command: 'man',
506
+ description: 'Display manual pages',
507
+ kernel,
508
+ shell,
509
+ terminal,
510
+ run: async (pid: number, argv: string[]) => {
511
+ const process = kernel.processes.get(pid) as Process | undefined
512
+
513
+ if (!process) return 1
514
+
515
+ if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
516
+ printUsage(process, terminal)
517
+ return 0
518
+ }
519
+
520
+ let whereArg: string | undefined
521
+ let topicPath: string | undefined
522
+ let listTopicsFlag = false
523
+
524
+ for (let i = 0; i < argv.length; i++) {
525
+ const arg = argv[i]
526
+ if (arg === undefined) continue
527
+
528
+ if (arg === '--where') {
529
+ if (i + 1 < argv.length) {
530
+ i++
531
+ whereArg = argv[i]
532
+ } else {
533
+ await writelnStderr(process, terminal, 'man: missing argument to --where')
534
+ return 1
535
+ }
536
+ } else if (arg === '--list' || arg === '-l') {
537
+ listTopicsFlag = true
538
+ } else if (!arg.startsWith('-')) {
539
+ topicPath = arg
540
+ }
541
+ }
542
+
543
+ if (!topicPath) {
544
+ topicPath = '@ecmaos/kernel'
545
+ }
546
+
547
+ const pkgPath = parsePackagePath(topicPath)
548
+ if (!pkgPath) {
549
+ await writelnStderr(process, terminal, `man: invalid package path: ${topicPath}`)
550
+ return 1
551
+ }
552
+
553
+ const manpaths = resolveManPath(shell, whereArg)
554
+
555
+ for (const manpath of manpaths) {
556
+ const packageDir = buildPackagePath(manpath, pkgPath)
557
+
558
+ if (!(await shell.context.fs.promises.exists(packageDir))) continue
559
+
560
+ await parseMetadata(shell.context.fs, packageDir)
561
+
562
+ if (listTopicsFlag) {
563
+ const topics = await listTopics(shell.context.fs, packageDir)
564
+ const packageName = pkgPath.scope ? `@${pkgPath.scope}/${pkgPath.package}` : pkgPath.package
565
+
566
+ if (topics.length === 0) {
567
+ terminal.writeln(`${packageName}: no topics available`)
568
+ } else {
569
+ terminal.writeln(`${packageName}:`)
570
+ for (const topic of topics) {
571
+ terminal.writeln(` ${topic}`)
572
+ }
573
+ }
574
+ return 0
575
+ }
576
+
577
+ if (!pkgPath.topic) {
578
+ const indexFile = await findDocument(shell.context.fs, packageDir)
579
+
580
+ if (indexFile) {
581
+ const content = await shell.context.fs.promises.readFile(indexFile, 'utf-8')
582
+ const ext = path.extname(indexFile)
583
+
584
+ let processedContent = content
585
+ if (ext === '.md') {
586
+ processedContent = convertMarkdownToText(content, pkgPath, manpath)
587
+ } else if (ext === '.html') {
588
+ processedContent = convertHtmlToText(content, pkgPath, manpath)
589
+ }
590
+
591
+ const documentName = pkgPath.scope
592
+ ? `@${pkgPath.scope}/${pkgPath.package}`
593
+ : pkgPath.package
594
+
595
+ await displayManPage(terminal, processedContent, documentName)
596
+ } else {
597
+ const topics = await listTopics(shell.context.fs, packageDir)
598
+ const packageName = pkgPath.scope ? `@${pkgPath.scope}/${pkgPath.package}` : pkgPath.package
599
+
600
+ if (topics.length === 0) {
601
+ terminal.writeln(`${packageName}: no topics available`)
602
+ } else {
603
+ const exampleTopic = topics[0]
604
+ terminal.writeln(`${kernel.i18n.t('coreutils.man.noIndexFound', 'No index found, try a topic:')} man ${packageName}/${exampleTopic}`)
605
+ terminal.writeln(`${packageName} ${kernel.i18n.t('topics')}:`)
606
+ for (const topic of topics) terminal.writeln(` ${topic}`)
607
+ }
608
+ }
609
+
610
+ return 0
611
+ }
612
+
613
+ const docFile = await findDocument(shell.context.fs, packageDir, pkgPath.topic)
614
+ if (!docFile) continue
615
+
616
+ try {
617
+ const content = await shell.context.fs.promises.readFile(docFile, 'utf-8')
618
+ const ext = path.extname(docFile)
619
+
620
+ let processedContent = content
621
+ if (ext === '.md') {
622
+ processedContent = convertMarkdownToText(content, pkgPath, manpath)
623
+ } else if (ext === '.html') {
624
+ processedContent = convertHtmlToText(content, pkgPath, manpath)
625
+ }
626
+
627
+ const documentName = pkgPath.scope
628
+ ? `@${pkgPath.scope}/${pkgPath.package}/${pkgPath.topic}`
629
+ : `${pkgPath.package}/${pkgPath.topic}`
630
+
631
+ await displayManPage(terminal, processedContent, documentName)
632
+ return 0
633
+ } catch (error) {
634
+ await writelnStderr(process, terminal, `man: error reading document: ${error instanceof Error ? error.message : 'Unknown error'}`)
635
+ return 1
636
+ }
637
+ }
638
+
639
+ await writelnStderr(process, terminal, `man: no manual entry for ${topicPath}`)
640
+ return 1
641
+ }
642
+ })
643
+ }
@@ -1,14 +1,14 @@
1
1
  import path from 'path'
2
2
  import type { Kernel, Process, Shell, Terminal } from '@ecmaos/types'
3
3
  import { TerminalCommand } from '../shared/terminal-command.js'
4
- import { writelnStdout, writelnStderr } from '../shared/helpers.js'
4
+ import { writelnStderr } from '../shared/helpers.js'
5
5
 
6
6
  function printUsage(process: Process | undefined, terminal: Terminal): void {
7
7
  const usage = `Usage: mkdir [OPTION]... DIRECTORY...
8
8
  Create the DIRECTORY(ies), if they do not already exist.
9
9
 
10
10
  --help display this help and exit`
11
- writelnStdout(process, terminal, usage)
11
+ writelnStderr(process, terminal, usage)
12
12
  }
13
13
 
14
14
  export function createCommand(kernel: Kernel, shell: Shell, terminal: Terminal): TerminalCommand {