@vee3/upload 0.1.1 → 0.2.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.
package/package.json CHANGED
@@ -1,24 +1,31 @@
1
- {
2
- "name": "@vee3/upload",
3
- "version": "0.1.1",
4
- "description": "Upload local files to Vee3.",
5
- "homepage": "https://vee3.io",
6
- "type": "module",
7
- "bin": {
8
- "vee3-upload": "./src/index.js"
9
- },
10
- "files": ["src"],
11
- "engines": {
12
- "node": ">=18"
13
- },
14
- "publishConfig": {
15
- "access": "public"
16
- },
17
- "repository": {
18
- "type": "git",
19
- "url": "https://github.com/vee3io/Vee3.git",
20
- "directory": "packages/upload"
21
- },
22
- "keywords": ["vee3", "upload", "cli", "mcp"],
23
- "license": "MIT"
24
- }
1
+ {
2
+ "name": "@vee3/upload",
3
+ "version": "0.2.1",
4
+ "description": "Upload local files to Vee3.",
5
+ "homepage": "https://vee3.io",
6
+ "type": "module",
7
+ "bin": {
8
+ "vee3-upload": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/vee3io/Vee3.git",
22
+ "directory": "packages/upload"
23
+ },
24
+ "keywords": [
25
+ "vee3",
26
+ "upload",
27
+ "cli",
28
+ "mcp"
29
+ ],
30
+ "license": "MIT"
31
+ }
package/src/api.js CHANGED
@@ -1,34 +1,43 @@
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
- }
1
+ import { UploadError, describeNetworkError } from "./errors.js";
2
+
3
+ export const DEFAULT_API_BASE_URL = "https://api.vee3.io";
4
+
5
+ async function formatApiError(response) {
6
+ const responseText = await response.text();
7
+ try {
8
+ const payload = JSON.parse(responseText);
9
+ if (payload && typeof payload.error === "object") {
10
+ const requestId = payload.error.request_id ? ` (request_id=${payload.error.request_id})` : "";
11
+ return `${payload.error.code}: ${payload.error.message}${requestId}`;
12
+ }
13
+ } catch {
14
+ // Fall through to raw response text.
15
+ }
16
+ return responseText || `HTTP ${response.status}`;
17
+ }
18
+
19
+ export async function resolveUpload({ apiBaseUrl, uploadCode, contentType }) {
20
+ const requestUrl = `${apiBaseUrl.replace(/\/$/, "")}/v1/agent-uploads/resolve`;
21
+
22
+ let response;
23
+ try {
24
+ response = await fetch(requestUrl, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json"
28
+ },
29
+ body: JSON.stringify({
30
+ upload_code: uploadCode,
31
+ content_type: contentType
32
+ })
33
+ });
34
+ } catch (error) {
35
+ throw new UploadError(describeNetworkError(error, `the Vee3 API at ${apiBaseUrl}`));
36
+ }
37
+
38
+ if (!response.ok) {
39
+ throw new UploadError(await formatApiError(response));
40
+ }
41
+
42
+ return await response.json();
43
+ }
package/src/errors.js ADDED
@@ -0,0 +1,51 @@
1
+ export class UsageError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "UsageError";
5
+ }
6
+ }
7
+
8
+ export class UploadError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "UploadError";
12
+ }
13
+ }
14
+
15
+ const TLS_VERIFICATION_CODES = new Set([
16
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
17
+ "SELF_SIGNED_CERT_IN_CHAIN",
18
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
19
+ "CERT_HAS_EXPIRED",
20
+ "ERR_TLS_CERT_ALTNAME_INVALID"
21
+ ]);
22
+
23
+ /**
24
+ * Turns a low-level fetch failure into a message a user can act on. Node's
25
+ * fetch throws a generic "fetch failed" TypeError and hides the real reason in
26
+ * error.cause, so we surface the underlying code here.
27
+ */
28
+ export function describeNetworkError(error, targetDescription) {
29
+ const cause = error?.cause ?? error;
30
+ const code = cause?.code;
31
+
32
+ if (code === "ECONNREFUSED") {
33
+ return `Could not connect to ${targetDescription}. Make sure it is running and the address is correct.`;
34
+ }
35
+ if (code === "ENOTFOUND") {
36
+ return `Could not find ${targetDescription}. Check the address and your network connection.`;
37
+ }
38
+ if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
39
+ return `Connection to ${targetDescription} timed out.`;
40
+ }
41
+ if (code && TLS_VERIFICATION_CODES.has(code)) {
42
+ return (
43
+ `Could not verify the TLS certificate for ${targetDescription}. ` +
44
+ "This usually means a proxy or antivirus is inspecting HTTPS traffic. " +
45
+ "Use Node 22.15 or newer, or set NODE_EXTRA_CA_CERTS to your root certificate."
46
+ );
47
+ }
48
+
49
+ const detail = cause?.message ?? error?.message ?? String(error);
50
+ return `Request to ${targetDescription} failed: ${detail}`;
51
+ }
package/src/format.js ADDED
@@ -0,0 +1,17 @@
1
+ const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"];
2
+
3
+ export function formatBytes(byteCount) {
4
+ if (!Number.isFinite(byteCount) || byteCount <= 0) {
5
+ return "0 B";
6
+ }
7
+
8
+ let unitIndex = 0;
9
+ let value = byteCount;
10
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
11
+ value /= 1024;
12
+ unitIndex += 1;
13
+ }
14
+
15
+ const decimals = unitIndex === 0 ? 0 : 1;
16
+ return `${value.toFixed(decimals)} ${BYTE_UNITS[unitIndex]}`;
17
+ }
package/src/index.js CHANGED
@@ -1,96 +1,121 @@
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
- });
1
+ #!/usr/bin/env node
2
+
3
+ import { stat } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
+ import { DEFAULT_API_BASE_URL, resolveUpload } from "./api.js";
6
+ import { detectContentType } from "./detectContentType.js";
7
+ import { UploadError, UsageError } from "./errors.js";
8
+ import { formatBytes } from "./format.js";
9
+ import { ensureSystemCertificateTrust } from "./systemCertificates.js";
10
+ import { uploadFileToSignedUrl } from "./uploadFile.js";
11
+
12
+ ensureSystemCertificateTrust();
13
+
14
+ function printUsage() {
15
+ console.error("Usage: vee3-upload <upload_code> <file_path>");
16
+ }
17
+
18
+ function parseArguments(argumentsList) {
19
+ const positionalArguments = [];
20
+ let apiBaseUrl = DEFAULT_API_BASE_URL;
21
+ let outputJson = false;
22
+
23
+ for (let index = 0; index < argumentsList.length; index += 1) {
24
+ const argument = argumentsList[index];
25
+ if (argument === "--api-base-url") {
26
+ const nextArgument = argumentsList[index + 1];
27
+ if (!nextArgument) {
28
+ throw new UsageError("--api-base-url requires a value");
29
+ }
30
+ apiBaseUrl = nextArgument;
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (argument === "--json") {
35
+ outputJson = true;
36
+ continue;
37
+ }
38
+ positionalArguments.push(argument);
39
+ }
40
+
41
+ if (positionalArguments.length !== 2) {
42
+ throw new UsageError("Expected upload_code and file_path");
43
+ }
44
+
45
+ return {
46
+ uploadCode: positionalArguments[0],
47
+ filePath: positionalArguments[1],
48
+ apiBaseUrl,
49
+ outputJson
50
+ };
51
+ }
52
+
53
+ async function readFileStat(filePath) {
54
+ try {
55
+ return await stat(filePath);
56
+ } catch (error) {
57
+ if (error?.code === "ENOENT") {
58
+ throw new UploadError(`File not found: ${filePath}`);
59
+ }
60
+ throw new UploadError(`Could not read file: ${filePath} (${error.message})`);
61
+ }
62
+ }
63
+
64
+ async function main() {
65
+ const { uploadCode, filePath, apiBaseUrl, outputJson } = parseArguments(
66
+ process.argv.slice(2)
67
+ );
68
+
69
+ const fileStat = await readFileStat(filePath);
70
+ if (!fileStat.isFile()) {
71
+ throw new UploadError(`Not a file: ${filePath}`);
72
+ }
73
+
74
+ const contentType = await detectContentType(filePath);
75
+
76
+ console.error("Resolving upload code...");
77
+ const resolvedUpload = await resolveUpload({
78
+ apiBaseUrl,
79
+ uploadCode,
80
+ contentType
81
+ });
82
+
83
+ if (fileStat.size > resolvedUpload.max_bytes) {
84
+ throw new UploadError(
85
+ `File is too large: ${formatBytes(fileStat.size)} exceeds the ${formatBytes(
86
+ resolvedUpload.max_bytes
87
+ )} limit.`
88
+ );
89
+ }
90
+
91
+ console.error(`Uploading ${basename(filePath)} (${formatBytes(fileStat.size)})...`);
92
+ await uploadFileToSignedUrl({
93
+ uploadUrl: resolvedUpload.upload_url,
94
+ filePath,
95
+ fileSizeBytes: fileStat.size,
96
+ requiredHeaders: resolvedUpload.required_headers
97
+ });
98
+
99
+ if (outputJson) {
100
+ console.log(
101
+ JSON.stringify({
102
+ upload_id: resolvedUpload.upload_id,
103
+ content_type: contentType,
104
+ size_bytes: fileStat.size
105
+ })
106
+ );
107
+ return;
108
+ }
109
+
110
+ console.log(resolvedUpload.upload_id);
111
+ }
112
+
113
+ main().catch((error) => {
114
+ if (error instanceof UsageError) {
115
+ printUsage();
116
+ console.error(error.message);
117
+ } else {
118
+ console.error(error instanceof Error ? error.message : String(error));
119
+ }
120
+ process.exit(1);
121
+ });
@@ -0,0 +1,59 @@
1
+ import { formatBytes } from "./format.js";
2
+
3
+ const TTY_RENDER_INTERVAL_MS = 150;
4
+ const NON_TTY_MILESTONE_STEP = 20;
5
+
6
+ /**
7
+ * Reports upload progress to stderr so that stdout stays clean for the
8
+ * upload_id. In an interactive terminal it redraws a single line; in
9
+ * non-interactive contexts (such as agent logs) it prints discrete milestone
10
+ * lines to avoid carriage-return spam.
11
+ */
12
+ export function createUploadProgressReporter(totalBytes) {
13
+ const isInteractive = Boolean(process.stderr.isTTY);
14
+ let lastRenderTime = 0;
15
+ let lastMilestone = 0;
16
+
17
+ function computePercent(uploadedBytes) {
18
+ if (totalBytes <= 0) {
19
+ return 100;
20
+ }
21
+ return Math.min(100, Math.floor((uploadedBytes / totalBytes) * 100));
22
+ }
23
+
24
+ function report(uploadedBytes) {
25
+ const percent = computePercent(uploadedBytes);
26
+
27
+ if (isInteractive) {
28
+ const now = Date.now();
29
+ const isComplete = uploadedBytes >= totalBytes;
30
+ if (!isComplete && now - lastRenderTime < TTY_RENDER_INTERVAL_MS) {
31
+ return;
32
+ }
33
+ lastRenderTime = now;
34
+ process.stderr.write(
35
+ `\rUploading ${percent}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})`
36
+ );
37
+ return;
38
+ }
39
+
40
+ const milestone = Math.floor(percent / NON_TTY_MILESTONE_STEP) * NON_TTY_MILESTONE_STEP;
41
+ if (milestone <= lastMilestone || milestone >= 100) {
42
+ return;
43
+ }
44
+ lastMilestone = milestone;
45
+ process.stderr.write(
46
+ `Uploading ${milestone}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})\n`
47
+ );
48
+ }
49
+
50
+ function finish() {
51
+ if (isInteractive) {
52
+ process.stderr.write(`\rUploaded ${formatBytes(totalBytes)} (100%)\n`);
53
+ return;
54
+ }
55
+ process.stderr.write(`Uploaded ${formatBytes(totalBytes)} (100%)\n`);
56
+ }
57
+
58
+ return { report, finish };
59
+ }
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ const SYSTEM_CA_FLAG = "--use-system-ca";
4
+ const REEXEC_GUARD_VARIABLE = "VEE3_UPLOAD_SYSTEM_CA_REEXEC";
5
+
6
+ function systemCaFlagAlreadyActive() {
7
+ if (process.execArgv.includes(SYSTEM_CA_FLAG)) {
8
+ return true;
9
+ }
10
+ const nodeOptions = process.env.NODE_OPTIONS ?? "";
11
+ return nodeOptions.includes(SYSTEM_CA_FLAG);
12
+ }
13
+
14
+ function systemCaFlagSupported() {
15
+ try {
16
+ return process.allowedNodeEnvironmentFlags.has(SYSTEM_CA_FLAG);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Networks that perform TLS inspection (corporate proxies, some antivirus
24
+ * products) present certificates signed by a private root CA that Node does not
25
+ * trust by default, so HTTPS requests fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE.
26
+ * Re-executing with --use-system-ca makes Node trust the same roots the
27
+ * operating system already trusts, which is what browsers and OS HTTP clients
28
+ * use. On older Node versions without the flag we fall back to whatever
29
+ * NODE_EXTRA_CA_CERTS provides.
30
+ */
31
+ export function ensureSystemCertificateTrust() {
32
+ if (process.env[REEXEC_GUARD_VARIABLE] === "1") {
33
+ return;
34
+ }
35
+ if (systemCaFlagAlreadyActive()) {
36
+ return;
37
+ }
38
+ if (!systemCaFlagSupported()) {
39
+ return;
40
+ }
41
+
42
+ const entryScript = process.argv[1];
43
+ if (!entryScript) {
44
+ return;
45
+ }
46
+
47
+ const result = spawnSync(
48
+ process.execPath,
49
+ [SYSTEM_CA_FLAG, entryScript, ...process.argv.slice(2)],
50
+ {
51
+ stdio: "inherit",
52
+ env: { ...process.env, [REEXEC_GUARD_VARIABLE]: "1" }
53
+ }
54
+ );
55
+
56
+ if (result.error) {
57
+ return;
58
+ }
59
+
60
+ process.exit(result.status ?? 0);
61
+ }
package/src/uploadFile.js CHANGED
@@ -1,25 +1,52 @@
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
- }
1
+ import { createReadStream } from "node:fs";
2
+ import { Transform } from "node:stream";
3
+ import { UploadError, describeNetworkError } from "./errors.js";
4
+ import { createUploadProgressReporter } from "./progress.js";
5
+
6
+ export async function uploadFileToSignedUrl({
7
+ uploadUrl,
8
+ filePath,
9
+ fileSizeBytes,
10
+ requiredHeaders
11
+ }) {
12
+ const progressReporter = createUploadProgressReporter(fileSizeBytes);
13
+ let uploadedBytes = 0;
14
+
15
+ const progressStream = new Transform({
16
+ transform(chunk, _encoding, callback) {
17
+ uploadedBytes += chunk.length;
18
+ progressReporter.report(uploadedBytes);
19
+ callback(null, chunk);
20
+ }
21
+ });
22
+
23
+ const fileStream = createReadStream(filePath);
24
+ fileStream.on("error", (error) => {
25
+ progressStream.destroy(error);
26
+ });
27
+ fileStream.pipe(progressStream);
28
+
29
+ const headers = {
30
+ ...requiredHeaders,
31
+ "Content-Length": String(fileSizeBytes)
32
+ };
33
+
34
+ let response;
35
+ try {
36
+ response = await fetch(uploadUrl, {
37
+ method: "PUT",
38
+ headers,
39
+ body: progressStream,
40
+ duplex: "half"
41
+ });
42
+ } catch (error) {
43
+ throw new UploadError(describeNetworkError(error, "Vee3 storage"));
44
+ }
45
+
46
+ if (!response.ok) {
47
+ const responseText = (await response.text()).trim();
48
+ throw new UploadError(responseText || `Upload failed with HTTP ${response.status}`);
49
+ }
50
+
51
+ progressReporter.finish();
52
+ }