@buildinternet/uploads 0.7.0 → 0.8.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
@@ -21,8 +21,14 @@ uploads put ./shot.png --no-optimize
21
21
  uploads put ./mobile.png --frame phone
22
22
  uploads put ./ui.png --frame browser --frame-url "https://app.example"
23
23
  uploads put ./after.png --pr 123 --comment
24
+ uploads put ./capture-2026-…Z.png --pr 123 --name hero.png # clean leaf, stable path
25
+ uploads put ./shot.png --pr 123 --name hero.png --dry-run --format url # preview URL, no upload
24
26
  uploads gallery create --title "Release screenshots"
25
27
  uploads put ./after.png --gallery gal_example
28
+ uploads put ./shot.png --meta app=myapp --meta page=settings # queryable custom metadata
29
+ uploads meta get screenshots/myapp/42/shot.png
30
+ uploads meta set screenshots/myapp/42/shot.png page=onboarding --delete device
31
+ uploads find app=myapp page=settings # or: list --meta app=myapp
26
32
  uploads doctor
27
33
  ```
28
34
 
@@ -30,8 +36,8 @@ Inside this monorepo only, `pnpm uploads …` builds the package first so you pi
30
36
  up local source; product docs and PR “how to try it” examples should use the
31
37
  global `uploads` form above.
32
38
 
33
- Commands: `attach`, `put`, `gallery`, `comment`, `list`, `delete`, `usage`, `reconcile`,
34
- `purge-expired`, `setup`, `install`, `config`, `doctor`, `health`, `mcp`.
39
+ Commands: `attach`, `put`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
40
+ `reconcile`, `purge-expired`, `setup`, `install`, `config`, `doctor`, `health`, `mcp`.
35
41
 
36
42
  **Globals (before the command):** `--api-url`, `--token`, `--workspace` / `-w`,
37
43
  `--env-file`, `--json`, `--quiet`, `--version` / `-V`, `-h` / `--help`.
@@ -41,6 +47,11 @@ newer npm release is available (at most once/day, `~/.cache/uploads/`). Silence
41
47
  with `--quiet`, `--json`, `UPLOADS_NO_UPDATE=1`, or `NO_UPDATE_NOTIFIER=1`. Not used
42
48
  for `uploads mcp`.
43
49
 
50
+ **Exit codes:** `0` ok, `2` usage/token/file, `3` auth/policy, `4` network, `1` other.
51
+ Failures go to stderr; under `--format json|url|markdown` they also go to stdout so
52
+ piped runs stay self-diagnosing. Prefer JSON `code` over message text. `put --dry-run`
53
+ previews the key + public URL without uploading.
54
+
44
55
  `attach` is the agent-friendly default for GitHub media. It accepts one or more files,
45
56
  infers the pull request for the current branch via `gh`, uploads stable URLs, and creates
46
57
  or updates one marker-owned GitHub comment. It keeps loose `gh/...` attachments and linked public galleries in distinct sections, shows up to three available gallery images inline, and updates that same comment in place on every sync. Use `--pr`, `--issue`, and `--repo` to select
@@ -95,7 +106,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
95
106
 
96
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`.
97
108
 
98
- 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 it with Claude Code (and installs the agent skill) in one step. 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. `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.
99
110
 
100
111
  ## Programmatic use
101
112
 
@@ -26,16 +26,31 @@ export declare class UsageError extends Error {
26
26
  }
27
27
  export interface CommandFlags {
28
28
  positionals: string[];
29
- flags: Map<string, string | boolean>;
29
+ /** Repeated string flags (e.g. `--meta k=v --meta k2=v2`) collapse into a string[]. */
30
+ flags: Map<string, string | boolean | string[]>;
30
31
  help: boolean;
31
32
  }
32
33
  /**
33
34
  * Parse command-specific args. Supports `--flag value`, `--flag=value`, and
34
- * boolean `--flag` flags.
35
+ * boolean `--flag` flags. A flag repeated multiple times with string values
36
+ * (e.g. `--meta app=x --meta page=y`) collapses into a `string[]` — read it
37
+ * with `flagValues`, not `flagString`.
35
38
  */
36
39
  export declare function parseCommandArgs(args: string[]): CommandFlags;
40
+ /**
41
+ * Single string value for a flag. A repeated single-value flag keeps the
42
+ * pre-repeatable-flags behavior: the last occurrence wins (e.g.
43
+ * `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
44
+ * `flagValues` instead.
45
+ */
37
46
  export declare function flagString(flags: CommandFlags["flags"], name: string): string | undefined;
