@ecmaos/coreutils 0.3.0 → 0.3.1

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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/commands/cron.d.ts +4 -0
  4. package/dist/commands/cron.d.ts.map +1 -0
  5. package/dist/commands/cron.js +439 -0
  6. package/dist/commands/cron.js.map +1 -0
  7. package/dist/commands/man.d.ts.map +1 -1
  8. package/dist/commands/man.js +10 -0
  9. package/dist/commands/man.js.map +1 -1
  10. package/dist/commands/open.d.ts +4 -0
  11. package/dist/commands/open.d.ts.map +1 -0
  12. package/dist/commands/open.js +74 -0
  13. package/dist/commands/open.js.map +1 -0
  14. package/dist/commands/play.d.ts +4 -0
  15. package/dist/commands/play.d.ts.map +1 -0
  16. package/dist/commands/play.js +231 -0
  17. package/dist/commands/play.js.map +1 -0
  18. package/dist/commands/tar.d.ts.map +1 -1
  19. package/dist/commands/tar.js +67 -17
  20. package/dist/commands/tar.js.map +1 -1
  21. package/dist/commands/video.d.ts +4 -0
  22. package/dist/commands/video.d.ts.map +1 -0
  23. package/dist/commands/video.js +250 -0
  24. package/dist/commands/video.js.map +1 -0
  25. package/dist/commands/view.d.ts +4 -0
  26. package/dist/commands/view.d.ts.map +1 -0
  27. package/dist/commands/view.js +488 -0
  28. package/dist/commands/view.js.map +1 -0
  29. package/dist/commands/web.d.ts +4 -0
  30. package/dist/commands/web.d.ts.map +1 -0
  31. package/dist/commands/web.js +348 -0
  32. package/dist/commands/web.js.map +1 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +20 -2
  36. package/dist/index.js.map +1 -1
  37. package/package.json +3 -2
  38. package/src/commands/cron.ts +499 -0
  39. package/src/commands/man.ts +8 -0
  40. package/src/commands/open.ts +84 -0
  41. package/src/commands/play.ts +249 -0
  42. package/src/commands/tar.ts +62 -19
  43. package/src/commands/video.ts +267 -0
  44. package/src/commands/view.ts +526 -0
  45. package/src/commands/web.ts +377 -0
  46. package/src/index.ts +20 -2
