@vee3/upload 0.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 +23 -0
- package/src/api.js +34 -0
- package/src/detectContentType.js +90 -0
- package/src/index.js +96 -0
- package/src/uploadFile.js +25 -0
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vee3/upload",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upload local files to Vee3.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vee3-upload": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["src"],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/vee3io/Vee3.git",
|
|
19
|
+
"directory": "packages/upload"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["vee3", "upload", "cli", "mcp"],
|
|
22
|
+
"license": "MIT"
|
|
23
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const DEFAULT_API_BASE_URL = "https://api.vee3.io";
|
|
2
|
+
|
|
3
|
+
async function formatApiError(response) {
|
|
4
|
+
const responseText = await response.text();
|
|
5
|
+
try {
|
|
6
|
+
const payload = JSON.parse(responseText);
|
|
7
|
+
if (payload && typeof payload.error === "object") {
|
|
8
|
+
const requestId = payload.error.request_id ? ` (request_id=${payload.error.request_id})` : "";
|
|
9
|
+
return `${payload.error.code}: ${payload.error.message}${requestId}`;
|
|
10
|
+
}
|
|
11
|
+
} catch {
|
|
12
|
+
// Fall through to raw response text.
|
|
13
|
+
}
|
|
14
|
+
return responseText || `HTTP ${response.status}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resolveUpload({ apiBaseUrl, uploadCode, contentType }) {
|
|
18
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/$/, "")}/v1/agent-uploads/resolve`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json"
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
upload_code: uploadCode,
|
|
25
|
+
content_type: contentType
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(await formatApiError(response));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return await response.json();
|
|
34
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const extensionContentTypes = new Map([
|
|
5
|
+
[".jpg", "image/jpeg"],
|
|
6
|
+
[".jpeg", "image/jpeg"],
|
|
7
|
+
[".png", "image/png"],
|
|
8
|
+
[".gif", "image/gif"],
|
|
9
|
+
[".webp", "image/webp"],
|
|
10
|
+
[".bmp", "image/bmp"],
|
|
11
|
+
[".tif", "image/tiff"],
|
|
12
|
+
[".tiff", "image/tiff"],
|
|
13
|
+
[".svg", "image/svg+xml"],
|
|
14
|
+
[".mp4", "video/mp4"],
|
|
15
|
+
[".m4v", "video/mp4"],
|
|
16
|
+
[".webm", "video/webm"],
|
|
17
|
+
[".mov", "video/quicktime"],
|
|
18
|
+
[".avi", "video/x-msvideo"],
|
|
19
|
+
[".mkv", "video/x-matroska"],
|
|
20
|
+
[".mpeg", "video/mpeg"],
|
|
21
|
+
[".mpg", "video/mpeg"],
|
|
22
|
+
[".3gp", "video/3gpp"],
|
|
23
|
+
[".mp3", "audio/mpeg"],
|
|
24
|
+
[".m4a", "audio/mp4"],
|
|
25
|
+
[".wav", "audio/wav"],
|
|
26
|
+
[".ogg", "audio/ogg"],
|
|
27
|
+
[".pdf", "application/pdf"],
|
|
28
|
+
[".zip", "application/zip"],
|
|
29
|
+
[".json", "application/json"],
|
|
30
|
+
[".txt", "text/plain"],
|
|
31
|
+
[".csv", "text/csv"]
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function matchesBytes(bytes, expectedBytes, offset = 0) {
|
|
35
|
+
if (bytes.length < offset + expectedBytes.length) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return expectedBytes.every((byte, index) => bytes[offset + index] === byte);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readAscii(bytes, start, end) {
|
|
42
|
+
return Buffer.from(bytes.subarray(start, end)).toString("ascii");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readSignature(filePath) {
|
|
46
|
+
const file = await open(filePath, "r");
|
|
47
|
+
try {
|
|
48
|
+
const buffer = Buffer.alloc(64);
|
|
49
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
50
|
+
return buffer.subarray(0, bytesRead);
|
|
51
|
+
} finally {
|
|
52
|
+
await file.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function detectContentType(filePath) {
|
|
57
|
+
const signature = await readSignature(filePath);
|
|
58
|
+
|
|
59
|
+
if (matchesBytes(signature, [0xff, 0xd8, 0xff])) {
|
|
60
|
+
return "image/jpeg";
|
|
61
|
+
}
|
|
62
|
+
if (matchesBytes(signature, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
63
|
+
return "image/png";
|
|
64
|
+
}
|
|
65
|
+
if (readAscii(signature, 0, 6) === "GIF87a" || readAscii(signature, 0, 6) === "GIF89a") {
|
|
66
|
+
return "image/gif";
|
|
67
|
+
}
|
|
68
|
+
if (readAscii(signature, 0, 4) === "RIFF" && readAscii(signature, 8, 12) === "WEBP") {
|
|
69
|
+
return "image/webp";
|
|
70
|
+
}
|
|
71
|
+
if (readAscii(signature, 0, 4) === "%PDF") {
|
|
72
|
+
return "application/pdf";
|
|
73
|
+
}
|
|
74
|
+
if (matchesBytes(signature, [0x50, 0x4b, 0x03, 0x04])) {
|
|
75
|
+
return "application/zip";
|
|
76
|
+
}
|
|
77
|
+
if (readAscii(signature, 0, 4) === "RIFF" && readAscii(signature, 8, 12) === "AVI ") {
|
|
78
|
+
return "video/x-msvideo";
|
|
79
|
+
}
|
|
80
|
+
if (matchesBytes(signature, [0x1a, 0x45, 0xdf, 0xa3])) {
|
|
81
|
+
return extname(filePath).toLowerCase() === ".mkv" ? "video/x-matroska" : "video/webm";
|
|
82
|
+
}
|
|
83
|
+
if (readAscii(signature, 4, 8) === "ftyp") {
|
|
84
|
+
const brand = readAscii(signature, 8, 12);
|
|
85
|
+
return brand === "qt " ? "video/quicktime" : "video/mp4";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const extension = extname(filePath).toLowerCase();
|
|
89
|
+
return extensionContentTypes.get(extension) ?? "application/octet-stream";
|
|
90
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { DEFAULT_API_BASE_URL, resolveUpload } from "./api.js";
|
|
5
|
+
import { detectContentType } from "./detectContentType.js";
|
|
6
|
+
import { uploadFileToSignedUrl } from "./uploadFile.js";
|
|
7
|
+
|
|
8
|
+
function printUsage() {
|
|
9
|
+
console.error(
|
|
10
|
+
"Usage: vee3-upload <upload_code> <file_path> [--api-base-url https://api.vee3.io] [--json]"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArguments(argumentsList) {
|
|
15
|
+
const positionalArguments = [];
|
|
16
|
+
let apiBaseUrl = DEFAULT_API_BASE_URL;
|
|
17
|
+
let outputJson = false;
|
|
18
|
+
|
|
19
|
+
for (let index = 0; index < argumentsList.length; index += 1) {
|
|
20
|
+
const argument = argumentsList[index];
|
|
21
|
+
if (argument === "--api-base-url") {
|
|
22
|
+
const nextArgument = argumentsList[index + 1];
|
|
23
|
+
if (!nextArgument) {
|
|
24
|
+
throw new Error("--api-base-url requires a value");
|
|
25
|
+
}
|
|
26
|
+
apiBaseUrl = nextArgument;
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (argument === "--json") {
|
|
31
|
+
outputJson = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
positionalArguments.push(argument);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (positionalArguments.length !== 2) {
|
|
38
|
+
throw new Error("Expected upload_code and file_path");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
uploadCode: positionalArguments[0],
|
|
43
|
+
filePath: positionalArguments[1],
|
|
44
|
+
apiBaseUrl,
|
|
45
|
+
outputJson
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
const { uploadCode, filePath, apiBaseUrl, outputJson } = parseArguments(
|
|
51
|
+
process.argv.slice(2)
|
|
52
|
+
);
|
|
53
|
+
const fileStat = await stat(filePath);
|
|
54
|
+
if (!fileStat.isFile()) {
|
|
55
|
+
throw new Error(`File path is not a file: ${filePath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const contentType = await detectContentType(filePath);
|
|
59
|
+
const resolvedUpload = await resolveUpload({
|
|
60
|
+
apiBaseUrl,
|
|
61
|
+
uploadCode,
|
|
62
|
+
contentType
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (fileStat.size > resolvedUpload.max_bytes) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`File is too large: ${fileStat.size} bytes exceeds ${resolvedUpload.max_bytes} bytes`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await uploadFileToSignedUrl({
|
|
72
|
+
uploadUrl: resolvedUpload.upload_url,
|
|
73
|
+
filePath,
|
|
74
|
+
fileSizeBytes: fileStat.size,
|
|
75
|
+
requiredHeaders: resolvedUpload.required_headers
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (outputJson) {
|
|
79
|
+
console.log(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
upload_id: resolvedUpload.upload_id,
|
|
82
|
+
content_type: contentType,
|
|
83
|
+
size_bytes: fileStat.size
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(resolvedUpload.upload_id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
main().catch((error) => {
|
|
93
|
+
printUsage();
|
|
94
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export async function uploadFileToSignedUrl({
|
|
4
|
+
uploadUrl,
|
|
5
|
+
filePath,
|
|
6
|
+
fileSizeBytes,
|
|
7
|
+
requiredHeaders
|
|
8
|
+
}) {
|
|
9
|
+
const headers = {
|
|
10
|
+
...requiredHeaders,
|
|
11
|
+
"Content-Length": String(fileSizeBytes)
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const response = await fetch(uploadUrl, {
|
|
15
|
+
method: "PUT",
|
|
16
|
+
headers,
|
|
17
|
+
body: createReadStream(filePath),
|
|
18
|
+
duplex: "half"
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const responseText = await response.text();
|
|
23
|
+
throw new Error(responseText || `Upload failed with HTTP ${response.status}`);
|
|
24
|
+
}
|
|
25
|
+
}
|