38
47
  export declare function flagBool(flags: CommandFlags["flags"], name: string): boolean;
48
+ /**
49
+ * Every string value passed for a repeatable flag (e.g. `--meta k=v`), in
50
+ * argument order. Empty when the flag is absent; a single occurrence yields
51
+ * a one-element array.
52
+ */
53
+ export declare function flagValues(flags: CommandFlags["flags"], name: string): string[];
39
54
  /** Command-level workspace override (`--workspace` / `-w`). */
40
55
  export declare function commandWorkspace(flags: CommandFlags["flags"]): string | undefined;
41
56
  export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string): number | undefined;
package/dist/cli-args.js CHANGED
@@ -69,9 +69,29 @@ export class UsageError extends Error {
69
69
  this.name = "UsageError";
70
70
  }
71
71
  }
72
+ /** Records a flag occurrence, turning a repeated string flag into an array. */
73
+ function setFlag(flags, name, value) {
74
+ const existing = flags.get(name);
75
+ if (existing === undefined) {
76
+ flags.set(name, value);
77
+ return;
78
+ }
79
+ if (Array.isArray(existing)) {
80
+ if (typeof value === "string")
81
+ existing.push(value);
82
+ return;
83
+ }
84
+ if (typeof existing === "string" && typeof value === "string") {
85
+ flags.set(name, [existing, value]);
86
+ return;
87
+ }
88
+ flags.set(name, value);
89
+ }
72
90
  /**
73
91
  * Parse command-specific args. Supports `--flag value`, `--flag=value`, and
74
- * boolean `--flag` flags.
92
+ * boolean `--flag` flags. A flag repeated multiple times with string values
93
+ * (e.g. `--meta app=x --meta page=y`) collapses into a `string[]` — read it
94
+ * with `flagValues`, not `flagString`.
75
95
  */