@@ -0,0 +1,526 @@
1
+ import path from 'path'
2
+ import chalk from 'chalk'
3
+ import type { Kernel, Process, Shell, Terminal } from '@ecmaos/types'
4
+ import { TerminalCommand } from '../shared/terminal-command.js'
5
+ import { writelnStderr, writelnStdout } from '../shared/helpers.js'
6
+
7
+ type FileType = 'pdf' | 'image' | 'audio' | 'video'
8
+
9
+ function detectFileType(filePath: string): FileType {
10
+ const ext = path.extname(filePath).toLowerCase()
11
+
12
+ // PDF
13
+ if (ext === '.pdf') {
14
+ return 'pdf'
15
+ }
16
+
17
+ // Images
18
+ const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.tif']
19
+ if (imageExts.includes(ext)) {
20
+ return 'image'
21
+ }
22
+
23
+ // Audio
24
+ const audioExts = ['.mp3', '.wav', '.ogg', '.oga', '.opus', '.m4a', '.aac', '.flac', '.webm', '.wma', '.aiff', '.aif', '.3gp', '.amr']
25
+ if (audioExts.includes(ext)) {
26
+ return 'audio'
27
+ }
28
+
29
+ // Video
30
+ const videoExts = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv', '.m4v', '.flv', '.wmv', '.3gp']
31
+ if (videoExts.includes(ext)) {
32
+ return 'video'
33
+ }
34
+
35
+ // Default to image for unknown types (might be an image without extension)
36
+ return 'image'
37
+ }
38
+
39
+ function getMimeType(filePath: string, fileType: FileType): string {
40
+ const ext = path.extname(filePath).toLowerCase()
41
+
42
+ if (fileType === 'pdf') {
43
+ return 'application/pdf'
44
+ }
45
+
46
+ if (fileType === 'image') {
47
+ const mimeTypes: Record<string, string> = {
48
+ '.jpg': 'image/jpeg',
49
+ '.jpeg': 'image/jpeg',
50
+ '.png': 'image/png',
51
+ '.gif': 'image/gif',
52
+ '.bmp': 'image/bmp',
53
+ '.webp': 'image/webp',
54
+ '.svg': 'image/svg+xml',
55
+ '.ico': 'image/x-icon',
56
+ '.tiff': 'image/tiff',
57
+ '.tif': 'image/tiff'
58
+ }
59
+ return mimeTypes[ext] || 'image/png'
60
+ }
61
+
62
+ if (fileType === 'audio') {
63
+ const mimeTypes: Record<string, string> = {
64
+ '.mp3': 'audio/mpeg',
65
+ '.wav': 'audio/wav',
66
+ '.ogg': 'audio/ogg',
67
+ '.oga': 'audio/ogg',
68
+ '.opus': 'audio/opus',
69
+ '.m4a': 'audio/mp4',
70
+ '.aac': 'audio/aac',
71
+ '.flac': 'audio/flac',
72
+ '.webm': 'audio/webm',
73
+ '.wma': 'audio/x-ms-wma',
74
+ '.aiff': 'audio/aiff',
75
+ '.aif': 'audio/aiff',
76
+ '.3gp': 'audio/3gpp',
77
+ '.amr': 'audio/amr'
78
+ }
79
+ return mimeTypes[ext] || 'audio/mpeg'
80
+ }
81
+
82
+ if (fileType === 'video') {
83
+ const mimeTypes: Record<string, string> = {
84
+ '.mp4': 'video/mp4',
85
+ '.webm': 'video/webm',
86
+ '.ogg': 'video/ogg',
87
+ '.ogv': 'video/ogg',
88
+ '.mov': 'video/quicktime',
89
+ '.avi': 'video/x-msvideo',
90
+ '.mkv': 'video/x-matroska',
91
+ '.m4v': 'video/mp4',
92
+ '.flv': 'video/x-flv',
93
+ '.wmv': 'video/x-ms-wmv',
94
+ '.3gp': 'video/3gpp'
95
+ }
96
+ return mimeTypes[ext] || 'video/mp4'
97
+ }
98
+
99
+ return 'application/octet-stream'
100
+ }
101
+
102
+ function printUsage(process: Process | undefined, terminal: Terminal): void {
103
+ const usage = `Usage: view [OPTIONS] [FILE...]
104
+ View files in a new window. Supports PDF, images, audio, and video files.
105
+
106
+ --help display this help and exit
107
+
108
+ Audio/Video Options (for audio and video files):
109
+ --no-autoplay don't start playing automatically
110
+ --loop loop the media
111
+ --muted start muted
112
+ --volume <0-100> set volume (0-100, default: 100, audio only)
113
+ --no-controls hide video controls (video only)
114
+ --fullscreen open in fullscreen mode (video only)
115
+ --width <width> set window width (video only)
116
+ --height <height> set window height (video only)
117
+ --quiet play without opening a window (audio only, background playback)
118
+
119
+ Examples:
120
+ view document.pdf view a PDF file
121
+ view image.png view an image
122
+ view song.mp3 view/play an audio file
123
+ view movie.mp4 view/play a video file
124
+ view --loop music.mp3 play audio in a loop
125
+ view --no-autoplay video.mp4 load video without auto-playing
126
+ view --volume 50 track.mp3 play at 50% volume
127
+ view --fullscreen movie.mp4 play video in fullscreen mode`
128
+ writelnStderr(process, terminal, usage)
129
+ }
130
+
131
+ async function loadAudioMetadata(audioElement: HTMLAudioElement): Promise<{ duration: number }> {
132
+ return new Promise((resolve, reject) => {
133
+ const timeout = setTimeout(() => {
134
+ reject(new Error('Timeout loading audio metadata'))
135
+ }, 10000)
136
+
137
+ audioElement.onloadedmetadata = () => {
138
+ clearTimeout(timeout)
139
+ resolve({
140
+ duration: audioElement.duration
141
+ })
142
+ }
143
+
144
+ audioElement.onerror = () => {
145
+ clearTimeout(timeout)
146
+ reject(new Error('Failed to load audio metadata'))
147
+ }
148
+ })
149
+ }
150
+
151
+ async function loadVideoMetadata(videoElement: HTMLVideoElement): Promise<{ width: number; height: number; duration: number }> {
152
+ return new Promise((resolve, reject) => {
153
+ const timeout = setTimeout(() => {
154
+ reject(new Error('Timeout loading video metadata'))
155
+ }, 10000)
156
+
157
+ videoElement.onloadedmetadata = () => {
158
+ clearTimeout(timeout)
159
+ resolve({
160
+ width: videoElement.videoWidth,
161
+ height: videoElement.videoHeight,
162
+ duration: videoElement.duration
163
+ })
164
+ }
165
+
166
+ videoElement.onerror = () => {
167
+ clearTimeout(timeout)
168
+ reject(new Error('Failed to load video metadata'))
169
+ }
170
+ })
171
+ }
172
+
173
+ function formatDuration(seconds: number): string {
174
+ if (!isFinite(seconds) || isNaN(seconds)) return '?:??'
175
+ const hours = Math.floor(seconds / 3600)
176
+ const minutes = Math.floor((seconds % 3600) / 60)
177
+ const secs = Math.floor(seconds % 60)
178
+
179
+ if (hours > 0) {
180
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
181
+ }
182
+ return `${minutes}:${secs.toString().padStart(2, '0')}`
183
+ }
184
+
185
+ export function createCommand(kernel: Kernel, shell: Shell, terminal: Terminal): TerminalCommand {
186
+ return new TerminalCommand({
187
+ command: 'view',
188
+ description: 'View files in a new window (PDF, images, audio, video)',
189
+ kernel,
190
+ shell,
191
+ terminal,
192
+ run: async (pid: number, argv: string[]) => {
193
+ const process = kernel.processes.get(pid) as Process | undefined
194
+
195
+ if (!process) return 1
196
+
197
+ if (argv.length > 0 && (argv[0] === '--help' || argv[0] === '-h')) {
198
+ printUsage(process, terminal)
199
+ return 0
200
+ }
201
+
202
+ // Parse options
203
+ const options: {
204
+ autoplay: boolean
205
+ loop: boolean
206
+ muted: boolean
207
+ volume: number
208
+ controls: boolean
209
+ fullscreen: boolean
210
+ quiet: boolean
211
+ width?: number
212
+ height?: number
213
+ } = {
214
+ autoplay: true,
215
+ loop: false,
216
+ muted: false,
217
+ volume: 100,
218
+ controls: true,
219
+ fullscreen: false,
220
+ quiet: false
221
+ }
222
+
223
+ const files: string[] = []
224
+
225
+ for (let i = 0; i < argv.length; i++) {
226
+ const arg = argv[i]
227
+ if (arg === '--no-autoplay') {
228
+ options.autoplay = false
229
+ } else if (arg === '--loop') {
230
+ options.loop = true
231
+ } else if (arg === '--muted') {
232
+ options.muted = true
233
+ } else if (arg === '--quiet') {
234
+ options.quiet = true
235
+ } else if (arg === '--no-controls') {
236
+ options.controls = false
237
+ } else if (arg === '--fullscreen') {
238
+ options.fullscreen = true
239
+ } else if (arg === '--volume' && i + 1 < argv.length) {
240
+ const volumeArg = argv[i + 1]
241
+ if (!volumeArg) {
242
+ await writelnStderr(process, terminal, chalk.red(`view: missing volume value`))
243
+ return 1
244
+ }
245
+ const volume = parseFloat(volumeArg)
246
+ if (isNaN(volume) || volume < 0 || volume > 100) {
247
+ await writelnStderr(process, terminal, chalk.red(`view: invalid volume: ${volumeArg} (must be 0-100)`))
248
+ return 1
249
+ }
250
+ options.volume = volume
251
+ i++ // Skip next argument
252
+ } else if (arg === '--width' && i + 1 < argv.length) {
253
+ const widthArg = argv[i + 1]
254
+ if (!widthArg) {
255
+ await writelnStderr(process, terminal, chalk.red(`view: missing width value`))
256
+ return 1
257
+ }
258
+ const width = parseInt(widthArg, 10)
259
+ if (isNaN(width) || width <= 0) {
260
+ await writelnStderr(process, terminal, chalk.red(`view: invalid width: ${widthArg}`))
261
+ return 1
262
+ }
263
+ options.width = width
264
+ i++ // Skip next argument
265
+ } else if (arg === '--height' && i + 1 < argv.length) {
266
+ const heightArg = argv[i + 1]
267
+ if (!heightArg) {
268
+ await writelnStderr(process, terminal, chalk.red(`view: missing height value`))
269
+ return 1
270
+ }
271
+ const height = parseInt(heightArg, 10)
272
+ if (isNaN(height) || height <= 0) {
273
+ await writelnStderr(process, terminal, chalk.red(`view: invalid height: ${heightArg}`))
274
+ return 1
275
+ }
276
+ options.height = height
277
+ i++ // Skip next argument
278
+ } else if (arg && !arg.startsWith('--')) {
279
+ files.push(arg)
280
+ }
281
+ }
282
+
283
+ if (files.length === 0) {
284
+ await writelnStderr(process, terminal, `view: missing file argument`)
285
+ await writelnStderr(process, terminal, `Try 'view --help' for more information.`)
286
+ return 1
287
+ }
288
+
289
+ // Process each file
290
+ for (const file of files) {
291
+ const fullPath = path.resolve(shell.cwd, file)
292
+
293
+ try {
294
+ // Check if file exists
295
+ if (!(await shell.context.fs.promises.exists(fullPath))) {
296
+ await writelnStderr(process, terminal, chalk.red(`view: file not found: ${fullPath}`))
297
+ continue
298
+ }
299
+
300
+ // Read file
301
+ await writelnStdout(process, terminal, chalk.blue(`Loading: ${file}...`))
302
+ const fileData = await shell.context.fs.promises.readFile(fullPath)
303
+ const fileType = detectFileType(fullPath)
304
+ const mimeType = getMimeType(fullPath, fileType)
305
+
306
+ const windowTitle = files.length > 1
307
+ ? `${path.basename(file)} (${files.indexOf(file) + 1}/${files.length})`
308
+ : path.basename(file)
309
+
310
+ if (fileType === 'pdf') {
311
+ // Convert PDF to base64 and display in object tag
312
+ // Use chunked encoding to avoid argument limit issues with large files
313
+ const uint8Array = new Uint8Array(fileData)
314
+ let binaryString = ''
315
+ const chunkSize = 8192
316
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
317
+ const chunk = uint8Array.subarray(i, i + chunkSize)
318
+ binaryString += String.fromCharCode(...chunk)
319
+ }
320
+ const base64Data = btoa(binaryString)
321
+ const dataUrl = `data:application/pdf;base64,${base64Data}`
322
+
323
+ const pdfHtml = `
324
+ <div style="width:100%;height:100%;display:flex;flex-direction:column;background:#1e1e1e;overflow:hidden;">
325
+ <object data="${dataUrl}" type="application/pdf" style="width:100%;height:100%;flex:1;">
326
+ <p style="color:#fff;padding:20px;text-align:center;">
327
+ Your browser does not support PDFs.
328
+ <a href="${dataUrl}" style="color:#4a9eff;" download="${path.basename(file)}">Download PDF</a>
329
+ </p>
330
+ </object>
331
+ </div>
332
+ `
333
+
334
+ kernel.windows.create({
335
+ title: windowTitle,
336
+ html: pdfHtml,
337
+ width: 800,
338
+ height: 600,
339
+ max: false
340
+ })
341
+
342
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
343
+ } else if (fileType === 'image') {
344
+ // Display image directly
345
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
346
+ const url = URL.createObjectURL(blob)
347
+
348
+ const imageHtml = `
349
+ <div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#1e1e1e;overflow:auto;padding:20px;box-sizing:border-box;">
350
+ <img src="${url}" style="max-width:100%;max-height:100%;object-fit:contain;" alt="${path.basename(file)}" />
351
+ </div>
352
+ `
353
+
354
+ kernel.windows.create({
355
+ title: windowTitle,
356
+ html: imageHtml,
357
+ width: 800,
358
+ height: 600,
359
+ max: false
360
+ })
361
+
362
+ await writelnStdout(process, terminal, chalk.green(`Viewing: ${file}`))
363
+ } else if (fileType === 'audio') {
364
+ // Handle audio similar to play command
365
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
366
+ const url = URL.createObjectURL(blob)
367
+
368
+ // Load audio metadata
369
+ const audioElement = document.createElement('audio')
370
+ audioElement.src = url
371
+ audioElement.preload = 'metadata'
372
+
373
+ let duration = 0
374
+
375
+ try {
376
+ const metadata = await loadAudioMetadata(audioElement)
377
+ duration = metadata.duration
378
+ } catch (error) {
379
+ await writelnStderr(process, terminal, chalk.yellow(`view: warning: could not load metadata for ${file}`))
380
+ }
381
+
382
+ // Set audio properties
383
+ audioElement.volume = options.volume / 100
384
+ if (options.autoplay) audioElement.autoplay = true
385
+ if (options.loop) audioElement.loop = true
386
+ if (options.muted) audioElement.muted = true
387
+
388
+ if (options.quiet) {
389
+ // Background playback - no window
390
+ audioElement.play().catch((error) => {
391
+ // Autoplay may be blocked by browser, but that's okay for quiet mode
392
+ console.warn('Autoplay blocked:', error)
393
+ })
394
+
395
+ if (duration > 0) {
396
+ const durationStr = formatDuration(duration)
397
+ await writelnStdout(process, terminal, chalk.green(`Playing in background: ${file} (${durationStr})`))
398
+ } else {
399
+ await writelnStdout(process, terminal, chalk.green(`Playing in background: ${file}`))
400
+ }
401
+ } else {
402
+ // Create a simple audio player window
403
+ const audioId = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
404
+ const audioHtml = `
405
+ <div style="width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#1e1e1e;color:#fff;font-family:monospace;padding:20px;box-sizing:border-box;">
406
+ <div style="font-size:18px;margin-bottom:20px;text-align:center;word-break:break-word;">${path.basename(file)}</div>
407
+ <audio id="${audioId}" src="${url}" ${options.autoplay ? 'autoplay' : ''} ${options.loop ? 'loop' : ''} ${options.muted ? 'muted' : ''} controls style="width:100%;max-width:600px;"></audio>
408
+ </div>
409
+ <script>
410
+ (function() {
411
+ const audio = document.getElementById('${audioId}');
412
+ if (audio) {
413
+ audio.volume = ${options.volume / 100};
414
+ audio.addEventListener('play', () => console.log('Playing: ${file}'));
415
+ audio.addEventListener('ended', () => console.log('Finished: ${file}'));
416
+ }
417
+ })();
418
+ </script>
419
+ `
420
+
421
+ kernel.windows.create({
422
+ title: windowTitle,
423
+ html: audioHtml,
424
+ width: 500,
425
+ height: 200,
426
+ max: false
427
+ })
428
+
429
+ if (duration > 0) {
430
+ const durationStr = formatDuration(duration)
431
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file} (${durationStr})`))
432
+ } else {
433
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file}`))
434
+ }
435
+ }
436
+ } else if (fileType === 'video') {
437
+ // Handle video similar to video command
438
+ const blob = new Blob([new Uint8Array(fileData)], { type: mimeType })
439
+ const url = URL.createObjectURL(blob)
440
+
441
+ // Load video metadata
442
+ const videoElement = document.createElement('video')
443
+ videoElement.src = url
444
+ videoElement.preload = 'metadata'
445
+
446
+ let videoWidth: number
447
+ let videoHeight: number
448
+ let duration: number
449
+
450
+ try {
451
+ const metadata = await loadVideoMetadata(videoElement)
452
+ videoWidth = metadata.width
453
+ videoHeight = metadata.height
454
+ duration = metadata.duration
455
+ } catch (error) {
456
+ await writelnStderr(process, terminal, chalk.yellow(`view: warning: could not load metadata for ${file}, using default size`))
457
+ videoWidth = 640
458
+ videoHeight = 360
459
+ duration = 0
460
+ }
461
+
462
+ // Calculate window dimensions
463
+ const { innerWidth, innerHeight } = window
464
+ let windowWidth: number
465
+ let windowHeight: number
466
+
467
+ if (options.fullscreen) {
468
+ windowWidth = innerWidth
469
+ windowHeight = innerHeight
470
+ } else if (options.width && options.height) {
471
+ windowWidth = options.width
472
+ windowHeight = options.height
473
+ } else if (options.width) {
474
+ windowWidth = options.width
475
+ windowHeight = Math.round((videoHeight / videoWidth) * windowWidth)
476
+ } else if (options.height) {
477
+ windowHeight = options.height
478
+ windowWidth = Math.round((videoWidth / videoHeight) * windowHeight)
479
+ } else {
480
+ // Auto-size: fit to screen if video is larger, otherwise use video dimensions
481
+ const scale = Math.min(innerWidth / videoWidth, innerHeight / videoHeight, 1)
482
+ windowWidth = Math.round(videoWidth * scale)
483
+ windowHeight = Math.round(videoHeight * scale)
484
+ }
485
+
486
+ // Ensure minimum size
487
+ windowWidth = Math.max(windowWidth, 320)
488
+ windowHeight = Math.max(windowHeight, 180)
489
+
490
+ // Build video attributes
491
+ const videoAttrs: string[] = []
492
+ if (options.autoplay) videoAttrs.push('autoplay')
493
+ if (options.controls) videoAttrs.push('controls')
494
+ if (options.loop) videoAttrs.push('loop')
495
+ if (options.muted) videoAttrs.push('muted')
496
+ videoAttrs.push('style="width:100%;height:100%;object-fit:contain"')
497
+
498
+ const videoHtml = `<video src="${url}" ${videoAttrs.join(' ')}></video>`
499
+
500
+ // Create window
501
+ kernel.windows.create({
502
+ title: windowTitle,
503
+ html: videoHtml,
504
+ width: windowWidth,
505
+ height: windowHeight,
506
+ max: options.fullscreen
507
+ })
508
+
509
+ if (duration > 0) {
510
+ const minutes = Math.floor(duration / 60)
511
+ const seconds = Math.floor(duration % 60)
512
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file} (${minutes}:${seconds.toString().padStart(2, '0')})`))
513
+ } else {
514
+ await writelnStdout(process, terminal, chalk.green(`Playing: ${file}`))
515
+ }
516
+ }
517
+ } catch (error) {
518
+ await writelnStderr(process, terminal, chalk.red(`view: error viewing ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`))
519
+ return 1
520
+ }
521
+ }
522
+
523
+ return 0
524
+ }
525
+ })
526
+ }