@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.
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;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Anonymous, opt-out usage telemetry for the CLI and MCP server.
3
+ *
4
+ * Sends command names, timing, exit codes, and optional error codes only —
5
+ * never arguments, paths, tokens, workspace names, or content.
6
+ *
7
+ * Opt out:
8
+ * UPLOADS_TELEMETRY_DISABLED=1
9
+ * DO_NOT_TRACK=1
10
+ * uploads telemetry disable
11
+ */
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { randomUUID } from "node:crypto";
16
+ import { DEFAULT_API_URL } from "./config.js";
17
+ import { packageVersion } from "./package-version.js";
18
+ const ANON_ID_FILE = "telemetry-id";
19
+ const DISABLE_FILE = "telemetry-disabled";
20
+ const NOTICE_FILE = "telemetry-notice-shown";
21
+ const POST_TIMEOUT_MS = 1500;
22
+ const MAX_COMMAND = 120;
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
24
+ /** Known root commands that take a subcommand as the second positional. */
25
+ const NESTED_COMMANDS = new Set([
26
+ "admin",
27
+ "completion",
28
+ "completions",
29
+ "config",
30
+ "gallery",
31
+ "install",
32
+ "meta",
33
+ "telemetry",
34
+ ]);
35
+ /** Global flags that consume a following value (must not become command names). */
36
+ const VALUE_GLOBALS = new Set(["--api-url", "--workspace", "-w", "--token", "--env-file"]);
37
+ /** Allowlisted error codes sent to telemetry (mirror server ERROR_CODES). */
38
+ const TELEMETRY_ERROR_CODES = new Set([
39
+ "MISSING_TOKEN",
40
+ "NO_PUBLIC_URL",
41
+ "FILE_NOT_FOUND",
42
+ "NOT_FOUND",
43
+ "UNAUTHORIZED",
44
+ "INVALID_KEY",
45
+ "KEY_POLICY",
46
+ "STORAGE_QUOTA",
47
+ "UPLOAD_BUDGET",
48
+ "GITHUB_REQUIRED",
49
+ "API_ERROR",
50
+ "NETWORK",
51
+ "USAGE",
52
+ ]);
53
+ function truthyEnv(name) {
54
+ const v = process.env[name];
55
+ if (!v)
56
+ return false;
57
+ const lower = v.toLowerCase();
58
+ return lower !== "0" && lower !== "false" && lower !== "no";
59
+ }
60
+ /** XDG data dir for long-lived telemetry state (anon id, disable marker). */
61
+ export function defaultTelemetryDataDir() {
62
+ const base = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
63
+ return join(base, "uploads");
64
+ }
65
+ function filePath(name, dataDir) {
66
+ return join(dataDir ?? defaultTelemetryDataDir(), name);
67
+ }
68
+ function safeRead(path) {
69
+ try {
70
+ return readFileSync(path, "utf8").trim();
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function safeWrite(path, content, mode) {
77
+ try {
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ writeFileSync(path, content, "utf8");
80
+ if (mode !== undefined)
81
+ chmodSync(path, mode);
82
+ }
83
+ catch {
84
+ // Telemetry must never throw or break the CLI.
85
+ }
86
+ }
87
+ export function getOrCreateAnonId(dataDir) {
88
+ const path = filePath(ANON_ID_FILE, dataDir);
89
+ const existing = safeRead(path);
90
+ if (existing && UUID_RE.test(existing))
91
+ return existing;
92
+ const id = randomUUID();
93
+ safeWrite(path, id, 0o600);
94
+ return id;
95
+ }
96
+ export function isTelemetryEnabled(dataDir) {
97
+ if (truthyEnv("UPLOADS_TELEMETRY_DISABLED"))
98
+ return false;
99
+ if (process.env.DO_NOT_TRACK === "1")
100
+ return false;
101
+ // Vitest sets VITEST=true — never open a network connection from other suites.
102
+ // Opt in for this package's own telemetry tests with UPLOADS_TELEMETRY_TEST=1.
103
+ if (process.env.VITEST && process.env.UPLOADS_TELEMETRY_TEST !== "1")
104
+ return false;
105
+ if (existsSync(filePath(DISABLE_FILE, dataDir)))
106
+ return false;
107
+ return true;
108
+ }
109
+ export function setTelemetryEnabled(enabled, dataDir) {
110
+ const path = filePath(DISABLE_FILE, dataDir);
111
+ if (enabled) {
112
+ try {
113
+ if (existsSync(path))
114
+ unlinkSync(path);
115
+ }
116
+ catch {
117
+ // ignore
118
+ }
119
+ }
120
+ else {
121
+ safeWrite(path, "disabled\n");
122
+ }
123
+ }
124
+ export function detectClientKind() {
125
+ const envKind = process.env.UPLOADS_CLIENT_KIND;
126
+ if (envKind === "external" || envKind === "ci" || envKind === "agent") {
127
+ return { kind: envKind, agentName: process.env.UPLOADS_CLIENT_AGENT };
128
+ }
129
+ if (process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true") {
130
+ return { kind: "ci" };
131
+ }
132
+ // Coarse agent-host markers only (no PII).
133
+ if (process.env.CLAUDECODE || process.env.CLAUDE_CODE) {
134
+ return { kind: "agent", agentName: process.env.UPLOADS_CLIENT_AGENT ?? "claude" };
135
+ }
136
+ if (process.env.CURSOR_AGENT || process.env.CURSOR_TRACE_ID) {
137
+ return { kind: "agent", agentName: process.env.UPLOADS_CLIENT_AGENT ?? "cursor" };
138
+ }
139
+ return { kind: "external" };
140
+ }
141
+ export function detectRuntime() {
142
+ const bun = globalThis.Bun;
143
+ if (bun?.version)
144
+ return `bun-${bun.version}`;
145
+ if (typeof process !== "undefined" && process.versions?.node) {
146
+ return `node-${process.versions.node}`;
147
+ }
148
+ return "unknown";
149
+ }
150
+ function endpoint(apiUrl) {
151
+ const base = (apiUrl ?? process.env.UPLOADS_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
152
+ return base;
153
+ }
154
+ /**
155
+ * Build a safe command label from argv. Only the root command (+ subcommand
156
+ * for known nested commands). Skips global flag/value pairs so
157
+ * `--token up_… put` never records the token as the command.
158
+ */
159
+ export function telemetryCommandName(argv) {
160
+ const args = argv.slice(2);
161
+ const positional = [];
162
+ for (let i = 0; i < args.length; i++) {
163
+ const arg = args[i];
164
+ if (arg === "--") {
165
+ positional.push(...args.slice(i + 1).filter((a) => !a.startsWith("-")));
166
+ break;
167
+ }
168
+ if (VALUE_GLOBALS.has(arg)) {
169
+ i += 1; // skip value
170
+ continue;
171
+ }
172
+ if (arg.startsWith("--") && arg.includes("="))
173
+ continue;
174
+ if (arg.startsWith("-"))
175
+ continue;
176
+ positional.push(arg);
177
+ }
178
+ const root = positional[0];
179
+ if (!root)
180
+ return "(root)";
181
+ if (!NESTED_COMMANDS.has(root))
182
+ return root.slice(0, MAX_COMMAND);
183
+ const sub = positional[1];
184
+ if (!sub)
185
+ return root.slice(0, MAX_COMMAND);
186
+ return `${root} ${sub}`.slice(0, MAX_COMMAND);
187
+ }
188
+ /** One-time stderr notice for interactive external users. */
189
+ export function maybeShowFirstRunNotice(opts = {}) {
190
+ if (!isTelemetryEnabled(opts.dataDir))
191
+ return;
192
+ if (opts.interactive === false)
193
+ return;
194
+ if (detectClientKind().kind !== "external")
195
+ return;
196
+ // Default: only when stderr looks interactive.
197
+ if (opts.interactive === undefined && !process.stderr.isTTY)
198
+ return;
199
+ const marker = filePath(NOTICE_FILE, opts.dataDir);
200
+ if (existsSync(marker))
201
+ return;
202
+ const write = opts.write ?? ((t) => process.stderr.write(t));
203
+ write("\n" +
204
+ "Note: uploads collects anonymous non-PII usage data. " +
205
+ "Opt out with `uploads telemetry disable` or UPLOADS_TELEMETRY_DISABLED=1.\n\n");
206
+ safeWrite(marker, new Date().toISOString());
207
+ }
208
+ /**
209
+ * Fire-and-forget usage ping. Never throws; delivery runs in the background
210
+ * so CLI exit is not delayed by the network.
211
+ */
212
+ export function recordEvent(input, opts = {}) {
213
+ if (!isTelemetryEnabled(opts.dataDir))
214
+ return;
215
+ const command = input.command.trim().slice(0, MAX_COMMAND);
216
+ if (!command)
217
+ return;
218
+ const ctx = detectClientKind();
219
+ const body = {
220
+ anonId: getOrCreateAnonId(opts.dataDir),
221
+ timestamp: opts.now ?? Date.now(),
222
+ surface: input.surface,
223
+ clientKind: ctx.kind,
224
+ agentName: ctx.agentName ?? null,
225
+ command,
226
+ exitCode: input.exitCode ?? null,
227
+ durationMs: input.durationMs ?? null,
228
+ errorCode: input.errorCode ?? null,
229
+ cliVersion: opts.version ?? packageVersion(),
230
+ os: process.platform,
231
+ arch: process.arch,
232
+ runtime: detectRuntime(),
233
+ };
234
+ const controller = new AbortController();
235
+ const t = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
236
+ const fetchImpl = opts.fetchImpl ?? fetch;
237
+ void fetchImpl(`${endpoint(opts.apiUrl)}/v1/telemetry`, {
238
+ method: "POST",
239
+ headers: {
240
+ "content-type": "application/json",
241
+ "user-agent": `uploads-cli/${body.cliVersion}`,
242
+ },
243
+ body: JSON.stringify(body),
244
+ signal: controller.signal,
245
+ })
246
+ .catch(() => {
247
+ // fire-and-forget
248
+ })
249
+ .finally(() => {
250
+ clearTimeout(t);
251
+ });
252
+ }
253
+ export function telemetryStatus(opts = {}) {
254
+ const dataDir = opts.dataDir;
255
+ const enabled = isTelemetryEnabled(dataDir);
256
+ let reason;
257
+ if (truthyEnv("UPLOADS_TELEMETRY_DISABLED"))
258
+ reason = "UPLOADS_TELEMETRY_DISABLED=1";
259
+ else if (process.env.DO_NOT_TRACK === "1")
260
+ reason = "DO_NOT_TRACK=1";
261
+ else if (existsSync(filePath(DISABLE_FILE, dataDir))) {
262
+ reason = `${filePath(DISABLE_FILE, dataDir)} present`;
263
+ }
264
+ const ctx = detectClientKind();
265
+ return {
266
+ enabled,
267
+ anonId: getOrCreateAnonId(dataDir),
268
+ clientKind: ctx.kind,
269
+ agentName: ctx.agentName,
270
+ endpoint: `${endpoint(opts.apiUrl)}/v1/telemetry`,
271
+ reason,
272
+ };
273
+ }
274
+ /** Map thrown CLI errors to a short, allowlisted code for telemetry. */
275
+ export function errorCodeFromUnknown(err) {
276
+ if (err &&
277
+ typeof err === "object" &&
278
+ "name" in err &&
279
+ err.name === "UsageError") {
280
+ return "USAGE";
281
+ }
282
+ if (err &&
283
+ typeof err === "object" &&
284
+ "code" in err &&
285
+ typeof err.code === "string") {
286
+ const code = err.code.slice(0, 64);
287
+ return TELEMETRY_ERROR_CODES.has(code) ? code : undefined;
288
+ }
289
+ return undefined;
290
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,