76
96
  export function parseCommandArgs(args) {
77
97
  const positionals = [];
@@ -88,18 +108,18 @@ export function parseCommandArgs(args) {
88
108
  if (arg.startsWith("--")) {
89
109
  const eq = arg.indexOf("=");
90
110
  if (eq !== -1) {
91
- flags.set(arg.slice(0, eq), arg.slice(eq + 1));
111
+ setFlag(flags, arg.slice(0, eq), arg.slice(eq + 1));
92
112
  i++;
93
113
  continue;
94
114
  }
95
115
  const name = arg;
96
116
  const next = args[i + 1];
97
117
  if (next && !next.startsWith("-")) {
98
- flags.set(name, next);
118
+ setFlag(flags, name, next);
99
119
  i += 2;
100
120
  continue;
101
121
  }
102
- flags.set(name, true);
122
+ setFlag(flags, name, true);
103
123
  i++;
104
124
  continue;
105
125
  }
@@ -108,13 +128,38 @@ export function parseCommandArgs(args) {
108
128
  }
109
129
  return { positionals, flags, help };
110
130
  }
131
+ /**
132
+ * Single string value for a flag. A repeated single-value flag keeps the
133
+ * pre-repeatable-flags behavior: the last occurrence wins (e.g.
134
+ * `--repo a --repo b` → `"b"`). Genuinely repeatable flags should use
135
+ * `flagValues` instead.
136
+ */
111
137
  export function flagString(flags, name) {
112
138
  const value = flags.get(name);
113
- return typeof value === "string" ? value : undefined;
139
+ if (typeof value === "string")
140
+ return value;
141
+ if (Array.isArray(value) && value.length > 0)
142
+ return value[value.length - 1];
143
+ return undefined;
114
144
  }
115
145
  export function flagBool(flags, name) {
116
146
  return flags.get(name) === true;
117
147
  }
148
+ /**
149
+ * Every string value passed for a repeatable flag (e.g. `--meta k=v`), in
150
+ * argument order. Empty when the flag is absent; a single occurrence yields
151
+ * a one-element array.
152
+ */
153
+ export function flagValues(flags, name) {
154
+ const value = flags.get(name);
155
+ if (value === undefined)
156
+ return [];
157
+ if (Array.isArray(value))
158
+ return value;
159
+ if (typeof value === "string")
160
+ return [value];
161
+ return [];
162
+ }
118
163
  /** Command-level workspace override (`--workspace` / `-w`). */
119
164
  export function commandWorkspace(flags) {
120
165
  return flagString(flags, "--workspace") ?? flagString(flags, "-w");
package/dist/cli.d.ts CHANGED
@@ -1 +1,6 @@
1
+ /** Effective stdout format, so failures surface where the caller reads output. */
2
+ type OutputFormat = "json" | "url" | "markdown" | "human";
3
+ /** Global `--json` wins; else put-style `--format`. Drives where failures print. */
4
+ export declare function outputFormat(argv: string[]): OutputFormat;
1
5
  export declare function runCli(argv: string[]): Promise<number>;
6
+ export {};
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createUploadsClient } from "./client.js";
2
2
  import { resolveApiUrl, resolveConfig } from "./config.js";
3
3
  import { UploadsError } from "./errors.js";
4
- import { commandWorkspace, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
- import { runPut, runAttach, runList, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
4
+ import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
+ import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
6
6
  import { runConfig } from "./commands/config.js";
7
7
  import { runSetup } from "./commands/setup.js";
8
8
  import { runLogin } from "./commands/login.js";
9
+ import { runInvite } from "./commands/invite.js";
9
10
  import { runAdmin } from "./commands/admin-enrollment.js";
10
11
  import { runMcp } from "./commands/mcp.js";
11
12
  import { runInstall } from "./commands/install.js";
@@ -41,7 +42,9 @@ Commands:
41
42
  put <file> Upload (+ URL + markdown for GitHub)
42
43
  gallery Create and organize public media galleries
43
44
  comment Create/update a PR/issue attachments comment (via gh)
44
- list List objects
45
+ list List objects (--meta k=v filters by queryable metadata)
46
+ find k=v... List objects matching metadata (alias for list --meta)
47
+ meta Get/set an object's queryable metadata
45
48
  delete <key> Delete object
46
49
  usage Workspace storage / upload counters
47
50
  reconcile Rebuild usage ledger from storage
@@ -49,7 +52,8 @@ Commands:
49
52
  setup Inspect/configure advanced CLI settings
50
53
  install Install the agent skill + register the remote MCP server
51
54
  login Sign in via browser (or an enrollment code) and save credentials
52
- admin Admin invitation management
55
+ invite Invite a teammate to a workspace (workspace admin; device login)
56
+ admin Site-operator invitation management (ADMIN_TOKEN)
53
57
  config Show path, init, or set shared config
54
58
  doctor Health + auth + workspace checks
55
59
  health API liveness (no auth)
@@ -99,6 +103,7 @@ function exitCode(err) {
99
103
  switch (err.code) {
100
104
  case "MISSING_TOKEN":
101
105
  case "USAGE":
106
+ case "FILE_NOT_FOUND":
102
107
  return 2;
103
108
  case "UNAUTHORIZED":
104
109
  case "NOT_FOUND":
@@ -114,32 +119,58 @@ function exitCode(err) {
114
119
  }
115
120
  return 1;
116
121
  }
117
- function errorOut(err, json) {
122
+ /** Global `--json` wins; else put-style `--format`. Drives where failures print. */
123
+ export function outputFormat(argv) {
124
+ const flags = parseCommandArgs(argv.slice(2)).flags;
125
+ if (flags.has("--json"))
126
+ return "json";
127
+ const value = flagString(flags, "--format");
128
+ if (value === "json" || value === "url" || value === "markdown")
129
+ return value;
130
+ return "human";
131
+ }
132
+ const QUOTA_HINT = "hint: run `uploads usage` then delete objects or raise limits (`pnpm workspace:limits`)\n";
133
+ const ERROR_HINTS = {
134
+ STORAGE_QUOTA: QUOTA_HINT,
135
+ UPLOAD_BUDGET: QUOTA_HINT,
136
+ KEY_POLICY: "hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n",
137
+ UNAUTHORIZED: "hint: token rejected — run `uploads login` to sign in again, or check UPLOADS_TOKEN / --token\n",
138
+ };
139
+ function errorOut(err, format) {
118
140
  const payload = err instanceof UploadsError
119
141
  ? { error: err.message, code: err.code, status: err.status }
120
142
  : err instanceof UsageError
121
143
  ? { error: err.message, code: "USAGE" }
122
144
  : { error: err instanceof Error ? err.message : String(err) };
123
- if (json)
145
+ if (format === "json") {
124
146
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
125
- else {
126
- const msg = payload.error;
127
- if (msg.includes("\n"))
128
- process.stderr.write(`${msg}\n`);
129
- else
130
- process.stderr.write(`error: ${msg}\n`);
131
- if (err instanceof UploadsError) {
132
- if (err.code === "STORAGE_QUOTA" || err.code === "UPLOAD_BUDGET") {
133
- process.stderr.write("hint: run `uploads usage` then delete objects or raise limits (`pnpm workspace:limits`)\n");
134
- }
135
- else if (err.code === "KEY_POLICY") {
136
- process.stderr.write("hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n");
137
- }
138
- else if (err.status === 413 || err.message.toLowerCase().includes("too large")) {
139
- process.stderr.write("hint: file exceeds workspace size policy (images vs video may differ); compress or raise --max-upload-bytes / --max-video-bytes\n");
140
- }
147
+ return;
148
+ }
149
+ const msg = payload.error;
150
+ // No token: onboarding nudge (no "error:" prefix). Exit stays non-zero; JSON keeps MISSING_TOKEN.
151
+ if (err instanceof UploadsError && err.code === "MISSING_TOKEN") {
152
+ process.stderr.write(`${msg}\n`);
153
+ if (format === "url" || format === "markdown") {
154
+ process.stdout.write("not signed in run uploads login\n");
155
+ }
156
+ return;
157
+ }
158
+ if (msg.includes("\n"))
159
+ process.stderr.write(`${msg}\n`);
160
+ else
161
+ process.stderr.write(`error: ${msg}\n`);
162
+ if (err instanceof UploadsError) {
163
+ const hint = ERROR_HINTS[err.code];
164
+ if (hint)
165
+ process.stderr.write(hint);
166
+ else if (err.status === 413 || err.message.toLowerCase().includes("too large")) {
167
+ process.stderr.write("hint: file exceeds workspace size policy (images vs video may differ); compress or raise --max-upload-bytes / --max-video-bytes\n");
141
168
  }
142
169
  }
170
+ // Scripted formats often drop stderr — mirror a one-line reason on stdout.
171
+ if (format === "url" || format === "markdown") {
172
+ process.stdout.write(`error: ${msg.replace(/\s*\n\s*/g, " ")}\n`);
173
+ }
143
174
  }
144
175
  /** Point agents at layered --help instead of dumping the full root manual. */
145
176
  function usageHint(argv) {
@@ -180,6 +211,9 @@ export async function runCli(argv) {
180
211
  case "login":
181
212
  code = await runLogin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
182
213
  break;
214
+ case "invite":
215
+ code = await runInvite(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
216
+ break;
183
217
  case "admin":
184
218
  code = await runAdmin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
185
219
  break;
@@ -193,6 +227,8 @@ export async function runCli(argv) {
193
227
  case "put":
194
228
  case "gallery":
195
229
  case "list":
230
+ case "find":
231
+ case "meta":
196
232
  case "delete":
197
233
  case "usage":
198
234
  case "reconcile":
@@ -216,6 +252,12 @@ export async function runCli(argv) {
216
252
  case "list":
217
253
  code = await runList(ctx, cmdArgs, showHelp);
218
254
  break;
255
+ case "find":
256
+ code = await runFind(ctx, cmdArgs, showHelp);
257
+ break;
258
+ case "meta":
259
+ code = await runMeta(ctx, cmdArgs, showHelp);
260
+ break;
219
261
  case "delete":
220
262
  code = await runDelete(ctx, cmdArgs, showHelp);
221
263
  break;
@@ -245,8 +287,9 @@ export async function runCli(argv) {
245
287
  return code;
246
288
  }
247
289
  catch (err) {
248
- errorOut(err, argv.includes("--json"));
249
- if (err instanceof UsageError && !argv.includes("--json"))
290
+ const format = outputFormat(argv);
291
+ errorOut(err, format);
292
+ if (err instanceof UsageError && format !== "json")
250
293
  usageHint(argv);
251
294
  return exitCode(err);
252
295
  }
package/dist/client.d.ts CHANGED
@@ -17,16 +17,47 @@ export interface PutOptions {
17
17
  deriveRepoFromGit?: boolean;
18
18
  /** Stored as R2 custom metadata; echoed on put/head. */
19
19
  provenance?: ProvenanceInput;
20
+ /**
21
+ * Queryable custom metadata (D1 `file_metadata`), sent alongside provenance
22
+ * as more `X-Uploads-Meta-<key>` headers — the server routes each key to R2
23
+ * (provenance) or D1 (everything else) by name. See `metadata.ts` for the
24
+ * client-side validation callers should run before this.
25
+ */
26
+ metadata?: Record<string, string>;
27
+ /** Validate key + resolve public URL without writing. `size` is local bytes only. */
28
+ dryRun?: boolean;
20
29
  }
21
30
  export interface ListOptions {
22
31
  prefix?: string;
23
32
  limit?: number;
24
33
  cursor?: string;
25
34
  }
35
+ export interface FindFilesOptions {
36
+ prefix?: string;
37
+ limit?: number;
38
+ }
39
+ export interface FindFilesItem {
40
+ key: string;
41
+ url: string | null;
42
+ metadata: Record<string, string>;
43
+ }
44
+ export interface FindFilesResult {
45
+ items: FindFilesItem[];
46
+ cursor: string | null;
47
+ }
48
+ export interface GetMetadataResult {
49
+ metadata: Record<string, string>;
50
+ }
51
+ export interface PatchMetadataOptions {
52
+ set?: Record<string, string>;
53
+ delete?: string[];
54
+ }
26
55
  export interface PutResult {
27
56
  workspace: string;
28
57
  key: string;
29
58
  url: string;
59
+ /** Same object on the embed host when dual-host applies; prefer for GitHub markdown. */
60
+ embedUrl: string | null;
30
61
  size: number;
31
62
  contentType: string;
32
63
  metadata?: Record<string, string>;
@@ -34,6 +65,7 @@ export interface PutResult {
34
65
  export interface ListItem {
35
66
  key: string;
36
67
  url: string | null;
68
+ embedUrl?: string | null;
37
69
  size?: number;
38
70
  uploaded?: string;
39
71
  }
@@ -44,6 +76,7 @@ export interface ListResult {
44
76
  export interface HeadResult {
45
77
  key: string;
46
78
  url: string | null;
79
+ embedUrl?: string | null;
47
80
  size: number;
48
81
  contentType: string;
49
82
  uploaded?: string;
@@ -63,6 +96,8 @@ export interface GalleryItem {
63
96
  createdAt: string;
64
97
  status: "available" | "missing";
65
98
  url: string | null;
99
+ /** Dual-host embed URL when available. */
100
+ embedUrl?: string | null;
66
101
  /** Standalone web page for this item (gallery URL + item id). Absent on older API deployments. */
67
102
  pageUrl?: string;
68
103
  contentType: string | null;
@@ -193,6 +228,13 @@ export declare function createEnrollment(apiUrl: string, adminToken: string, inp
193
228
  }): Promise<EnrollmentCreateResult>;
194
229
  /** Static OAuth client id allowlisted by the auth worker's `validateClient`. */
195
230
  export declare const DEVICE_CLIENT_ID = "uploads-cli";
231
+ /**
232
+ * User-Agent for device-flow requests. Stored on the Better Auth session row
233
+ * when `/device/token` creates the session, so the web account UI can tell a
234
+ * completed `uploads login` apart from a browser tab. Keep the
235
+ * `@buildinternet/uploads` prefix in sync with apps/web `CLI_USER_AGENT_RE`.
236
+ */
237
+ export declare function cliUserAgent(purpose?: string): string;
196
238
  export interface DeviceCodeResponse {
197
239
  device_code: string;
198
240
  user_code: string;
@@ -247,6 +289,24 @@ export interface MintTokenResult {
247
289
  label: string | null;
248
290
  expiresAt: string | null;
249
291
  }
292
+ /**
293
+ * POST /me/workspaces/:name/invites — org invitation for a workspace.
294
+ * Requires a Better Auth session bearer (device flow), not a workspace token.
295
+ * Caller must be org admin|owner. `acceptUrl` is always returned so
296
+ * self-hosted deploys without email can still share the link.
297
+ */
298
+ export declare function createWorkspaceInvite(apiUrl: string, accessToken: string, workspace: string, input: {
299
+ email: string;
300
+ role?: "member" | "admin";
301
+ }): Promise<{
302
+ invitation: {
303
+ id: string;
304
+ email: string;
305
+ role: string;
306
+ status: string;
307
+ };
308
+ acceptUrl?: string;
309
+ }>;
250
310
  /**
251
311
  * POST /v1/tokens — mint a `up_<workspace>_…` workspace token from a device-flow
252
312
  * session (presented as a bearer). v1 sends exactly one grant.
@@ -267,6 +327,15 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
267
327
  cursor?: string;
268
328
  }): Promise<ListItem[]>;
269
329
  delete(key: string): Promise<DeleteResult>;
330
+ /** `GET /v1/:workspace/files/:key?metadata=1` — the object's queryable metadata. */
331
+ getMetadata(key: string): Promise<GetMetadataResult>;
332
+ /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
333
+ patchMetadata(key: string, opts: PatchMetadataOptions): Promise<GetMetadataResult>;
334
+ /**
335
+ * `GET /v1/:workspace/files?meta.<k>=<v>&…` — ANDed equality filter over
336
+ * queryable metadata. `filters` must be pre-validated (see `metadata.ts`).
337
+ */
338
+ findFiles(filters: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
270
339
  head(key: string): Promise<HeadResult>;
271
340
  createGallery(opts: CreateGalleryOptions): Promise<Gallery>;
272
341
  getGallery(id: string): Promise<Gallery>;
package/dist/client.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { inferContentType } from "./embed.js";
2
2
  import { UploadsError } from "./errors.js";
3
3
  import { buildScreenshotKey } from "./keys.js";
4
+ import { packageVersion } from "./package-version.js";
5
+ import { resolveEmbedUrl } from "./public-urls.js";
4
6
  async function jsonRequest(url, init) {
5
7
  let res;
6
8
  try {
@@ -36,11 +38,23 @@ export function createEnrollment(apiUrl, adminToken, input) {
36
38
  // below are what the worker expects.
37
39
  /** Static OAuth client id allowlisted by the auth worker's `validateClient`. */
38
40
  export const DEVICE_CLIENT_ID = "uploads-cli";
41
+ /**
42
+ * User-Agent for device-flow requests. Stored on the Better Auth session row
43
+ * when `/device/token` creates the session, so the web account UI can tell a
44
+ * completed `uploads login` apart from a browser tab. Keep the
45
+ * `@buildinternet/uploads` prefix in sync with apps/web `CLI_USER_AGENT_RE`.
46
+ */
47
+ export function cliUserAgent(purpose = "device-login") {
48
+ return `@buildinternet/uploads/${packageVersion()} (${purpose})`;
49
+ }
39
50
  /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
40
51
  export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID) {
41
52
  return jsonRequest(`${authUrl.replace(/\/$/, "")}/api/auth/device/code`, {
42
53
  method: "POST",
43
- headers: { "Content-Type": "application/json" },
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ "User-Agent": cliUserAgent("device-code"),
57
+ },
44
58
  body: JSON.stringify({ client_id: clientId }),
45
59
  });
46
60
  }
@@ -49,7 +63,12 @@ export async function requestDeviceToken(authUrl, input) {
49
63
  try {
50
64
  res = await fetch(`${authUrl.replace(/\/$/, "")}/api/auth/device/token`, {
51
65
  method: "POST",
52
- headers: { "Content-Type": "application/json" },
66
+ headers: {
67
+ "Content-Type": "application/json",
68
+ // Session user_agent is taken from this request when the token is
69
+ // exchanged — identify as the CLI so /account can surface it.
70
+ "User-Agent": cliUserAgent("device-token"),
71
+ },
53
72
  body: JSON.stringify({
54
73
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
55
74
  device_code: input.deviceCode,
@@ -93,6 +112,22 @@ export function listMintWorkspaces(apiUrl, accessToken) {
93
112
  headers: { Authorization: `Bearer ${accessToken}` },
94
113
  });
95
114
  }
115
+ /**
116
+ * POST /me/workspaces/:name/invites — org invitation for a workspace.
117
+ * Requires a Better Auth session bearer (device flow), not a workspace token.
118
+ * Caller must be org admin|owner. `acceptUrl` is always returned so
119
+ * self-hosted deploys without email can still share the link.
120
+ */
121
+ export function createWorkspaceInvite(apiUrl, accessToken, workspace, input) {
122
+ return jsonRequest(`${apiUrl.replace(/\/$/, "")}/me/workspaces/${encodeURIComponent(workspace)}/invites`, {
123
+ method: "POST",
124
+ headers: {
125
+ Authorization: `Bearer ${accessToken}`,
126
+ "Content-Type": "application/json",
127
+ },
128
+ body: JSON.stringify({ email: input.email, role: input.role ?? "member" }),
129
+ });
130
+ }
96
131
  /**
97
132
  * POST /v1/tokens — mint a `up_<workspace>_…` workspace token from a device-flow
98
133
  * session (presented as a bearer). v1 sends exactly one grant.
@@ -206,7 +241,14 @@ export function createUploadsClient(config) {
206
241
  if (opts.cursor)
207
242
  params.set("cursor", opts.cursor);
208
243
  const qs = params.toString();
209
- return request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
244
+ const page = await request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
245
+ return {
246
+ ...page,
247
+ items: page.items.map((item) => ({
248
+ ...item,
249
+ embedUrl: resolveEmbedUrl(item.url, item.embedUrl),
250
+ })),
251
+ };
210
252
  }
211
253
  async function getGallery(id) {
212
254
  return request("GET", `${galleriesBase(config)}/${encodeURIComponent(id)}`);
@@ -223,6 +265,20 @@ export function createUploadsClient(config) {
223
265
  deriveRepoFromGit: opts.deriveRepoFromGit,
224
266
  }));
225
267
  const contentType = opts.contentType ?? inferContentType(opts.filename);
268
+ if (opts.dryRun) {
269
+ const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?dryRun=1`);
270
+ if (preview.url == null) {
271
+ throw new UploadsError("workspace has no publicBaseUrl (cannot resolve a public URL)", "NO_PUBLIC_URL");
272
+ }
273
+ return {
274
+ workspace: preview.workspace,
275
+ key: preview.key,
276
+ url: preview.url,
277
+ embedUrl: resolveEmbedUrl(preview.url, preview.embedUrl),
278
+ size: body.byteLength,
279
+ contentType,
280
+ };
281
+ }
226
282
  const headers = { "Content-Type": contentType };
227
283
  if (opts.provenance) {
228
284
  for (const [k, v] of Object.entries(opts.provenance)) {
@@ -230,6 +286,14 @@ export function createUploadsClient(config) {
230
286
  headers[`X-Uploads-Meta-${k}`] = v;
231
287
  }
232
288
  }
289
+ // Same header prefix as provenance above; the server splits allowlisted
290
+ // provenance keys (R2) from everything else (D1 file_metadata) by name.
291
+ if (opts.metadata) {
292
+ for (const [k, v] of Object.entries(opts.metadata)) {
293
+ if (v !== undefined && v !== "")
294
+ headers[`X-Uploads-Meta-${k}`] = v;
295
+ }
296
+ }
233
297
  const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
234
298
  body,
235
299
  headers,
@@ -237,7 +301,11 @@ export function createUploadsClient(config) {
237
301
  if (result.url == null) {
238
302
  throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
239
303
  }
240
- return { ...result, url: result.url };
304
+ return {
305
+ ...result,
306
+ url: result.url,
307
+ embedUrl: resolveEmbedUrl(result.url, result.embedUrl),
308
+ };
241
309
  },
242
310
  list,
243
311
  /** Follow cursors (optionally starting from one) and return every remaining item. */
@@ -254,8 +322,34 @@ export function createUploadsClient(config) {
254
322
  async delete(key) {
255
323
  return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
256
324
  },
325
+ /** `GET /v1/:workspace/files/:key?metadata=1` — the object's queryable metadata. */
326
+ async getMetadata(key) {
327
+ return request("GET", `${filesBase(config)}/${encodeKeyPath(key)}?metadata=1`);
328
+ },
329
+ /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
330
+ async patchMetadata(key, opts) {
331
+ return request("PATCH", `${filesBase(config)}/${encodeKeyPath(key)}`, {
332
+ body: new TextEncoder().encode(JSON.stringify(opts)),
333
+ headers: { "Content-Type": "application/json" },
334
+ });
335
+ },
336
+ /**
337
+ * `GET /v1/:workspace/files?meta.<k>=<v>&…` — ANDed equality filter over
338
+ * queryable metadata. `filters` must be pre-validated (see `metadata.ts`).
339
+ */
340
+ async findFiles(filters, opts = {}) {
341
+ const params = new URLSearchParams();
342
+ for (const [k, v] of Object.entries(filters))
343
+ params.append(`meta.${k}`, v);
344
+ if (opts.prefix)
345
+ params.set("prefix", opts.prefix);
346
+ if (opts.limit != null)
347
+ params.set("limit", String(opts.limit));
348
+ return request("GET", `${filesBase(config)}?${params.toString()}`);
349
+ },
257
350
  async head(key) {
258
- return request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
351
+ const result = await request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
352
+ return { ...result, embedUrl: resolveEmbedUrl(result.url, result.embedUrl) };
259
353
  },
260
354
  async createGallery(opts) {
261
355
  return request("POST", galleriesBase(config), {