@buildinternet/uploads 0.8.0 → 0.9.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
@@ -98,7 +98,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
98
98
 
99
99
  ## MCP server
100
100
 
101
- `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`health` needs no auth).
101
+ `uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `set_metadata` and `find_files` mirror `uploads meta set` and `uploads find`. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`health` needs no auth).
102
102
 
103
103
  ```json
104
104
  { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] }
@@ -106,7 +106,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
106
106
 
107
107
  Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`.
108
108
 
109
- For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. `uploads install` registers the skill + hosted MCP (short progress; `--verbose` for underlying output). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
109
+ For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skill + hosted MCP (short progress; `--verbose` for underlying output). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
110
110
 
111
111
  ## Programmatic use
112
112
 
package/dist/commands.js CHANGED
@@ -9,7 +9,7 @@ import { UploadsError } from "./errors.js";
9
9
  import { writeJson, writeStdout } from "./io.js";
10
10
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
11
11
  import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
12
- import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
12
+ import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
13
13
  import { resolvePutPrefix } from "./destinations.js";
14
14
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
15
15
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -65,6 +65,8 @@ Options:
65
65
  --optimize-quality <1-100> WebP quality (default: 85)
66
66
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
67
67
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
68
+ --auto Resolve current PR/issue and stamp gh.* metadata (default on)
69
+ --no-auto Skip gh.* auto-resolution (also skipped by --no-git or UPLOADS_NO_AUTO_META=1)
68
70
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
69
71
  --format human|url|markdown|json
70
72
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
@@ -107,6 +109,24 @@ export function makeGhTarget(pr, issue, repoArg, run) {
107
109
  function ghTargetFromFlags(flags, run) {
108
110
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
109
111
  }
112
+ /**
113
+ * Best-effort GitHub target for the default put path (no --pr/--issue). A
114
+ * numeric --ref is classified as pull vs issue; otherwise the current branch's
115
+ * PR is resolved. Never throws — any failure yields undefined so the upload
116
+ * proceeds without gh metadata.
117
+ */
118
+ function resolveAutoGhTarget(repoArg, ref, run) {
119
+ try {
120
+ const repo = resolveRepo(repoArg, run);
121
+ if (ref !== undefined && /^\d+$/.test(ref) && Number(ref) > 0) {
122
+ return classifyGhNumber(repo, Number.parseInt(ref, 10), run);
123
+ }
124
+ return resolveCurrentPullRequest(repo, run);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
110
130
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
111
131
  export function optimizeOptionsFromFlags(flags, defaults) {
112
132
  if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
@@ -363,6 +383,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
363
383
  }
364
384
  if (!ctx.quiet && comment)
365
385
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
386
+ // attach auto-writes gh.* metadata; point the user at how to find it later.
387
+ if (!ctx.quiet) {
388
+ const ref = ghMetadataFromTarget(target)["gh.ref"];
389
+ process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
390
+ }
366
391
  }
367
392
  return 0;
368
393
  }
@@ -390,13 +415,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
390
415
  const nameFlag = flagString(parsed.flags, "--name");
391
416
  const dryRun = flagBool(parsed.flags, "--dry-run");
392
417
  // Validate --meta up front (fail fast, before reading/optimizing the file).
393
- const metadata = (() => {
418
+ const userMeta = (() => {
394
419
  const pairs = flagValues(parsed.flags, "--meta");
395
420
  return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
396
421
  })();
397
422
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
398
423
  throw new UsageError("--comment takes no value — place it after the file argument");
399
424
  }
425
+ if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") {
426
+ throw new UsageError("--auto takes no value");
427
+ }
428
+ if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
429
+ throw new UsageError("--no-auto takes no value");
430
+ }
400
431
  if (wantComment && !ghTarget)
401
432
  throw new UsageError("--comment requires --pr or --issue");
402
433
  if (ghTarget) {
@@ -474,6 +505,43 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
474
505
  process.stderr.write(`>> ${note}\n`);
475
506
  }
