@buildinternet/uploads 0.3.0 → 0.5.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 CHANGED
@@ -1,14 +1,14 @@
1
1
  # @buildinternet/uploads
2
2
 
3
- CLI and client for **uploads.sh** — upload files, get public URLs, and produce GitHub-ready markdown. Successor to the R2 scripts in `buildinternet-skills/github-screenshots`.
3
+ CLI and client for **uploads.sh** — upload files, get public URLs, and produce GitHub-ready markdown.
4
4
 
5
5
  ## CLI
6
6
 
7
- Binary: **`uploads`**. Install globally (or use a pinned `npx` one-shot):
7
+ Binary: **`uploads`**. Install globally (or use an `npx` one-shot):
8
8
 
9
9
  ```bash
10
10
  npm install --global @buildinternet/uploads
11
- npx @buildinternet/uploads@0.1.0 --help
11
+ npx @buildinternet/uploads --help
12
12
  ```
13
13
 
14
14
  ```bash
package/dist/agent.js CHANGED
@@ -15,7 +15,7 @@ export function createUploadsWorkerFileTools(files, opts = {}) {
15
15
  },
16
16
  overrides: {
17
17
  uploadFile: {
18
- description: "Upload a file for public hosting (e.g. GitHub embeds). Prefer keys under screenshots/.",
18
+ description: "Upload a non-sensitive file for public hosting (e.g. GitHub embeds). GitHub repository visibility does not restrict access; prefer keys under screenshots/.",
19
19
  },
20
20
  ...overrides,
21
21
  },
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ Config (first match wins, per key):
19
19
  environment UPLOADS_API_URL, UPLOADS_TOKEN, UPLOADS_WORKSPACE
20
20
  --env-file <path>
21
21
  $BUILDINTERNET_CONFIG
22
- ~/.config/buildinternet/config (shared with github-screenshots)
22
+ ~/.config/buildinternet/config
23
23
 
24
24
  Workspace (within config layers):
25
25
  --workspace, -w override — global (before command) or per-command (after)
@@ -45,7 +45,7 @@ Commands:
45
45
  setup Inspect/configure advanced CLI settings
46
46
  install Install the agent skill + register the remote MCP server
47
47
  login Exchange an enrollment code and configure credentials
48
- admin Admin enrollment management
48
+ admin Admin invitation management
49
49
  config Show path, init, or set shared config
50
50
  doctor Health + auth + workspace checks
51
51
  health API liveness (no auth)
package/dist/client.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
+ /** Allowlisted object provenance (maps to X-Uploads-Meta-* on put). */
3
+ export type ProvenanceInput = {
4
+ client?: string;
5
+ "client-version"?: string;
6
+ "source-name"?: string;
7
+ optimized?: "0" | "1";
8
+ frame?: string;
9
+ "keep-exif"?: "0" | "1";
10
+ };
2
11
  export interface PutOptions {
3
12
  key?: string;
4
13
  contentType?: string;
@@ -6,6 +15,8 @@ export interface PutOptions {
6
15
  repo?: string;
7
16
  ref?: string;
8
17
  deriveRepoFromGit?: boolean;
18
+ /** Stored as R2 custom metadata; echoed on put/head. */
19
+ provenance?: ProvenanceInput;
9
20
  }
10
21
  export interface ListOptions {
11
22
  prefix?: string;
@@ -18,6 +29,7 @@ export interface PutResult {
18
29
  url: string;
19
30
  size: number;
20
31
  contentType: string;
32
+ metadata?: Record<string, string>;
21
33
  }
22
34
  export interface ListItem {
23
35
  key: string;
@@ -35,6 +47,7 @@ export interface HeadResult {
35
47
  size: number;
36
48
  contentType: string;
37
49
  uploaded?: string;
50
+ metadata?: Record<string, string>;
38
51
  }
39
52
  export interface DeleteResult {
40
53
  key: string;
@@ -88,14 +101,17 @@ export interface EnrollmentExchangeResult {
88
101
  expiresAt?: string;
89
102
  }
90
103
  export interface EnrollmentCreateResult {
104
+ pageId: string;
91
105
  code: string;
92
106
  expiresAt: string;
93
107
  tokenExpiresAt: string;
108
+ emailed?: boolean;
94
109
  }
95
110
  export declare function exchangeEnrollment(apiUrl: string, code: string): Promise<EnrollmentExchangeResult>;
96
111
  export declare function createEnrollment(apiUrl: string, adminToken: string, input: {
97
112
  workspace?: string;
98
113
  label?: string;
114
+ email?: string;
99
115
  enrollmentSeconds?: number;
100
116
  tokenExpiresInSeconds?: number;
101
117
  scopes?: Array<"files:read" | "files:write" | "files:delete">;
package/dist/client.js CHANGED
@@ -38,19 +38,19 @@ function usageBase(config) {
38
38
  }
39
39
  function mapApiError(status, error, code) {
40
40
  const normalized = error.toLowerCase();
41
- if (status === 401 || normalized === "unauthorized") {
41
+ if (status === 401 || code === "unauthorized" || normalized === "unauthorized") {
42
42
  return new UploadsError(error, "UNAUTHORIZED", status);
43
43
  }
44
- if (status === 404 || normalized === "not found") {
44
+ if (status === 404 || code === "not_found" || normalized === "not found") {
45
45
  return new UploadsError(error, "NOT_FOUND", status);
46
46
  }
47
- if (status === 400 && normalized === "invalid key") {
47
+ if (code === "invalid_key" || (status === 400 && normalized === "invalid key")) {
48
48
  return new UploadsError(error, "INVALID_KEY", status);
49
49
  }
50
50
  if (code === "key_prefix_not_allowed" || code === "key_too_deep") {
51
51
  return new UploadsError(error, "KEY_POLICY", status);
52
52
  }
53
- // Prefer stable body.code — bare 429 is also used for write rate limits.
53
+ // Prefer stable body code — bare 429 is also used for write rate limits.
54
54
  if (status === 507 || code === "storage_quota_exceeded") {
55
55
  return new UploadsError(error, "STORAGE_QUOTA", status);
56
56
  }
@@ -59,15 +59,34 @@ function mapApiError(status, error, code) {
59
59
  }
60
60
  return new UploadsError(error, "API_ERROR", status);
61
61
  }
62
+ /**
63
+ * Parse API error bodies. Prefers the nested envelope
64
+ * `{ error: { code, type, message, details? } }`; still accepts the legacy
65
+ * flat `{ error: string, code?: string }` shape.
66
+ */
67
+ function extractErrorFields(body) {
68
+ if (typeof body === "object" && body && "error" in body) {
69
+ const err = body.error;
70
+ if (typeof err === "object" && err && "message" in err) {
71
+ const nested = err;
72
+ return {
73
+ message: typeof nested.message === "string" ? nested.message : "request failed",
74
+ code: typeof nested.code === "string" ? nested.code : undefined,
75
+ };
76
+ }
77
+ if (typeof err === "string") {
78
+ const code = "code" in body && typeof body.code === "string"
79
+ ? body.code
80
+ : undefined;
81
+ return { message: err, code };
82
+ }
83
+ }
84
+ return { message: "request failed" };
85
+ }
62
86
  async function parseErrorResponse(res) {
63
87
  const body = await res.json().catch(() => ({}));
64
- const message = typeof body === "object" && body && "error" in body && typeof body.error === "string"
65
- ? body.error
66
- : res.statusText || "request failed";
67
- const code = typeof body === "object" && body && "code" in body && typeof body.code === "string"
68
- ? body.code
69
- : undefined;
70
- return mapApiError(res.status, message, code);
88
+ const { message, code } = extractErrorFields(body);
89
+ return mapApiError(res.status, message || res.statusText || "request failed", code);
71
90
  }
72
91
  export function createUploadsClient(config) {
73
92
  async function request(method, path, opts) {
@@ -117,9 +136,16 @@ export function createUploadsClient(config) {
117
136
  deriveRepoFromGit: opts.deriveRepoFromGit,
118
137
  }));
119
138
  const contentType = opts.contentType ?? inferContentType(opts.filename);
139
+ const headers = { "Content-Type": contentType };
140
+ if (opts.provenance) {
141
+ for (const [k, v] of Object.entries(opts.provenance)) {
142
+ if (v !== undefined && v !== "")
143
+ headers[`X-Uploads-Meta-${k}`] = v;
144
+ }
145
+ }
120
146
  const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
121
147
  body,
122
- headers: { "Content-Type": contentType },
148
+ headers,
123
149
  });
124
150
  if (result.url == null) {
125
151
  throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
@@ -1,4 +1,6 @@
1
1
  type FileScope = "files:read" | "files:write" | "files:delete";
2
+ export declare function invitePageUrl(apiUrl: string, pageId: string, webUrl?: string): string;
3
+ export declare function inviteMagicLink(pageUrl: string, code: string): string;
2
4
  export declare function parseScopes(raw: string | undefined): FileScope[] | undefined;
3
5
  export declare function runAdmin(args: string[], opts: {
4
6
  json?: boolean;
@@ -1,8 +1,12 @@
1
1
  import { createEnrollment } from "../client.js";
2
- import { flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
3
- const HELP = `uploads admin enrollment create [options]
2
+ import { flagBool, flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
3
+ const HELP = `uploads admin invite create [options]
4
4
 
5
- Admin-only: create a short-lived, one-time enrollment code.
5
+ Admin-only: create a short-lived invitation for an existing workspace.
6
+ Prints one magic link whose URL fragment carries the single-use code — treat the
7
+ link like a password. Pass --separate-code for the legacy two-channel output (a
8
+ non-secret page URL plus a code you share separately). The legacy
9
+ "admin enrollment create" spelling is accepted.
6
10
 
7
11
  Options:
8
12
  --admin-token <token> Or ADMIN_TOKEN (UPLOADS_ADMIN_TOKEN is a legacy alias)
@@ -11,9 +15,34 @@ Options:
11
15
  --expires-in <seconds> Default: server policy
12
16
  --token-expires-in <seconds> Upload token lifetime (default: server policy)
13
17
  --scopes <list> Comma-separated files:read,files:write,files:delete
18
+ --email <address> Email the invite link to this recipient (from invites@uploads.sh)
19
+ --separate-code Two-channel output: non-secret page URL + separate code
14
20
  --api-url <url> Default: https://api.uploads.sh
21
+ --web-url <url> Invite-page origin (defaults from --api-url)
15
22
  `;
16
23
  const FILE_SCOPES = new Set(["files:read", "files:write", "files:delete"]);
24
+ export function invitePageUrl(apiUrl, pageId, webUrl) {
25
+ let url;
26
+ try {
27
+ url = new URL(webUrl ?? apiUrl);
28
+ }
29
+ catch {
30
+ throw new UsageError("invalid invite web URL");
31
+ }
32
+ if (!webUrl && url.hostname.startsWith("api."))
33
+ url.hostname = url.hostname.slice(4);
34
+ url.pathname = "/invite";
35
+ url.search = "";
36
+ url.hash = "";
37
+ url.searchParams.set("id", pageId);
38
+ return url.toString();
39
+ }
40
+ // Compose the self-contained magic link. The one-time code rides in the URL
41
+ // fragment (#code=…), which browsers never send to the server, so opening the
42
+ // page neither leaks nor consumes it — only the CLI's exchange call redeems it.
43
+ export function inviteMagicLink(pageUrl, code) {
44
+ return `${pageUrl}#code=${encodeURIComponent(code)}`;
45
+ }
17
46
  export function parseScopes(raw) {
18
47
  if (raw === undefined)
19
48
  return undefined;
@@ -34,26 +63,44 @@ export async function runAdmin(args, opts, help = false) {
34
63
  process.stderr.write(HELP);
35
64
  return 0;
36
65
  }
37
- if (parsed.positionals[0] !== "enrollment" || parsed.positionals[1] !== "create")
38
- throw new UsageError("expected: uploads admin enrollment create");
66
+ if (!["invite", "enrollment"].includes(parsed.positionals[0] ?? "") ||
67
+ parsed.positionals[1] !== "create")
68
+ throw new UsageError("expected: uploads admin invite create");
39
69
  const adminToken = flagString(parsed.flags, "--admin-token") ??
40
70
  process.env.ADMIN_TOKEN ??
41
71
  process.env.UPLOADS_ADMIN_TOKEN;
42
72
  if (!adminToken)
43
73
  throw new UsageError("ADMIN_TOKEN is required for admin enrollment creation");
44
74
  const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
75
+ const webUrl = flagString(parsed.flags, "--web-url");
45
76
  const workspace = flagString(parsed.flags, "--workspace") ?? "default";
46
77
  const label = flagString(parsed.flags, "--label");
78
+ const email = flagString(parsed.flags, "--email");
47
79
  const result = await createEnrollment(apiUrl, adminToken, {
48
80
  workspace,
49
81
  label,
82
+ email,
50
83
  enrollmentSeconds: flagInt(parsed.flags, "--expires-in", "--expires-in"),
51
84
  tokenExpiresInSeconds: flagInt(parsed.flags, "--token-expires-in", "--token-expires-in"),
52
85
  scopes: parseScopes(flagString(parsed.flags, "--scopes")),
53
86
  });
54
- if (opts.json)
55
- process.stdout.write(JSON.stringify({ workspace, label: label ?? null, ...result }, null, 2) + "\n");
87
+ const separateCode = flagBool(parsed.flags, "--separate-code");
88
+ const pageUrl = invitePageUrl(apiUrl, result.pageId, webUrl);
89
+ const link = separateCode ? pageUrl : inviteMagicLink(pageUrl, result.code);
90
+ const footer = `workspace: ${workspace}\nexpires: ${result.expiresAt}\n`;
91
+ if (opts.json) {
92
+ process.stdout.write(JSON.stringify({ workspace, label: label ?? null, url: link, ...result }, null, 2) + "\n");
93
+ return 0;
94
+ }
95
+ if (email && result.emailed) {
96
+ process.stdout.write(`Invite emailed to ${email}\n${footer}`);
97
+ return 0;
98
+ }
99
+ if (email && result.emailed === false)
100
+ process.stderr.write("warning: email delivery failed; share this link instead\n");
101
+ if (separateCode)
102
+ process.stdout.write(`Invite page: ${pageUrl}\nOne-time code (share separately): ${result.code}\n${footer}`);
56
103
  else
57
- process.stdout.write(`Enrollment code (share once): ${result.code}\nworkspace: ${workspace}\nexpires: ${result.expiresAt}\n`);
104
+ process.stdout.write(`Invite link (contains the one-time code — treat like a password):\n${link}\n${footer}`);
58
105
  return 0;
59
106
  }
package/dist/commands.js CHANGED
@@ -11,6 +11,7 @@ import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsCo
11
11
  import { resolvePutPrefix } from "./destinations.js";
12
12
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
13
13
  import { applyFrame, resolveFrameId } from "./frame.js";
14
+ import { buildCliProvenance } from "./provenance.js";
14
15
  // --- put ---
15
16
  const PUT_HELP = `uploads put <file> [options]
16
17
 
@@ -24,6 +25,10 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
24
25
  Optional --frame wraps the image in a device/browser chrome before optimize
25
26
  (default off). See: uploads put --help frames
26
27
 
28
+ Uploads are public. --pr/--issue keys include the repo, number, and filename and
29
+ remain public even for private/internal GitHub repositories. Upload only media
30
+ that is safe at a predictable public URL.
31
+
27
32
  Options:
28
33
  --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
29
34
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
@@ -165,6 +170,10 @@ const ATTACH_HELP = `uploads attach <file...> [options]
165
170
  Upload one or more stable PR/issue attachments and maintain a single GitHub
166
171
  comment. With no target, uses the pull request for the current branch.
167
172
 
173
+ Attachments are public and their repo/number/filename keys are predictable.
174
+ Private/internal GitHub repository visibility does not restrict access; upload
175
+ only media that is safe at a public URL.
176
+
168
177
  Still images are optimized to WebP by default (same as put). Use --no-optimize
169
178
  to upload originals. Optional --frame wraps images in device/browser chrome.
170
179
 
@@ -230,6 +239,12 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
230
239
  filename: prepared.filename,
231
240
  key: ghAttachmentKey(target, prepared.filename),
232
241
  contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
242
+ provenance: buildCliProvenance({
243
+ sourceName,
244
+ optimized: prepared.optimized,
245
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
246
+ keepExif: optimizeOpts.keepExif === true,
247
+ }),
233
248
  });
