@buildinternet/uploads 0.10.0 → 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.
@@ -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.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,