@buildinternet/uploads 0.10.1 → 0.11.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.
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tool handler failure that still carries structuredContent (e.g. multi-file
3
+ * total failure with a `failures` array). The MCP server maps this to
4
+ * isError: true while preserving structuredContent for agents.
5
+ */
6
+ export declare class ToolBatchError extends Error {
7
+ readonly structuredContent: unknown;
8
+ constructor(message: string, structuredContent: unknown);
9
+ }
10
+ /** One-line summary of a multi-file failure list. */
11
+ export declare function batchFailureMessage(failures: readonly {
12
+ file: string;
13
+ error: {
14
+ message: string;
15
+ };
16
+ }[]): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tool handler failure that still carries structuredContent (e.g. multi-file
3
+ * total failure with a `failures` array). The MCP server maps this to
4
+ * isError: true while preserving structuredContent for agents.
5
+ */
6
+ export class ToolBatchError extends Error {
7
+ structuredContent;
8
+ constructor(message, structuredContent) {
9
+ super(message);
10
+ this.name = "ToolBatchError";
11
+ this.structuredContent = structuredContent;
12
+ }
13
+ }
14
+ /** One-line summary of a multi-file failure list. */
15
+ export function batchFailureMessage(failures) {
16
+ if (failures.length === 0)
17
+ return "upload failed";
18
+ if (failures.length === 1) {
19
+ const f = failures[0];
20
+ return `${f.file}: ${f.error.message}`;
21
+ }
22
+ const lines = failures.map((f) => ` ${f.file}: ${f.error.message}`);
23
+ return `${failures.length} uploads failed:\n${lines.join("\n")}`;
24
+ }
@@ -1,4 +1,5 @@
1
1
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
2
+ export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
2
3
  export interface McpTool {
3
4
  name: string;
4
5
  description: string;
@@ -16,4 +17,6 @@ export declare function createMcpServer(opts: {
16
17
  version: string;
17
18
  };
18
19
  tools: McpTool[];
20
+ /** API base for telemetry (honors uploads --api-url). */
21
+ apiUrl?: string;
19
22
  }): McpServer;
@@ -8,7 +8,10 @@
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";
12
+ import { ToolBatchError } from "./batch-error.js";
11
13
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
14
+ export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
12
15
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
13
16
  const LATEST_PROTOCOL_VERSION = "2025-06-18";
14
17
  function response(id, result) {
@@ -24,7 +27,7 @@ function toolErrorText(err) {
24
27
  return err instanceof Error ? err.message : String(err);
25
28
  }
26
29
  export function createMcpServer(opts) {
27
- const { serverInfo, tools } = opts;
30
+ const { serverInfo, tools, apiUrl } = opts;
28
31
  async function callTool(id, params) {
29
32
  const name = params.name;
30
33
  const tool = typeof name === "string" ? tools.find((t) => t.name === name) : undefined;
@@ -34,8 +37,16 @@ export function createMcpServer(opts) {
34
37
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
35
38
  return errorResponse(id, -32602, "tool arguments must be an object");
36
39
  }
40
+ const start = Date.now();
41
+ const command = `tool ${tool.name}`.slice(0, 120);
37
42
  try {
38
43
  const result = await tool.handler(args);
44
+ recordEvent({
45
+ surface: "mcp",
46
+ command,
47
+ exitCode: 0,
48
+ durationMs: Date.now() - start,
49
+ }, { apiUrl });
39
50
  return response(id, {
40
51
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
41
52
  structuredContent: result,
@@ -43,6 +54,27 @@ export function createMcpServer(opts) {
43
54
  });
44
55
  }
45
56
  catch (err) {
57
+ recordEvent({
58
+ surface: "mcp",
59
+ command,
60
+ exitCode: 1,
61
+ durationMs: Date.now() - start,
62
+ errorCode: errorCodeFromUnknown(err),
63
+ }, { apiUrl });
64
+ // Multi-file total failure: keep structuredContent so agents see every
65
+ // per-file error, not only the first message string.
66
+ if (err instanceof ToolBatchError) {
67
+ return response(id, {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: JSON.stringify(err.structuredContent, null, 2),
72
+ },
73
+ ],
74
+ structuredContent: err.structuredContent,
75
+ isError: true,
76
+ });
77
+ }
46
78
  return response(id, {
47
79
  content: [{ type: "text", text: toolErrorText(err) }],
48
80
  isError: true,
@@ -2,7 +2,7 @@ import type { GlobalFlags } from "../cli-args.js";
2
2
  import { type UploadsClient } from "../client.js";
3
3
  import { type UploadsClientConfig } from "../config.js";
4
4
  import { type CommandRunner } from "../github-gh.js";
5
- import type { McpTool } from "./server.js";
5
+ import { type McpTool } from "./server.js";
6
6
  export declare function createUploadsMcpTools(opts: {
7
7
  globals: GlobalFlags;
8
8
  runner?: CommandRunner;