476
507
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
508
+ // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
509
+ // best-effort auto resolution (on by default) where --meta wins. --no-git,
510
+ // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
511
+ // config default but never past --no-git (no repo to resolve).
512
+ let metadata = userMeta;
513
+ let attachedRef;
514
+ if (ghTarget) {
515
+ const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) };
516
+ validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
517
+ metadata = merged;
518
+ attachedRef = merged["gh.ref"];
519
+ }
520
+ else {
521
+ const autoEnabled = !noGit &&
522
+ !flagBool(parsed.flags, "--no-auto") &&
523
+ (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
524
+ if (autoEnabled) {
525
+ const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
526
+ if (autoTarget) {
527
+ const autoMeta = ghMetadataFromTarget(autoTarget);
528
+ const merged = { ...autoMeta, ...userMeta };
529
+ // Auto resolution must never fail the upload: if merging the gh.* pairs
530
+ // would exceed the metadata caps, drop them and upload with --meta only.
531
+ try {
532
+ validateMetaMap(merged);
533
+ metadata = merged;
534
+ attachedRef = merged["gh.ref"];
535
+ }
536
+ catch {
537
+ // keep metadata = userMeta (already validated); skip auto gh.*
538
+ }
539
+ }
540
+ }
541
+ }
542
+ if (attachedRef && !ctx.quiet && format === "human") {
543
+ process.stderr.write(`>> attached to ${attachedRef}\n`);
544
+ }
477
545
  let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
478
546
  if (key && prepared.optimized)
479
547
  key = rewriteKeyExtension(key, filename);
@@ -805,8 +873,15 @@ async function runFindFiles(ctx, filters, flags) {
805
873
  if (ctx.json)
806
874
  await writeJson(result);
807
875
  else
808
- for (const item of result.items)
809
- await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
876
+ for (const item of result.items) {
877
+ // LIST_HELP promises matched metadata in the output; render it inline
878
+ // (sorted for stable output) so human mode honors that, not just --json.
879
+ const meta = Object.entries(item.metadata)
880
+ .toSorted(([a], [b]) => a.localeCompare(b))
881
+ .map(([k, v]) => `${k}=${v}`)
882
+ .join(" ");
883
+ await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`);
884
+ }
810
885
  return 0;
811
886
  }
812
887
  export async function runList(ctx, args, help = false, run = execRunner) {
@@ -910,6 +985,11 @@ export async function runMeta(ctx, args, help = false) {
910
985
  const result = await ctx.client.getMetadata(key);
911
986
  if (ctx.json)
912
987
  await writeJson(result);
988
+ else if (Object.keys(result.metadata).length === 0) {
989
+ // Empty stdout reads as failure; a stderr note keeps stdout parseable.
990
+ if (!ctx.quiet)
991
+ process.stderr.write("(no metadata)\n");
992
+ }
913
993
  else
914
994
  for (const [k, v] of Object.entries(result.metadata))
915
995
  await writeStdout(`${k}=${v}\n`);
@@ -1,5 +1,5 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
- export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -12,6 +12,8 @@ export interface PutDefaults {
12
12
  noOptimize?: boolean;
13
13
  /** When true, optimize keeps EXIF/XMP/ICC (default strips). */
14
14
  keepExif?: boolean;
15
+ /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */
16
+ noAutoMeta?: boolean;
15
17
  }
16
18
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
17
19
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -12,6 +12,7 @@ export const UPLOADS_CONFIG_KEYS = [
12
12
  "UPLOADS_NO_GIT",
13
13
  "UPLOADS_NO_OPTIMIZE",
14
14
  "UPLOADS_KEEP_EXIF",
15
+ "UPLOADS_NO_AUTO_META",
15
16
  ];
16
17
  const PUT_DEFAULT_KEY_MAP = {
17
18
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -21,6 +22,7 @@ const PUT_DEFAULT_KEY_MAP = {
21
22
  noGit: "UPLOADS_NO_GIT",
22
23
  noOptimize: "UPLOADS_NO_OPTIMIZE",
23
24
  keepExif: "UPLOADS_KEEP_EXIF",
25
+ noAutoMeta: "UPLOADS_NO_AUTO_META",
24
26
  };
25
27
  function isTruthyConfigFlag(value) {
26
28
  if (!value)
@@ -44,6 +46,8 @@ export function putDefaultsToConfigValues(defaults) {
44
46
  out.UPLOADS_NO_OPTIMIZE = "1";
45
47
  if (defaults.keepExif)
46
48
  out.UPLOADS_KEEP_EXIF = "1";
49
+ if (defaults.noAutoMeta)
50
+ out.UPLOADS_NO_AUTO_META = "1";
47
51
  return out;
48
52
  }
49
53
  function parsePutDefaultsFromRaw(raw) {
@@ -65,6 +69,8 @@ function parsePutDefaultsFromRaw(raw) {
65
69
  out.noOptimize = true;
66
70
  if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
67
71
  out.keepExif = true;
72
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META))
73
+ out.noAutoMeta = true;
68
74
  return out;
69
75
  }
70
76
  function parsePutDefaultsFromEnv() {
@@ -83,6 +89,8 @@ function parsePutDefaultsFromEnv() {
83
89
  raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
84
90
  if (process.env.UPLOADS_KEEP_EXIF)
85
91
  raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
92
+ if (process.env.UPLOADS_NO_AUTO_META)
93
+ raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META;
86
94
  return parsePutDefaultsFromRaw(raw);
87
95
  }
88
96
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -152,6 +160,8 @@ export function mergePutDefaults(...layers) {
152
160
  out.noOptimize = layer.noOptimize;
153
161
  if (layer.keepExif != null)
154
162
  out.keepExif = layer.keepExif;
163
+ if (layer.noAutoMeta != null)
164
+ out.noAutoMeta = layer.noAutoMeta;
155
165
  }
156
166
  return out;
157
167
  }
@@ -9,6 +9,13 @@ export declare const execRunner: CommandRunner;
9
9
  export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
10
10
  /** Resolve the pull request associated with the current branch. */
11
11
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
+ /**
13
+ * Classify a bare PR/issue number via the GitHub API so the default `put`
14
+ * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
15
+ * missing, 404, network) — the caller treats that as "no gh context" and
16
+ * uploads without metadata.
17
+ */
18
+ export declare function classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined;
12
19
  /**
13
20
  * Create the managed attachments comment, or edit it in place if it already
14
21
  * exists. Never touches any other comment. Body is passed via stdin
package/dist/github-gh.js CHANGED
@@ -61,6 +61,30 @@ export function resolveCurrentPullRequest(repo, run = execRunner) {
61
61
  }
62
62
  throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
63
63
  }
64
+ /**
65
+ * Classify a bare PR/issue number via the GitHub API so the default `put`
66
+ * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
67
+ * missing, 404, network) — the caller treats that as "no gh context" and
68
+ * uploads without metadata.
69
+ */
70
+ export function classifyGhNumber(repo, num, run = execRunner) {
71
+ try {
72
+ const out = run("gh", [
73
+ "api",
74
+ `repos/${repo}/issues/${num}`,
75
+ "--jq",
76
+ 'if .pull_request then "pull" else "issue" end',
77
+ ]).trim();
78
+ if (out === "pull")
79
+ return { repo, kind: "pull", num };
80
+ if (out === "issue")
81
+ return { repo, kind: "issues", num };
82
+ }
83
+ catch {
84
+ // gh missing / not found / network — caller skips
85
+ }
86
+ return undefined;
87
+ }
64
88
  /**
65
89
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
66
90
  * Only the first 100 comments are searched (accepted v1 limitation).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.8.0",
3
+ "version": "0.9.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,