@buildinternet/uploads 0.10.1 → 0.11.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/README.md +17 -3
- package/dist/cli-catalog.js +17 -3
- package/dist/cli-help.js +1 -1
- package/dist/cli.js +59 -7
- package/dist/commands/install.js +28 -15
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/report.d.ts +4 -0
- package/dist/commands/report.js +164 -0
- package/dist/commands/telemetry.d.ts +4 -0
- package/dist/commands/telemetry.js +91 -0
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +14 -11
- package/dist/format-usage.d.ts +38 -0
- package/dist/format-usage.js +125 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +17 -1
- package/dist/mcp/tools.js +84 -0
- package/dist/report.d.ts +68 -0
- package/dist/report.js +152 -0
- package/dist/telemetry.d.ts +60 -0
- package/dist/telemetry.js +290 -0
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -15,8 +15,10 @@ import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
|
15
15
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
16
16
|
import { buildCliProvenance } from "./provenance.js";
|
|
17
17
|
import { formatByteSize } from "./format-bytes.js";
|
|
18
|
+
import { formatUsageHuman } from "./format-usage.js";
|
|
18
19
|
import { packageVersion } from "./package-version.js";
|
|
19
|
-
import { writeCommandHelp } from "./cli-style.js";
|
|
20
|
+
import { colorEnabled, writeCommandHelp } from "./cli-style.js";
|
|
21
|
+
export { formatUsageHuman } from "./format-usage.js";
|
|
20
22
|
/** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
|
|
21
23
|
export function readFileArg(fileArg) {
|
|
22
24
|
try {
|
|
@@ -1112,12 +1114,20 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1112
1114
|
// --- usage / reconcile / purge ---
|
|
1113
1115
|
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
1114
1116
|
|
|
1115
|
-
Show workspace storage and monthly upload counters
|
|
1117
|
+
Show workspace storage and monthly upload counters.
|
|
1118
|
+
|
|
1119
|
+
When the API reports workspace quotas (typical on uploads.sh cloud /
|
|
1120
|
+
self-serve plans), human output includes progress bars toward those caps.
|
|
1121
|
+
Self-hosted or unlimited operator workspaces get usage totals only, plus a
|
|
1122
|
+
short unmetered note — no invented limits.
|
|
1116
1123
|
|
|
1117
1124
|
Examples:
|
|
1118
1125
|
uploads --env-file .env usage
|
|
1119
1126
|
uploads usage --json
|
|
1120
1127
|
`;
|
|
1128
|
+
function formatCount(n) {
|
|
1129
|
+
return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(n);
|
|
1130
|
+
}
|
|
1121
1131
|
export async function runUsage(ctx, args, help = false) {
|
|
1122
1132
|
if (help || parseCommandArgs(args).help) {
|
|
1123
1133
|
writeCommandHelp(USAGE_HELP);
|
|
@@ -1128,14 +1138,7 @@ export async function runUsage(ctx, args, help = false) {
|
|
|
1128
1138
|
await writeJson(result);
|
|
1129
1139
|
return 0;
|
|
1130
1140
|
}
|
|
1131
|
-
|
|
1132
|
-
`workspace: ${result.workspace}`,
|
|
1133
|
-
`bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
|
|
1134
|
-
`objects: ${result.objects}`,
|
|
1135
|
-
`uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
|
|
1136
|
-
`updated: ${result.updatedAt}`,
|
|
1137
|
-
];
|
|
1138
|
-
await writeStdout(lines.join("\n") + "\n");
|
|
1141
|
+
await writeStdout(formatUsageHuman(result, { color: colorEnabled(process.stdout) }).join("\n") + "\n");
|
|
1139
1142
|
return 0;
|
|
1140
1143
|
}
|
|
1141
1144
|
const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
|
|
@@ -1298,7 +1301,7 @@ export async function runDoctor(ctx, args, help = false) {
|
|
|
1298
1301
|
];
|
|
1299
1302
|
if (report.usage) {
|
|
1300
1303
|
lines.push(report.usage.ok
|
|
1301
|
-
? `usage: ${report.usage.bytes}
|
|
1304
|
+
? `usage: ${formatByteSize(report.usage.bytes ?? 0)}, ${formatCount(report.usage.objects ?? 0)} objects, ${formatCount(report.usage.uploadsInPeriod ?? 0)} uploads this period`
|
|
1302
1305
|
: `usage: failed — ${report.usage.error ?? "unknown"}`);
|
|
1303
1306
|
}
|
|
1304
1307
|
if (report.warning)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type UsageSnapshotLike = {
|
|
2
|
+
workspace: string;
|
|
3
|
+
bytes: number;
|
|
4
|
+
objects: number;
|
|
5
|
+
uploadsInPeriod: number;
|
|
6
|
+
periodStart: string;
|
|
7
|
+
updatedAt: string;
|
|
8
|
+
maxStorageBytes?: number;
|
|
9
|
+
storageRemainingBytes?: number;
|
|
10
|
+
maxUploadsPerPeriod?: number;
|
|
11
|
+
uploadsRemaining?: number;
|
|
12
|
+
};
|
|
13
|
+
export type FormatUsageOptions = {
|
|
14
|
+
/** IANA zone or undefined for the host local zone. */
|
|
15
|
+
timeZone?: string;
|
|
16
|
+
/** Color the bar fill (TTY / FORCE_COLOR). Default false. */
|
|
17
|
+
color?: boolean;
|
|
18
|
+
/** Progress track width in cells. Default 20. */
|
|
19
|
+
barWidth?: number;
|
|
20
|
+
};
|
|
21
|
+
/** True when the API reported any cumulative workspace quota. */
|
|
22
|
+
export declare function isUsageMetered(result: UsageSnapshotLike): boolean;
|
|
23
|
+
/** 0–100, one decimal. Missing/invalid caps → no bar. Matches web `usagePct`. */
|
|
24
|
+
export declare function usagePct(value: number, max: number | undefined): number | null;
|
|
25
|
+
/** Web thresholds: high ≥85, full ≥100. */
|
|
26
|
+
export declare function usageLevel(pct: number): "normal" | "high" | "full";
|
|
27
|
+
/**
|
|
28
|
+
* Terminal meter: `[████░░░░░░░░░░░░░░░░] 20%`
|
|
29
|
+
* At least one filled cell when pct > 0 so tiny usage is still visible.
|
|
30
|
+
*/
|
|
31
|
+
export declare function formatProgressBar(pct: number, opts?: {
|
|
32
|
+
width?: number;
|
|
33
|
+
color?: boolean;
|
|
34
|
+
}): string;
|
|
35
|
+
/** Host-local time by default; pass `timeZone` for stable tests. */
|
|
36
|
+
export declare function formatUsageTimestamp(iso: string, timeZone?: string): string;
|
|
37
|
+
/** Human-readable lines for `uploads usage` (not JSON). */
|
|
38
|
+
export declare function formatUsageHuman(result: UsageSnapshotLike, opts?: FormatUsageOptions): string[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human formatting for `uploads usage` — sizes, progress bars (mirrors web
|
|
3
|
+
* account meters when workspace quotas exist), and local timestamps.
|
|
4
|
+
*
|
|
5
|
+
* Metered vs unmetered is derived from the usage payload: cloud / self-serve
|
|
6
|
+
* workspaces ship with `maxStorageBytes` / `maxUploadsPerPeriod`; self-host
|
|
7
|
+
* and operator workspaces usually omit them (unlimited). Progress bars only
|
|
8
|
+
* appear for fields that have a positive cap — never invent a budget.
|
|
9
|
+
*/
|
|
10
|
+
import { formatByteSize } from "./format-bytes.js";
|
|
11
|
+
import { BRAND } from "./cli-brand.js";
|
|
12
|
+
/** True when the API reported any cumulative workspace quota. */
|
|
13
|
+
export function isUsageMetered(result) {
|
|
14
|
+
return (usagePct(result.bytes, result.maxStorageBytes) !== null ||
|
|
15
|
+
usagePct(result.uploadsInPeriod, result.maxUploadsPerPeriod) !== null);
|
|
16
|
+
}
|
|
17
|
+
/** 0–100, one decimal. Missing/invalid caps → no bar. Matches web `usagePct`. */
|
|
18
|
+
export function usagePct(value, max) {
|
|
19
|
+
if (typeof max !== "number" || !(max > 0) || !Number.isFinite(value))
|
|
20
|
+
return null;
|
|
21
|
+
return Math.min(100, Math.max(0, Math.round((value / max) * 1000) / 10));
|
|
22
|
+
}
|
|
23
|
+
/** Web thresholds: high ≥85, full ≥100. */
|
|
24
|
+
export function usageLevel(pct) {
|
|
25
|
+
if (pct >= 100)
|
|
26
|
+
return "full";
|
|
27
|
+
if (pct >= 85)
|
|
28
|
+
return "high";
|
|
29
|
+
return "normal";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Terminal meter: `[████░░░░░░░░░░░░░░░░] 20%`
|
|
33
|
+
* At least one filled cell when pct > 0 so tiny usage is still visible.
|
|
34
|
+
*/
|
|
35
|
+
export function formatProgressBar(pct, opts = {}) {
|
|
36
|
+
const width = opts.width ?? 20;
|
|
37
|
+
const clamped = Math.min(100, Math.max(0, pct));
|
|
38
|
+
let filled = Math.round((clamped / 100) * width);
|
|
39
|
+
if (clamped > 0 && filled === 0)
|
|
40
|
+
filled = 1;
|
|
41
|
+
if (clamped >= 100)
|
|
42
|
+
filled = width;
|
|
43
|
+
filled = Math.min(width, Math.max(0, filled));
|
|
44
|
+
const fillChar = "█";
|
|
45
|
+
const emptyChar = "░";
|
|
46
|
+
const body = fillChar.repeat(filled) + emptyChar.repeat(width - filled);
|
|
47
|
+
const bar = opts.color ? colorizeBar(body, filled, usageLevel(clamped)) : body;
|
|
48
|
+
const label = formatPctLabel(clamped);
|
|
49
|
+
return `[${bar}] ${label.padStart(5)}`;
|
|
50
|
+
}
|
|
51
|
+
function formatPctLabel(pct) {
|
|
52
|
+
if (Number.isInteger(pct))
|
|
53
|
+
return `${pct}%`;
|
|
54
|
+
return `${pct.toFixed(1)}%`;
|
|
55
|
+
}
|
|
56
|
+
function colorizeBar(body, filled, level) {
|
|
57
|
+
if (filled <= 0)
|
|
58
|
+
return paint(body, BRAND.muted);
|
|
59
|
+
const fill = body.slice(0, filled);
|
|
60
|
+
const empty = body.slice(filled);
|
|
61
|
+
const tone = level === "full" ? BRAND.accent : level === "high" ? BRAND.body : BRAND.green;
|
|
62
|
+
return paint(fill, tone) + paint(empty, BRAND.muted);
|
|
63
|
+
}
|
|
64
|
+
function paint(text, c) {
|
|
65
|
+
return `\u001b[38;2;${c.r};${c.g};${c.b}m${text}\u001b[0m`;
|
|
66
|
+
}
|
|
67
|
+
function formatCount(n) {
|
|
68
|
+
return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(n);
|
|
69
|
+
}
|
|
70
|
+
/** Host-local time by default; pass `timeZone` for stable tests. */
|
|
71
|
+
export function formatUsageTimestamp(iso, timeZone) {
|
|
72
|
+
const ms = Date.parse(iso);
|
|
73
|
+
if (!Number.isFinite(ms))
|
|
74
|
+
return iso;
|
|
75
|
+
try {
|
|
76
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "short",
|
|
79
|
+
day: "numeric",
|
|
80
|
+
hour: "numeric",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
timeZoneName: "short",
|
|
83
|
+
...(timeZone ? { timeZone } : {}),
|
|
84
|
+
}).format(new Date(ms));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return iso;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Human-readable lines for `uploads usage` (not JSON). */
|
|
91
|
+
export function formatUsageHuman(result, opts = {}) {
|
|
92
|
+
const width = opts.barWidth ?? 20;
|
|
93
|
+
const color = opts.color === true;
|
|
94
|
+
const metered = isUsageMetered(result);
|
|
95
|
+
const lines = [`workspace: ${result.workspace}`];
|
|
96
|
+
const storagePct = usagePct(result.bytes, result.maxStorageBytes);
|
|
97
|
+
if (storagePct !== null && result.maxStorageBytes != null) {
|
|
98
|
+
const detail = `${formatByteSize(result.bytes)} / ${formatByteSize(result.maxStorageBytes)}` +
|
|
99
|
+
(result.storageRemainingBytes != null
|
|
100
|
+
? ` (${formatByteSize(result.storageRemainingBytes)} free)`
|
|
101
|
+
: "");
|
|
102
|
+
const bar = formatProgressBar(storagePct, { width, color });
|
|
103
|
+
lines.push(`storage: ${bar} ${detail}`);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
lines.push(`storage: ${formatByteSize(result.bytes)}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push(`objects: ${formatCount(result.objects)}`);
|
|
109
|
+
const uploadsPct = usagePct(result.uploadsInPeriod, result.maxUploadsPerPeriod);
|
|
110
|
+
if (uploadsPct !== null && result.maxUploadsPerPeriod != null) {
|
|
111
|
+
const detail = `${formatCount(result.uploadsInPeriod)} / ${formatCount(result.maxUploadsPerPeriod)} this period (${result.periodStart})`;
|
|
112
|
+
const bar = formatProgressBar(uploadsPct, { width, color });
|
|
113
|
+
lines.push(`uploads: ${bar} ${detail}`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Unmetered (or no upload cap): period counter only — not a quota fraction.
|
|
117
|
+
lines.push(`uploads: ${formatCount(result.uploadsInPeriod)} this period (${result.periodStart})`);
|
|
118
|
+
}
|
|
119
|
+
lines.push(`updated: ${formatUsageTimestamp(result.updatedAt, opts.timeZone)}`);
|
|
120
|
+
if (!metered) {
|
|
121
|
+
// Self-host / operator unlimited: report usage without implying a plan.
|
|
122
|
+
lines.push("note: unmetered — no storage or upload quotas on this workspace");
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
package/dist/mcp/server.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* stdio transport lives in ./stdio.ts; logs must never go to stdout.
|
|
9
9
|
*/
|
|
10
10
|
import { UploadsError } from "../errors.js";
|
|
11
|
+
import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
|
|
11
12
|
export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
12
13
|
const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
13
14
|
const LATEST_PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -24,7 +25,7 @@ function toolErrorText(err) {
|
|
|
24
25
|
return err instanceof Error ? err.message : String(err);
|
|
25
26
|
}
|
|
26
27
|
export function createMcpServer(opts) {
|
|
27
|
-
const { serverInfo, tools } = opts;
|
|
28
|
+
const { serverInfo, tools, apiUrl } = opts;
|
|
28
29
|
async function callTool(id, params) {
|
|
29
30
|
const name = params.name;
|
|
30
31
|
const tool = typeof name === "string" ? tools.find((t) => t.name === name) : undefined;
|
|
@@ -34,8 +35,16 @@ export function createMcpServer(opts) {
|
|
|
34
35
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
35
36
|
return errorResponse(id, -32602, "tool arguments must be an object");
|
|
36
37
|
}
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
const command = `tool ${tool.name}`.slice(0, 120);
|
|
37
40
|
try {
|
|
38
41
|
const result = await tool.handler(args);
|
|
42
|
+
recordEvent({
|
|
43
|
+
surface: "mcp",
|
|
44
|
+
command,
|
|
45
|
+
exitCode: 0,
|
|
46
|
+
durationMs: Date.now() - start,
|
|
47
|
+
}, { apiUrl });
|
|
39
48
|
return response(id, {
|
|
40
49
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
41
50
|
structuredContent: result,
|
|
@@ -43,6 +52,13 @@ export function createMcpServer(opts) {
|
|
|
43
52
|
});
|
|
44
53
|
}
|
|
45
54
|
catch (err) {
|
|
55
|
+
recordEvent({
|
|
56
|
+
surface: "mcp",
|
|
57
|
+
command,
|
|
58
|
+
exitCode: 1,
|
|
59
|
+
durationMs: Date.now() - start,
|
|
60
|
+
errorCode: errorCodeFromUnknown(err),
|
|
61
|
+
}, { apiUrl });
|
|
46
62
|
return response(id, {
|
|
47
63
|
content: [{ type: "text", text: toolErrorText(err) }],
|
|
48
64
|
isError: true,
|
package/dist/mcp/tools.js
CHANGED
|
@@ -19,6 +19,8 @@ import { rewriteKeyExtension } from "../optimize.js";
|
|
|
19
19
|
import { buildCliProvenance } from "../provenance.js";
|
|
20
20
|
import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
21
21
|
import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
|
|
22
|
+
import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
|
|
23
|
+
import { resolveApiUrl } from "../config.js";
|
|
22
24
|
function optBool(args, name) {
|
|
23
25
|
const v = args[name];
|
|
24
26
|
if (v === undefined || v === null)
|
|
@@ -773,5 +775,87 @@ export function createUploadsMcpTools(opts) {
|
|
|
773
775
|
return buildDoctorReport(config, client);
|
|
774
776
|
},
|
|
775
777
|
},
|
|
778
|
+
{
|
|
779
|
+
name: "report",
|
|
780
|
+
description: "Send an explicit diagnostic report to the uploads team (message + optional text log). " +
|
|
781
|
+
"Only call this when the user asked to submit feedback, a bug report, or error logs — " +
|
|
782
|
+
"never automatically. Do not include tokens, secrets, or private file contents. " +
|
|
783
|
+
"Same as `uploads report`.",
|
|
784
|
+
inputSchema: {
|
|
785
|
+
type: "object",
|
|
786
|
+
properties: {
|
|
787
|
+
message: {
|
|
788
|
+
type: "string",
|
|
789
|
+
description: "Short description of the problem (required, 5–4000 chars).",
|
|
790
|
+
},
|
|
791
|
+
type: {
|
|
792
|
+
type: "string",
|
|
793
|
+
description: `One of: ${REPORT_TYPES.join(", ")} (default: other).`,
|
|
794
|
+
},
|
|
795
|
+
contact: {
|
|
796
|
+
type: "string",
|
|
797
|
+
description: "Optional contact for follow-up (email or handle).",
|
|
798
|
+
},
|
|
799
|
+
command: {
|
|
800
|
+
type: "string",
|
|
801
|
+
description: "Command that failed (e.g. put) — name only, no paths or args.",
|
|
802
|
+
},
|
|
803
|
+
errorCode: {
|
|
804
|
+
type: "string",
|
|
805
|
+
description: "Optional UploadsError code (e.g. KEY_POLICY).",
|
|
806
|
+
},
|
|
807
|
+
attachmentText: {
|
|
808
|
+
type: "string",
|
|
809
|
+
description: "Optional text log/trace body the user consented to send (max 256 KiB). Not a file path.",
|
|
810
|
+
},
|
|
811
|
+
attachmentFilename: {
|
|
812
|
+
type: "string",
|
|
813
|
+
description: "Filename label for attachmentText (default: trace.txt).",
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
required: ["message"],
|
|
817
|
+
additionalProperties: false,
|
|
818
|
+
},
|
|
819
|
+
async handler(args) {
|
|
820
|
+
const messageRaw = optString(args, "message");
|
|
821
|
+
if (!messageRaw)
|
|
822
|
+
usage("message is required");
|
|
823
|
+
const validated = validateReportMessage(messageRaw);
|
|
824
|
+
if (!validated.ok)
|
|
825
|
+
usage(validated.error);
|
|
826
|
+
const typeRaw = optString(args, "type");
|
|
827
|
+
if (typeRaw && !parseReportType(typeRaw)) {
|
|
828
|
+
usage(`type must be one of: ${REPORT_TYPES.join(", ")}`);
|
|
829
|
+
}
|
|
830
|
+
const type = parseReportType(typeRaw) ?? "other";
|
|
831
|
+
let attachment;
|
|
832
|
+
const attachmentText = optString(args, "attachmentText");
|
|
833
|
+
if (attachmentText) {
|
|
834
|
+
try {
|
|
835
|
+
attachment = attachmentFromText(attachmentText, optString(args, "attachmentFilename") ?? "trace.txt");
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
usage(err instanceof Error ? err.message : String(err));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const payload = buildReportPayload(validated.message, {
|
|
842
|
+
type,
|
|
843
|
+
contact: optString(args, "contact"),
|
|
844
|
+
surface: "mcp",
|
|
845
|
+
command: optString(args, "command"),
|
|
846
|
+
errorCode: optString(args, "errorCode"),
|
|
847
|
+
attachment,
|
|
848
|
+
});
|
|
849
|
+
const apiUrl = resolveApiUrl(globals);
|
|
850
|
+
const result = await submitReport(payload, { apiUrl });
|
|
851
|
+
if (!result.ok)
|
|
852
|
+
usage(`couldn't send report: ${result.error}`);
|
|
853
|
+
return {
|
|
854
|
+
ok: true,
|
|
855
|
+
id: result.id,
|
|
856
|
+
hasAttachment: result.hasAttachment,
|
|
857
|
+
};
|
|
858
|
+
},
|
|
859
|
+
},
|
|
776
860
|
];
|
|
777
861
|
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const REPORT_TYPES: readonly ["bug", "error", "idea", "other"];
|
|
2
|
+
export type ReportType = (typeof REPORT_TYPES)[number];
|
|
3
|
+
export declare const MIN_REPORT_MESSAGE = 5;
|
|
4
|
+
export declare const MAX_REPORT_MESSAGE = 4000;
|
|
5
|
+
export declare const MAX_REPORT_ATTACHMENT_BYTES: number;
|
|
6
|
+
export interface ReportAttachment {
|
|
7
|
+
filename: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ReportPayload {
|
|
12
|
+
message: string;
|
|
13
|
+
type: ReportType;
|
|
14
|
+
contact?: string;
|
|
15
|
+
surface: "cli" | "mcp";
|
|
16
|
+
cliVersion: string;
|
|
17
|
+
clientKind: string;
|
|
18
|
+
agentName?: string;
|
|
19
|
+
anonId?: string;
|
|
20
|
+
os: string;
|
|
21
|
+
arch: string;
|
|
22
|
+
runtime: string;
|
|
23
|
+
command?: string;
|
|
24
|
+
errorCode?: string;
|
|
25
|
+
attachment?: ReportAttachment;
|
|
26
|
+
}
|
|
27
|
+
export interface SubmitReportOptions {
|
|
28
|
+
apiUrl?: string;
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
export type ValidateMessageResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
message: string;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function validateReportMessage(raw: string): ValidateMessageResult;
|
|
39
|
+
export declare function parseReportType(raw: string | undefined): ReportType | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Load a local text file as an attachment. Rejects oversized / missing files.
|
|
42
|
+
* Callers must have obtained the path from explicit user input (`--file`).
|
|
43
|
+
*/
|
|
44
|
+
export declare function loadReportAttachment(path: string): ReportAttachment;
|
|
45
|
+
/** Build attachment from an in-memory string (MCP / piped text). */
|
|
46
|
+
export declare function attachmentFromText(body: string, filename?: string, contentType?: string): ReportAttachment;
|
|
47
|
+
export declare function buildReportPayload(message: string, opts?: {
|
|
48
|
+
type?: ReportType;
|
|
49
|
+
contact?: string;
|
|
50
|
+
surface?: "cli" | "mcp";
|
|
51
|
+
command?: string;
|
|
52
|
+
errorCode?: string;
|
|
53
|
+
attachment?: ReportAttachment;
|
|
54
|
+
}, deps?: {
|
|
55
|
+
version?: string;
|
|
56
|
+
dataDir?: string;
|
|
57
|
+
includeAnonId?: boolean;
|
|
58
|
+
}): ReportPayload;
|
|
59
|
+
export type SubmitReportResult = {
|
|
60
|
+
ok: true;
|
|
61
|
+
id: string;
|
|
62
|
+
hasAttachment: boolean;
|
|
63
|
+
} | {
|
|
64
|
+
ok: false;
|
|
65
|
+
error: string;
|
|
66
|
+
};
|
|
67
|
+
export declare function submitReport(payload: ReportPayload, opts?: SubmitReportOptions): Promise<SubmitReportResult>;
|
|
68
|
+
export declare function reportFallbackHint(): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit, permissioned diagnostic report submission.
|
|
3
|
+
*
|
|
4
|
+
* Unlike automatic telemetry, nothing is sent unless the user (or an agent
|
|
5
|
+
* they instructed) runs `uploads report` / the MCP `report` tool.
|
|
6
|
+
*
|
|
7
|
+
* Optional log/trace attachments are text-only, capped, and stored server-side
|
|
8
|
+
* in R2 under an unguessable key. Never auto-attaches files from disk.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { DEFAULT_API_URL } from "./config.js";
|
|
13
|
+
import { packageVersion } from "./package-version.js";
|
|
14
|
+
import { detectClientKind, detectRuntime, getOrCreateAnonId, isTelemetryEnabled, } from "./telemetry.js";
|
|
15
|
+
export const REPORT_TYPES = ["bug", "error", "idea", "other"];
|
|
16
|
+
export const MIN_REPORT_MESSAGE = 5;
|
|
17
|
+
export const MAX_REPORT_MESSAGE = 4000;
|
|
18
|
+
export const MAX_REPORT_ATTACHMENT_BYTES = 256 * 1024;
|
|
19
|
+
const POST_TIMEOUT_MS = 15_000;
|
|
20
|
+
const ISSUES_URL = "https://github.com/buildinternet/uploads/issues";
|
|
21
|
+
export function validateReportMessage(raw) {
|
|
22
|
+
const message = raw.trim();
|
|
23
|
+
if (message.length < MIN_REPORT_MESSAGE) {
|
|
24
|
+
return { ok: false, error: "message is too short — add a sentence or two" };
|
|
25
|
+
}
|
|
26
|
+
if (message.length > MAX_REPORT_MESSAGE) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `message is too long (max ${MAX_REPORT_MESSAGE} chars)`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, message };
|
|
33
|
+
}
|
|
34
|
+
export function parseReportType(raw) {
|
|
35
|
+
if (!raw)
|
|
36
|
+
return undefined;
|
|
37
|
+
return REPORT_TYPES.includes(raw) ? raw : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load a local text file as an attachment. Rejects oversized / missing files.
|
|
41
|
+
* Callers must have obtained the path from explicit user input (`--file`).
|
|
42
|
+
*/
|
|
43
|
+
export function loadReportAttachment(path) {
|
|
44
|
+
let size;
|
|
45
|
+
try {
|
|
46
|
+
size = statSync(path).size;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err?.code === "ENOENT") {
|
|
50
|
+
throw new Error(`file not found: ${path}`, { cause: err });
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
if (size > MAX_REPORT_ATTACHMENT_BYTES) {
|
|
55
|
+
throw new Error(`attachment exceeds ${MAX_REPORT_ATTACHMENT_BYTES} bytes (got ${size})`);
|
|
56
|
+
}
|
|
57
|
+
const body = readFileSync(path, "utf8");
|
|
58
|
+
// Re-check UTF-8 byte length (stat is filesystem size).
|
|
59
|
+
const bytes = new TextEncoder().encode(body).byteLength;
|
|
60
|
+
if (bytes > MAX_REPORT_ATTACHMENT_BYTES) {
|
|
61
|
+
throw new Error(`attachment exceeds ${MAX_REPORT_ATTACHMENT_BYTES} bytes after read`);
|
|
62
|
+
}
|
|
63
|
+
const filename = basename(path) || "attachment.txt";
|
|
64
|
+
const lower = filename.toLowerCase();
|
|
65
|
+
const contentType = lower.endsWith(".json") || lower.endsWith(".jsonl") || lower.endsWith(".ndjson")
|
|
66
|
+
? "application/json"
|
|
67
|
+
: "text/plain; charset=utf-8";
|
|
68
|
+
return { filename, contentType, body };
|
|
69
|
+
}
|
|
70
|
+
/** Build attachment from an in-memory string (MCP / piped text). */
|
|
71
|
+
export function attachmentFromText(body, filename = "trace.txt", contentType = "text/plain; charset=utf-8") {
|
|
72
|
+
const bytes = new TextEncoder().encode(body).byteLength;
|
|
73
|
+
if (bytes > MAX_REPORT_ATTACHMENT_BYTES) {
|
|
74
|
+
throw new Error(`attachment exceeds ${MAX_REPORT_ATTACHMENT_BYTES} bytes`);
|
|
75
|
+
}
|
|
76
|
+
if (!body.trim())
|
|
77
|
+
throw new Error("attachment is empty");
|
|
78
|
+
return {
|
|
79
|
+
filename: basename(filename) || "trace.txt",
|
|
80
|
+
contentType,
|
|
81
|
+
body,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function buildReportPayload(message, opts = {}, deps = {}) {
|
|
85
|
+
const ctx = detectClientKind();
|
|
86
|
+
const includeAnon = deps.includeAnonId ?? isTelemetryEnabled(deps.dataDir);
|
|
87
|
+
return {
|
|
88
|
+
message,
|
|
89
|
+
type: opts.type ?? "other",
|
|
90
|
+
contact: opts.contact?.trim() || undefined,
|
|
91
|
+
surface: opts.surface ?? "cli",
|
|
92
|
+
cliVersion: deps.version ?? packageVersion(),
|
|
93
|
+
clientKind: ctx.kind,
|
|
94
|
+
agentName: ctx.agentName,
|
|
95
|
+
anonId: includeAnon ? getOrCreateAnonId(deps.dataDir) : undefined,
|
|
96
|
+
os: process.platform,
|
|
97
|
+
arch: process.arch,
|
|
98
|
+
runtime: detectRuntime(),
|
|
99
|
+
command: opts.command?.trim().slice(0, 120) || undefined,
|
|
100
|
+
errorCode: opts.errorCode?.trim().slice(0, 64) || undefined,
|
|
101
|
+
attachment: opts.attachment,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function submitReport(payload, opts = {}) {
|
|
105
|
+
const base = (opts.apiUrl ?? process.env.UPLOADS_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const t = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
|
|
108
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetchImpl(`${base}/v1/reports`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
"user-agent": `uploads-cli/${payload.cliVersion}`,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify(payload),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
let detail = `server returned ${res.status}`;
|
|
121
|
+
try {
|
|
122
|
+
const errBody = (await res.json());
|
|
123
|
+
if (errBody?.error?.message)
|
|
124
|
+
detail = errBody.error.message;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, error: detail };
|
|
130
|
+
}
|
|
131
|
+
const json = (await res.json());
|
|
132
|
+
if (!json.ok || !json.id)
|
|
133
|
+
return { ok: false, error: "unexpected response" };
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
id: json.id,
|
|
137
|
+
hasAttachment: Boolean(json.hasAttachment),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
142
|
+
if (msg.includes("abort"))
|
|
143
|
+
return { ok: false, error: "request timed out" };
|
|
144
|
+
return { ok: false, error: msg };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
clearTimeout(t);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function reportFallbackHint() {
|
|
151
|
+
return `You can open an issue instead: ${ISSUES_URL}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type TelemetrySurface = "cli" | "mcp";
|
|
2
|
+
export type TelemetryClientKind = "external" | "ci" | "agent";
|
|
3
|
+
export interface TelemetryEventInput {
|
|
4
|
+
surface: TelemetrySurface;
|
|
5
|
+
command: string;
|
|
6
|
+
exitCode?: number;
|
|
7
|
+
durationMs?: number;
|
|
8
|
+
/** UploadsError code or USAGE — never free-form messages. */
|
|
9
|
+
errorCode?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RecordEventOptions {
|
|
12
|
+
/** Override data directory (tests). */
|
|
13
|
+
dataDir?: string;
|
|
14
|
+
/** Override API base URL (tests / --api-url). */
|
|
15
|
+
apiUrl?: string;
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
now?: number;
|
|
18
|
+
version?: string;
|
|
19
|
+
}
|
|
20
|
+
/** XDG data dir for long-lived telemetry state (anon id, disable marker). */
|
|
21
|
+
export declare function defaultTelemetryDataDir(): string;
|
|
22
|
+
export declare function getOrCreateAnonId(dataDir?: string): string;
|
|
23
|
+
export declare function isTelemetryEnabled(dataDir?: string): boolean;
|
|
24
|
+
export declare function setTelemetryEnabled(enabled: boolean, dataDir?: string): void;
|
|
25
|
+
export declare function detectClientKind(): {
|
|
26
|
+
kind: TelemetryClientKind;
|
|
27
|
+
agentName?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function detectRuntime(): string;
|
|
30
|
+
/**
|
|
31
|
+
* Build a safe command label from argv. Only the root command (+ subcommand
|
|
32
|
+
* for known nested commands). Skips global flag/value pairs so
|
|
33
|
+
* `--token up_… put` never records the token as the command.
|
|
34
|
+
*/
|
|
35
|
+
export declare function telemetryCommandName(argv: string[]): string;
|
|
36
|
+
/** One-time stderr notice for interactive external users. */
|
|
37
|
+
export declare function maybeShowFirstRunNotice(opts?: {
|
|
38
|
+
dataDir?: string;
|
|
39
|
+
write?: (text: string) => void;
|
|
40
|
+
/** When false, skip (MCP stdio, --json, non-TTY). */
|
|
41
|
+
interactive?: boolean;
|
|
42
|
+
}): void;
|
|
43
|
+
/**
|
|
44
|
+
* Fire-and-forget usage ping. Never throws; delivery runs in the background
|
|
45
|
+
* so CLI exit is not delayed by the network.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recordEvent(input: TelemetryEventInput, opts?: RecordEventOptions): void;
|
|
48
|
+
export declare function telemetryStatus(opts?: {
|
|
49
|
+
dataDir?: string;
|
|
50
|
+
apiUrl?: string;
|
|
51
|
+
}): {
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
anonId: string;
|
|
54
|
+
clientKind: TelemetryClientKind;
|
|
55
|
+
agentName?: string;
|
|
56
|
+
endpoint: string;
|
|
57
|
+
reason?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Map thrown CLI errors to a short, allowlisted code for telemetry. */
|
|
60
|
+
export declare function errorCodeFromUnknown(err: unknown): string | undefined;
|