234
249
  results.push({
235
250
  ...result,
@@ -363,6 +378,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
363
378
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
364
379
  contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
365
380
  deriveRepoFromGit: !noGit,
381
+ provenance: buildCliProvenance({
382
+ sourceName,
383
+ optimized: prepared.optimized,
384
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
385
+ keepExif: optimizeOpts.keepExif === true,
386
+ }),
366
387
  });
367
388
  const markdown = buildMarkdown(result.url, { alt, width });
368
389
  const optimizeMeta = {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
3
3
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
4
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
5
5
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
6
- export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
7
+ export { buildCliProvenance } from "./provenance.js";
7
8
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
8
9
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
9
10
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, reso
4
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
5
5
  export { UploadsError } from "./errors.js";
6
6
  export { createUploadsClient, } from "./client.js";
7
+ export { buildCliProvenance } from "./provenance.js";
7
8
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
8
9
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
9
10
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
package/dist/mcp/tools.js CHANGED
@@ -15,6 +15,7 @@ import { buildMarkdown } from "../embed.js";
15
15
  import { resolvePutPrefix } from "../destinations.js";
16
16
  import { ghAttachmentKey, ghKeyPrefix } from "../github.js";
17
17
  import { rewriteKeyExtension } from "../optimize.js";
18
+ import { buildCliProvenance } from "../provenance.js";
18
19
  import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
19
20
  import { optPosInt, optString, usage } from "./args.js";
20
21
  function optBool(args, name) {
@@ -137,7 +138,7 @@ export function createUploadsMcpTools(opts) {
137
138
  return [
138
139
  {
139
140
  name: "put",
140
- description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment.",
141
+ description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable and remain public for private/internal GitHub repositories, so upload only non-sensitive media.",
141
142
  inputSchema: {
142
143
  type: "object",
143
144
  properties: {
@@ -254,9 +255,11 @@ export function createUploadsMcpTools(opts) {
254
255
  : new Uint8Array(Buffer.from(contentBase64, "base64"));
255
256
  const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
256
257
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
258
+ const frameOpts = mcpFrameOptions(args);
259
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
257
260
  const prepared = await prepareImageForUpload(bytes, sourceName, {
258
- ...mcpFrameOptions(args),
259
- optimize: mcpOptimizeOptions(args, defaults),
261
+ ...frameOpts,
262
+ optimize: optimizeOpts,
260
263
  });
261
264
  const filename = prepared.filename;
262
265
  let key = target ? ghAttachmentKey(target, filename) : keyArg;
@@ -271,6 +274,13 @@ export function createUploadsMcpTools(opts) {
271
274
  ref: refArg ?? defaults.ref,
272
275
  contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
273
276
  deriveRepoFromGit: !noGit,
277
+ provenance: buildCliProvenance({
278
+ sourceName,
279
+ client: "uploads-mcp",
280
+ optimized: prepared.optimized,
281
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
282
+ keepExif: optimizeOpts.keepExif === true,
283
+ }),
274
284
  });
275
285
  const markdown = buildMarkdown(result.url, {
276
286
  alt: optString(args, "alt") ?? sourceName,
@@ -292,7 +302,7 @@ export function createUploadsMcpTools(opts) {
292
302
  },
293
303
  {
294
304
  name: "attach",
295
- description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch.",
305
+ description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch. Attachments are public and their repo/number/filename keys are predictable even for private/internal GitHub repositories; upload only non-sensitive media.",
296
306
  inputSchema: {
297
307
  type: "object",
298
308
  properties: {
@@ -352,6 +362,13 @@ export function createUploadsMcpTools(opts) {
352
362
  filename: prepared.filename,
353
363
  key: ghAttachmentKey(target, prepared.filename),
354
364
  contentType: prepared.optimized ? prepared.contentType : contentType,
365
+ provenance: buildCliProvenance({
366
+ sourceName,
367
+ client: "uploads-mcp",
368
+ optimized: prepared.optimized,
369
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
370
+ keepExif: optimizeOpts.keepExif === true,
371
+ }),
355
372
  });
356
373
  uploads.push({
357
374
  ...result,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Client-side provenance headers for put (X-Uploads-Meta-*).
3
+ * API allowlists keys; secrets never go here.
4
+ */
5
+ import type { ProvenanceInput } from "./client.js";
6
+ export declare function buildCliProvenance(opts: {
7
+ sourceName: string;
8
+ optimized?: boolean;
9
+ frameId?: string;
10
+ keepExif?: boolean;
11
+ client?: string;
12
+ }): ProvenanceInput;
@@ -0,0 +1,29 @@
1
+ import { createRequire } from "node:module";
2
+ let cachedVersion;
3
+ function packageVersion() {
4
+ if (cachedVersion)
5
+ return cachedVersion;
6
+ try {
7
+ const require = createRequire(import.meta.url);
8
+ const pkg = require("../package.json");
9
+ cachedVersion = pkg.version ?? "0.0.0";
10
+ }
11
+ catch {
12
+ cachedVersion = "0.0.0";
13
+ }
14
+ return cachedVersion;
15
+ }
16
+ export function buildCliProvenance(opts) {
17
+ const provenance = {
18
+ client: opts.client ?? "uploads-cli",
19
+ "client-version": packageVersion(),
20
+ "source-name": opts.sourceName.slice(0, 128),
21
+ };
22
+ if (opts.optimized)
23
+ provenance.optimized = "1";
24
+ if (opts.frameId)
25
+ provenance.frame = opts.frameId.slice(0, 64);
26
+ if (opts.keepExif)
27
+ provenance["keep-exif"] = "1";
28
+ return provenance;
29
+ }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
+ "sideEffects": false,
6
7
  "license": "MIT",
7
8
  "repository": {
8
9
  "type": "git",