noupload 1.0.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/.github/workflows/release.yml +73 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/biome.json +34 -0
- package/bunfig.toml +7 -0
- package/dist/index.js +192 -0
- package/install.sh +68 -0
- package/package.json +47 -0
- package/scripts/inspect-help.ts +15 -0
- package/site/index.html +112 -0
- package/src/cli.ts +24 -0
- package/src/commands/audio/convert.ts +107 -0
- package/src/commands/audio/extract.ts +84 -0
- package/src/commands/audio/fade.ts +128 -0
- package/src/commands/audio/index.ts +35 -0
- package/src/commands/audio/merge.ts +109 -0
- package/src/commands/audio/normalize.ts +110 -0
- package/src/commands/audio/reverse.ts +64 -0
- package/src/commands/audio/speed.ts +101 -0
- package/src/commands/audio/trim.ts +98 -0
- package/src/commands/audio/volume.ts +91 -0
- package/src/commands/audio/waveform.ts +117 -0
- package/src/commands/doctor.ts +125 -0
- package/src/commands/image/adjust.ts +129 -0
- package/src/commands/image/border.ts +94 -0
- package/src/commands/image/bulk-compress.ts +111 -0
- package/src/commands/image/bulk-convert.ts +114 -0
- package/src/commands/image/bulk-resize.ts +112 -0
- package/src/commands/image/compress.ts +95 -0
- package/src/commands/image/convert.ts +116 -0
- package/src/commands/image/crop.ts +96 -0
- package/src/commands/image/favicon.ts +89 -0
- package/src/commands/image/filters.ts +108 -0
- package/src/commands/image/index.ts +49 -0
- package/src/commands/image/resize.ts +110 -0
- package/src/commands/image/rotate.ts +90 -0
- package/src/commands/image/strip-metadata.ts +60 -0
- package/src/commands/image/to-base64.ts +72 -0
- package/src/commands/image/watermark.ts +141 -0
- package/src/commands/pdf/compress.ts +157 -0
- package/src/commands/pdf/decrypt.ts +102 -0
- package/src/commands/pdf/delete-pages.ts +112 -0
- package/src/commands/pdf/duplicate.ts +119 -0
- package/src/commands/pdf/encrypt.ts +161 -0
- package/src/commands/pdf/from-images.ts +104 -0
- package/src/commands/pdf/index.ts +55 -0
- package/src/commands/pdf/merge.ts +84 -0
- package/src/commands/pdf/ocr.ts +270 -0
- package/src/commands/pdf/organize.ts +88 -0
- package/src/commands/pdf/page-numbers.ts +152 -0
- package/src/commands/pdf/reverse.ts +71 -0
- package/src/commands/pdf/rotate.ts +116 -0
- package/src/commands/pdf/sanitize.ts +77 -0
- package/src/commands/pdf/sign.ts +156 -0
- package/src/commands/pdf/split.ts +148 -0
- package/src/commands/pdf/to-images.ts +84 -0
- package/src/commands/pdf/to-text.ts +51 -0
- package/src/commands/pdf/watermark.ts +179 -0
- package/src/commands/qr/bulk-generate.ts +136 -0
- package/src/commands/qr/generate.ts +128 -0
- package/src/commands/qr/index.ts +16 -0
- package/src/commands/qr/scan.ts +114 -0
- package/src/commands/setup.ts +156 -0
- package/src/index.ts +42 -0
- package/src/lib/audio/ffmpeg.ts +93 -0
- package/src/utils/colors.ts +41 -0
- package/src/utils/detect.ts +222 -0
- package/src/utils/errors.ts +89 -0
- package/src/utils/files.ts +148 -0
- package/src/utils/logger.ts +90 -0
- package/src/utils/pdf-tools.ts +220 -0
- package/src/utils/progress.ts +142 -0
- package/src/utils/style.ts +38 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import QRCode from 'qrcode';
|
|
5
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
6
|
+
import { ensureDir, resolvePath } from '../../utils/files';
|
|
7
|
+
import { info, success, warn } from '../../utils/logger';
|
|
8
|
+
import { withProgressBar } from '../../utils/progress';
|
|
9
|
+
|
|
10
|
+
export const bulkGenerate = defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'bulk-generate',
|
|
13
|
+
description: 'Generate multiple QR codes from a text file (one per line)',
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
input: {
|
|
17
|
+
type: 'positional',
|
|
18
|
+
description: 'Input text file with content (one item per line)',
|
|
19
|
+
required: true,
|
|
20
|
+
},
|
|
21
|
+
output: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
alias: 'o',
|
|
24
|
+
description: 'Output directory (default: ./qr-codes)',
|
|
25
|
+
default: './qr-codes',
|
|
26
|
+
},
|
|
27
|
+
format: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
alias: 'f',
|
|
30
|
+
description: 'Output format: png, svg (default: png)',
|
|
31
|
+
default: 'png',
|
|
32
|
+
},
|
|
33
|
+
size: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
alias: 's',
|
|
36
|
+
description: 'Size/scale of the QR codes (default: 10)',
|
|
37
|
+
default: '10',
|
|
38
|
+
},
|
|
39
|
+
prefix: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
alias: 'p',
|
|
42
|
+
description: 'Filename prefix (default: qr)',
|
|
43
|
+
default: 'qr',
|
|
44
|
+
},
|
|
45
|
+
errorLevel: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
alias: 'e',
|
|
48
|
+
description: 'Error correction level: L, M, Q, H (default: M)',
|
|
49
|
+
default: 'M',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
async run({ args }) {
|
|
53
|
+
try {
|
|
54
|
+
const input = requireArg(args.input, 'input');
|
|
55
|
+
|
|
56
|
+
const inputPath = resolvePath(input as string);
|
|
57
|
+
if (!existsSync(inputPath)) {
|
|
58
|
+
throw new FileNotFoundError(input as string);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read lines from input file
|
|
62
|
+
const content = readFileSync(inputPath, 'utf-8');
|
|
63
|
+
const lines = content
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((line) => line.trim())
|
|
66
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
67
|
+
|
|
68
|
+
if (lines.length === 0) {
|
|
69
|
+
warn('No content found in input file');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const outputDir = resolvePath(args.output || './qr-codes');
|
|
74
|
+
ensureDir(join(outputDir, 'dummy.txt')); // Ensure directory exists
|
|
75
|
+
|
|
76
|
+
const format = (args.format || 'png').toLowerCase();
|
|
77
|
+
const scale = Number.parseInt(args.size || '10', 10);
|
|
78
|
+
const prefix = args.prefix || 'qr';
|
|
79
|
+
const errorLevel = (args.errorLevel || 'M').toUpperCase() as 'L' | 'M' | 'Q' | 'H';
|
|
80
|
+
|
|
81
|
+
if (!['L', 'M', 'Q', 'H'].includes(errorLevel)) {
|
|
82
|
+
throw new Error('Error level must be L, M, Q, or H');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const options: QRCode.QRCodeToFileOptions = {
|
|
86
|
+
scale,
|
|
87
|
+
margin: 4,
|
|
88
|
+
errorCorrectionLevel: errorLevel,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
info(`Generating ${lines.length} QR codes...`);
|
|
92
|
+
|
|
93
|
+
let generated = 0;
|
|
94
|
+
let failed = 0;
|
|
95
|
+
|
|
96
|
+
await withProgressBar(
|
|
97
|
+
lines,
|
|
98
|
+
async (line: string, index: number) => {
|
|
99
|
+
const filename = `${prefix}-${String(index + 1).padStart(4, '0')}.${format}`;
|
|
100
|
+
const outputFilePath = join(outputDir, filename);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (format === 'svg') {
|
|
104
|
+
const svg = await QRCode.toString(line, {
|
|
105
|
+
type: 'svg',
|
|
106
|
+
margin: 4,
|
|
107
|
+
errorCorrectionLevel: errorLevel,
|
|
108
|
+
width: scale * 25,
|
|
109
|
+
});
|
|
110
|
+
writeFileSync(outputFilePath, svg);
|
|
111
|
+
} else {
|
|
112
|
+
await QRCode.toFile(outputFilePath, line, options);
|
|
113
|
+
}
|
|
114
|
+
generated++;
|
|
115
|
+
} catch {
|
|
116
|
+
failed++;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
label: 'Generating QR codes',
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
info(`Output directory: ${outputDir}`);
|
|
125
|
+
info(`Format: ${format.toUpperCase()}, Scale: ${scale}x, Error Level: ${errorLevel}`);
|
|
126
|
+
|
|
127
|
+
if (failed > 0) {
|
|
128
|
+
warn(`${failed} QR codes failed to generate`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
success(`${generated} QR codes generated`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
handleError(err);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import QRCode from 'qrcode';
|
|
4
|
+
import { handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const generate = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'generate',
|
|
12
|
+
description: 'Generate a QR code from text or URL',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
content: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Text or URL to encode in QR code',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path (default: qr.png)',
|
|
24
|
+
},
|
|
25
|
+
format: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
alias: 'f',
|
|
28
|
+
description: 'Output format: png, svg, terminal (default: png)',
|
|
29
|
+
default: 'png',
|
|
30
|
+
},
|
|
31
|
+
size: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
alias: 's',
|
|
34
|
+
description: 'Size/scale of the QR code (default: 10)',
|
|
35
|
+
default: '10',
|
|
36
|
+
},
|
|
37
|
+
margin: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
alias: 'm',
|
|
40
|
+
description: 'Margin around QR code in modules (default: 4)',
|
|
41
|
+
default: '4',
|
|
42
|
+
},
|
|
43
|
+
errorLevel: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
alias: 'e',
|
|
46
|
+
description: 'Error correction level: L, M, Q, H (default: M)',
|
|
47
|
+
default: 'M',
|
|
48
|
+
},
|
|
49
|
+
dark: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Dark module color (default: #000000)',
|
|
52
|
+
default: '#000000',
|
|
53
|
+
},
|
|
54
|
+
light: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Light module color (default: #ffffff)',
|
|
57
|
+
default: '#ffffff',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async run({ args }) {
|
|
61
|
+
try {
|
|
62
|
+
const content = requireArg(args.content, 'content') as string;
|
|
63
|
+
const format = (args.format || 'png').toLowerCase();
|
|
64
|
+
const scale = Number.parseInt(args.size || '10', 10);
|
|
65
|
+
const margin = Number.parseInt(args.margin || '4', 10);
|
|
66
|
+
const errorLevel = (args.errorLevel || 'M').toUpperCase() as 'L' | 'M' | 'Q' | 'H';
|
|
67
|
+
|
|
68
|
+
// Validate error correction level
|
|
69
|
+
if (!['L', 'M', 'Q', 'H'].includes(errorLevel)) {
|
|
70
|
+
throw new Error('Error level must be L, M, Q, or H');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const options: QRCode.QRCodeToFileOptions & QRCode.QRCodeToStringOptions = {
|
|
74
|
+
scale,
|
|
75
|
+
margin,
|
|
76
|
+
errorCorrectionLevel: errorLevel,
|
|
77
|
+
color: {
|
|
78
|
+
dark: args.dark || '#000000',
|
|
79
|
+
light: args.light || '#ffffff',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (format === 'terminal') {
|
|
84
|
+
// Output to terminal
|
|
85
|
+
await withSpinner(
|
|
86
|
+
'Generating QR code...',
|
|
87
|
+
async () => {
|
|
88
|
+
const qrString = await QRCode.toString(content, { type: 'terminal', ...options });
|
|
89
|
+
console.log(`\n${qrString}`);
|
|
90
|
+
},
|
|
91
|
+
'QR code generated'
|
|
92
|
+
);
|
|
93
|
+
} else if (format === 'svg' || format === 'png') {
|
|
94
|
+
// Output to file
|
|
95
|
+
let outputPath: string;
|
|
96
|
+
if (args.output) {
|
|
97
|
+
outputPath = resolvePath(args.output);
|
|
98
|
+
} else {
|
|
99
|
+
outputPath = resolvePath(`qr.${format}`);
|
|
100
|
+
}
|
|
101
|
+
ensureDir(outputPath);
|
|
102
|
+
|
|
103
|
+
await withSpinner(
|
|
104
|
+
'Generating QR code...',
|
|
105
|
+
async () => {
|
|
106
|
+
if (format === 'svg') {
|
|
107
|
+
const svg = await QRCode.toString(content, { type: 'svg', ...options });
|
|
108
|
+
await Bun.write(outputPath, svg);
|
|
109
|
+
} else {
|
|
110
|
+
await QRCode.toFile(outputPath, content, options);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
'QR code generated'
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
info(`Content: "${content.length > 50 ? `${content.slice(0, 47)}...` : content}"`);
|
|
117
|
+
info(`File: ${outputPath} (${getFileSize(outputPath)})`);
|
|
118
|
+
info(`Error correction: ${errorLevel}, Scale: ${scale}x`);
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`Unknown format: ${format}. Use 'png', 'svg', or 'terminal'`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
success('QR code created');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
handleError(err);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { bulkGenerate } from './bulk-generate';
|
|
3
|
+
import { generate } from './generate';
|
|
4
|
+
import { scan } from './scan';
|
|
5
|
+
|
|
6
|
+
export const qr = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'qr',
|
|
9
|
+
description: 'QR code generation and scanning tools',
|
|
10
|
+
},
|
|
11
|
+
subCommands: {
|
|
12
|
+
generate,
|
|
13
|
+
scan,
|
|
14
|
+
'bulk-generate': bulkGenerate,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import jsQR from 'jsqr';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
6
|
+
import { resolvePath } from '../../utils/files';
|
|
7
|
+
import { info, success, warn } from '../../utils/logger';
|
|
8
|
+
import { withSpinner } from '../../utils/progress';
|
|
9
|
+
|
|
10
|
+
interface ScanResult {
|
|
11
|
+
data: string;
|
|
12
|
+
location: {
|
|
13
|
+
topRightCorner: { x: number; y: number };
|
|
14
|
+
topLeftCorner: { x: number; y: number };
|
|
15
|
+
bottomRightCorner: { x: number; y: number };
|
|
16
|
+
bottomLeftCorner: { x: number; y: number };
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const scan = defineCommand({
|
|
21
|
+
meta: {
|
|
22
|
+
name: 'scan',
|
|
23
|
+
description: 'Scan and decode a QR code from an image',
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
input: {
|
|
27
|
+
type: 'positional',
|
|
28
|
+
description: 'Input image file containing QR code',
|
|
29
|
+
required: true,
|
|
30
|
+
},
|
|
31
|
+
json: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
alias: 'j',
|
|
34
|
+
description: 'Output result as JSON',
|
|
35
|
+
default: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
async run({ args }) {
|
|
39
|
+
try {
|
|
40
|
+
const input = requireArg(args.input, 'input');
|
|
41
|
+
|
|
42
|
+
const inputPath = resolvePath(input as string);
|
|
43
|
+
if (!existsSync(inputPath)) {
|
|
44
|
+
throw new FileNotFoundError(input as string);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await withSpinner(
|
|
48
|
+
'Scanning QR code...',
|
|
49
|
+
async (): Promise<ScanResult | null> => {
|
|
50
|
+
// Load image and convert to raw RGBA data
|
|
51
|
+
const image = sharp(inputPath);
|
|
52
|
+
const { width, height } = await image.metadata();
|
|
53
|
+
|
|
54
|
+
if (!width || !height) {
|
|
55
|
+
throw new Error('Could not read image dimensions');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Convert to raw RGBA
|
|
59
|
+
const rawData = await image.ensureAlpha().raw().toBuffer();
|
|
60
|
+
|
|
61
|
+
// Create Uint8ClampedArray for jsQR
|
|
62
|
+
const data = new Uint8ClampedArray(rawData);
|
|
63
|
+
|
|
64
|
+
// Scan for QR code
|
|
65
|
+
const qrCode = jsQR(data, width, height);
|
|
66
|
+
|
|
67
|
+
if (qrCode) {
|
|
68
|
+
return {
|
|
69
|
+
data: qrCode.data,
|
|
70
|
+
location: qrCode.location,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
},
|
|
75
|
+
'Scan complete'
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (result) {
|
|
79
|
+
if (args.json) {
|
|
80
|
+
console.log(JSON.stringify(result, null, 2));
|
|
81
|
+
} else {
|
|
82
|
+
info(`Content: ${result.data}`);
|
|
83
|
+
|
|
84
|
+
// Detect content type
|
|
85
|
+
const contentData = result.data;
|
|
86
|
+
if (contentData.startsWith('http://') || contentData.startsWith('https://')) {
|
|
87
|
+
info('Type: URL');
|
|
88
|
+
} else if (contentData.startsWith('mailto:')) {
|
|
89
|
+
info('Type: Email');
|
|
90
|
+
} else if (contentData.startsWith('tel:')) {
|
|
91
|
+
info('Type: Phone');
|
|
92
|
+
} else if (contentData.startsWith('WIFI:')) {
|
|
93
|
+
info('Type: WiFi Network');
|
|
94
|
+
} else if (contentData.startsWith('BEGIN:VCARD')) {
|
|
95
|
+
info('Type: Contact (vCard)');
|
|
96
|
+
} else if (contentData.startsWith('BEGIN:VEVENT')) {
|
|
97
|
+
info('Type: Calendar Event');
|
|
98
|
+
} else {
|
|
99
|
+
info('Type: Text');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
success('QR code decoded');
|
|
103
|
+
} else {
|
|
104
|
+
warn('No QR code found in the image');
|
|
105
|
+
info('Tips:');
|
|
106
|
+
info(' - Ensure the QR code is clearly visible');
|
|
107
|
+
info(' - Try cropping the image to focus on the QR code');
|
|
108
|
+
info(' - Ensure good contrast and lighting');
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
handleError(err);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import { type ToolInfo, detectTool, getPackageMap } from '../utils/detect';
|
|
5
|
+
import { error, info, success, warn } from '../utils/logger';
|
|
6
|
+
import { c, sym } from '../utils/style';
|
|
7
|
+
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{ name: 'ffmpeg', check: 'ffmpeg' },
|
|
10
|
+
{ name: 'ghostscript', check: 'gs', altCheck: 'gswin64c' },
|
|
11
|
+
{ name: 'qpdf', check: 'qpdf' },
|
|
12
|
+
{ name: 'mupdf', check: 'mutool' },
|
|
13
|
+
{ name: 'poppler', check: 'pdftotext' },
|
|
14
|
+
{ name: 'imagemagick', check: 'magick', altCheck: 'convert' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
async function detectPackageManager(): Promise<
|
|
18
|
+
'brew' | 'apt' | 'dnf' | 'pacman' | 'apk' | 'winget' | null
|
|
19
|
+
> {
|
|
20
|
+
if (process.platform === 'darwin') {
|
|
21
|
+
if ((await detectTool('brew')).available) return 'brew';
|
|
22
|
+
} else if (process.platform === 'win32') {
|
|
23
|
+
if ((await detectTool('winget')).available) return 'winget';
|
|
24
|
+
} else if (process.platform === 'linux') {
|
|
25
|
+
if ((await detectTool('apt-get')).available) return 'apt';
|
|
26
|
+
if ((await detectTool('dnf')).available) return 'dnf';
|
|
27
|
+
if ((await detectTool('pacman')).available) return 'pacman';
|
|
28
|
+
if ((await detectTool('apk')).available) return 'apk';
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function askConfirmation(question: string): Promise<boolean> {
|
|
34
|
+
const rl = createInterface({
|
|
35
|
+
input: process.stdin,
|
|
36
|
+
output: process.stdout,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
rl.question(question, (answer) => {
|
|
41
|
+
rl.close();
|
|
42
|
+
resolve(answer.toLowerCase().startsWith('y'));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const setup = defineCommand({
|
|
48
|
+
meta: {
|
|
49
|
+
name: 'setup',
|
|
50
|
+
description: 'Automatically install missing system dependencies',
|
|
51
|
+
},
|
|
52
|
+
args: {
|
|
53
|
+
yes: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
alias: 'y',
|
|
56
|
+
description: 'Skip confirmation',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
async run({ args }) {
|
|
60
|
+
console.log('');
|
|
61
|
+
info('NoUpload CLI Setup');
|
|
62
|
+
console.log(c.dim(' Checking environment...'));
|
|
63
|
+
|
|
64
|
+
// 1. Detect Package Manager
|
|
65
|
+
const pm = await detectPackageManager();
|
|
66
|
+
if (!pm) {
|
|
67
|
+
error('Could not detect a supported package manager (brew, apt, winget).');
|
|
68
|
+
console.log(
|
|
69
|
+
` ${c.dim('Hint:')} Please install dependencies manually using "noupload doctor".`
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
console.log(` ${c.dim('Package Manager:')} ${c.active(pm)}`);
|
|
74
|
+
|
|
75
|
+
// 2. Detect Missing Tools
|
|
76
|
+
const missing: string[] = [];
|
|
77
|
+
const pkgMap = getPackageMap();
|
|
78
|
+
|
|
79
|
+
for (const tool of TOOLS) {
|
|
80
|
+
let available = (await detectTool(tool.check)).available;
|
|
81
|
+
if (!available && tool.altCheck) {
|
|
82
|
+
available = (await detectTool(tool.altCheck)).available;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!available) {
|
|
86
|
+
missing.push(tool.name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (missing.length === 0) {
|
|
91
|
+
success('All dependencies are already installed!');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Build Install Command
|
|
96
|
+
const packages = missing.map((tool) => pkgMap[tool][pm]);
|
|
97
|
+
let cmd = '';
|
|
98
|
+
const cmdArgs: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (pm === 'brew') {
|
|
101
|
+
cmd = 'brew';
|
|
102
|
+
cmdArgs.push('install', ...packages);
|
|
103
|
+
} else if (pm === 'apt') {
|
|
104
|
+
cmd = 'sudo';
|
|
105
|
+
cmdArgs.push('apt-get', 'install', '-y', ...packages);
|
|
106
|
+
} else if (pm === 'dnf') {
|
|
107
|
+
cmd = 'sudo';
|
|
108
|
+
cmdArgs.push('dnf', 'install', '-y', ...packages);
|
|
109
|
+
} else if (pm === 'pacman') {
|
|
110
|
+
cmd = 'sudo';
|
|
111
|
+
cmdArgs.push('pacman', '-S', '--noconfirm', ...packages);
|
|
112
|
+
} else if (pm === 'apk') {
|
|
113
|
+
cmd = 'sudo';
|
|
114
|
+
cmdArgs.push('apk', 'add', ...packages);
|
|
115
|
+
} else if (pm === 'winget') {
|
|
116
|
+
cmd = 'winget';
|
|
117
|
+
cmdArgs.push('install', ...packages);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fullCmd = `${cmd} ${cmdArgs.join(' ')}`;
|
|
121
|
+
|
|
122
|
+
console.log('');
|
|
123
|
+
console.warn(`${c.warn(sym.warn)} ${c.warn(`Missing tools: ${missing.join(', ')}`)}`);
|
|
124
|
+
console.log(` ${c.dim('Command:')} ${c.white(fullCmd)}`);
|
|
125
|
+
console.log('');
|
|
126
|
+
|
|
127
|
+
// 4. Confirm and Execute
|
|
128
|
+
if (!args.yes) {
|
|
129
|
+
const q = `${c.active(sym.input)} Run this command now? [y/N] `;
|
|
130
|
+
const confirmed = await askConfirmation(q);
|
|
131
|
+
if (!confirmed) {
|
|
132
|
+
console.log(c.dim(' Setup cancelled.'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log(c.dim(' Running installer... (you may be asked for your password)'));
|
|
139
|
+
console.log('');
|
|
140
|
+
|
|
141
|
+
const child = spawn(cmd, cmdArgs, {
|
|
142
|
+
stdio: 'inherit', // Important: Inherit stdio so user can interact/see progress
|
|
143
|
+
shell: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
child.on('close', (code) => {
|
|
147
|
+
console.log('');
|
|
148
|
+
if (code === 0) {
|
|
149
|
+
success('Setup complete! All dependencies installed.');
|
|
150
|
+
} else {
|
|
151
|
+
error(`Installer failed with code ${code}.`);
|
|
152
|
+
console.log(c.dim(' Please try running the command manually.'));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { renderUsage, runMain } from 'citty';
|
|
3
|
+
import { main } from './cli';
|
|
4
|
+
import { hex } from './utils/colors';
|
|
5
|
+
|
|
6
|
+
// Theme Colors
|
|
7
|
+
const ORANGE = hex('#f59e0b');
|
|
8
|
+
const PURPLE = hex('#a855f7'); // Tailwind Purple 500
|
|
9
|
+
|
|
10
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic wrapper for citty types
|
|
11
|
+
async function customShowUsage(cmd: any, parent?: any) {
|
|
12
|
+
try {
|
|
13
|
+
let output = await renderUsage(cmd, parent);
|
|
14
|
+
|
|
15
|
+
// Fix ugly subcommand lists in USAGE line
|
|
16
|
+
// Replaces long lists like "merge|split|compress|..." with "<command>"
|
|
17
|
+
output = output.replace(/ ([a-z0-9-]+\|[a-z0-9-|]+)/g, ' <command>');
|
|
18
|
+
|
|
19
|
+
// Colorize commands in the list to Theme Orange
|
|
20
|
+
output = output.replace(/^\s+`([a-z0-9-]+)`/gm, (match, commandName) => {
|
|
21
|
+
const indentation = match.substring(0, match.indexOf('`'));
|
|
22
|
+
return `${indentation}${ORANGE(commandName)}`;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Colorize footer example command to Theme Purple
|
|
26
|
+
output = output.replace(/Use `([^`]+)`/g, (match, content) => {
|
|
27
|
+
return `Use ${PURPLE(content)}`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Colorize remaining backticked items (Usage line) to Theme Orange
|
|
31
|
+
output = output.replace(/`([^`]+)`/g, (match, content) => {
|
|
32
|
+
// Backticks are already stripped from 'content' capture group
|
|
33
|
+
return ORANGE(content);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
console.log(`${output}\n`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
runMain(main, { showUsage: customShowUsage });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { detectFFmpeg, getInstallCommand } from '../../utils/detect';
|
|
3
|
+
import { warn } from '../../utils/logger';
|
|
4
|
+
import { c } from '../../utils/style';
|
|
5
|
+
|
|
6
|
+
export interface FFmpegOptions {
|
|
7
|
+
input: string;
|
|
8
|
+
output: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Run FFmpeg command
|
|
13
|
+
export async function runFFmpeg(options: FFmpegOptions): Promise<void> {
|
|
14
|
+
const ffmpeg = await detectFFmpeg();
|
|
15
|
+
|
|
16
|
+
if (!ffmpeg.available || !ffmpeg.path) {
|
|
17
|
+
const installCmd = getInstallCommand('ffmpeg');
|
|
18
|
+
throw new Error(`FFmpeg not found. ${installCmd ? `Install with: ${installCmd}` : ''}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ffmpegPath = ffmpeg.path;
|
|
22
|
+
|
|
23
|
+
const args = [
|
|
24
|
+
'-y', // Overwrite output
|
|
25
|
+
'-i',
|
|
26
|
+
options.input,
|
|
27
|
+
...(options.args || []),
|
|
28
|
+
options.output,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const proc = spawn(ffmpegPath, args, {
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let stderr = '';
|
|
37
|
+
proc.stderr?.on('data', (data) => {
|
|
38
|
+
stderr += data.toString();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
proc.on('close', (code) => {
|
|
42
|
+
if (code === 0) {
|
|
43
|
+
resolve();
|
|
44
|
+
} else {
|
|
45
|
+
reject(new Error(`FFmpeg failed: ${stderr.slice(-500)}`));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
proc.on('error', reject);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if FFmpeg is available
|
|
54
|
+
export async function checkFFmpeg(): Promise<boolean> {
|
|
55
|
+
const ffmpeg = await detectFFmpeg();
|
|
56
|
+
|
|
57
|
+
if (!ffmpeg.available) {
|
|
58
|
+
warn('FFmpeg is required for audio operations');
|
|
59
|
+
const installCmd = getInstallCommand('ffmpeg');
|
|
60
|
+
if (installCmd) {
|
|
61
|
+
console.log(` ${c.active('Install:')} ${installCmd}`);
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse time string to seconds (supports "HH:MM:SS" or "MM:SS" or seconds)
|
|
70
|
+
export function parseTime(time: string): number {
|
|
71
|
+
if (time.includes(':')) {
|
|
72
|
+
const parts = time.split(':').map(Number);
|
|
73
|
+
if (parts.length === 3) {
|
|
74
|
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
75
|
+
}
|
|
76
|
+
if (parts.length === 2) {
|
|
77
|
+
return parts[0] * 60 + parts[1];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return Number.parseFloat(time);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Format seconds to time string
|
|
84
|
+
export function formatTime(seconds: number): string {
|
|
85
|
+
const h = Math.floor(seconds / 3600);
|
|
86
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
87
|
+
const s = Math.floor(seconds % 60);
|
|
88
|
+
|
|
89
|
+
if (h > 0) {
|
|
90
|
+
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
91
|
+
}
|
|
92
|
+
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
93
|
+
}
|