@buildinternet/uploads 0.6.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/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 {
@@ -27,6 +29,120 @@ export function createEnrollment(apiUrl, adminToken, input) {
27
29
  body: JSON.stringify(input),
28
30
  });
29
31
  }
32
+ // --- Device authorization (RFC 8628) — the `uploads login` device flow ---
33
+ //
34
+ // The CLI speaks the auth worker's OAuth-shaped endpoints directly with plain
35
+ // `fetch` (no better-auth client dependency in the published package, per plan
36
+ // D5). Better Auth's `device.code`/`device.token` endpoints take
37
+ // `application/json` bodies, NOT the RFC's form-encoding — the JSON shapes
38
+ // below are what the worker expects.
39
+ /** Static OAuth client id allowlisted by the auth worker's `validateClient`. */
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
+ }
50
+ /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
51
+ export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID) {
52
+ return jsonRequest(`${authUrl.replace(/\/$/, "")}/api/auth/device/code`, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ "User-Agent": cliUserAgent("device-code"),
57
+ },
58
+ body: JSON.stringify({ client_id: clientId }),
59
+ });
60
+ }
61
+ export async function requestDeviceToken(authUrl, input) {
62
+ let res;
63
+ try {
64
+ res = await fetch(`${authUrl.replace(/\/$/, "")}/api/auth/device/token`, {
65
+ method: "POST",
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
+ },
72
+ body: JSON.stringify({
73
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
74
+ device_code: input.deviceCode,
75
+ client_id: input.clientId ?? DEVICE_CLIENT_ID,
76
+ }),
77
+ });
78
+ }
79
+ catch (err) {
80
+ throw new UploadsError(err instanceof Error ? err.message : "network request failed", "NETWORK");
81
+ }
82
+ const body = (await res.json().catch(() => null));
83
+ if (res.ok && body?.access_token) {
84
+ return {
85
+ status: "ok",
86
+ accessToken: body.access_token,
87
+ tokenType: body.token_type ?? "Bearer",
88
+ expiresIn: typeof body.expires_in === "number" ? body.expires_in : 0,
89
+ scope: body.scope ?? "",
90
+ };
91
+ }
92
+ switch (body?.error) {
93
+ case "authorization_pending":
94
+ return { status: "pending" };
95
+ case "slow_down":
96
+ return { status: "slow_down" };
97
+ case "expired_token":
98
+ return { status: "expired" };
99
+ case "access_denied":
100
+ return { status: "denied" };
101
+ default:
102
+ return {
103
+ status: "error",
104
+ error: body?.error ?? "unknown",
105
+ description: body?.error_description,
106
+ };
107
+ }
108
+ }
109
+ /** GET /v1/tokens — workspaces the signed-in user can mint tokens for. */
110
+ export function listMintWorkspaces(apiUrl, accessToken) {
111
+ return jsonRequest(`${apiUrl.replace(/\/$/, "")}/v1/tokens`, {
112
+ headers: { Authorization: `Bearer ${accessToken}` },
113
+ });
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
+ }
131
+ /**
132
+ * POST /v1/tokens — mint a `up_<workspace>_…` workspace token from a device-flow
133
+ * session (presented as a bearer). v1 sends exactly one grant.
134
+ */
135
+ export function mintWorkspaceToken(apiUrl, accessToken, input) {
136
+ return jsonRequest(`${apiUrl.replace(/\/$/, "")}/v1/tokens`, {
137
+ method: "POST",
138
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
139
+ body: JSON.stringify({
140
+ grants: [{ workspace: input.workspace, ...(input.scopes ? { scopes: input.scopes } : {}) }],
141
+ ...(input.label ? { label: input.label } : {}),
142
+ ...(input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {}),
143
+ }),
144
+ });
145
+ }
30
146
  function encodeKeyPath(key) {
31
147
  return key.split("/").map(encodeURIComponent).join("/");
32
148
  }
@@ -125,7 +241,14 @@ export function createUploadsClient(config) {
125
241
  if (opts.cursor)
126
242
  params.set("cursor", opts.cursor);
127
243
  const qs = params.toString();
128
- 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
+ };
129
252
  }
130
253
  async function getGallery(id) {
131
254
  return request("GET", `${galleriesBase(config)}/${encodeURIComponent(id)}`);
@@ -142,6 +265,20 @@ export function createUploadsClient(config) {
142
265
  deriveRepoFromGit: opts.deriveRepoFromGit,
143
266
  }));
144
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
+ }
145
282
  const headers = { "Content-Type": contentType };
146
283
  if (opts.provenance) {
147
284
  for (const [k, v] of Object.entries(opts.provenance)) {
@@ -149,6 +286,14 @@ export function createUploadsClient(config) {
149
286
  headers[`X-Uploads-Meta-${k}`] = v;
150
287
  }
151
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
+ }
152
297
  const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
153
298
  body,
154
299
  headers,
@@ -156,7 +301,11 @@ export function createUploadsClient(config) {
156
301
  if (result.url == null) {
157
302
  throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
158
303
  }
159
- return { ...result, url: result.url };
304
+ return {
305
+ ...result,
306
+ url: result.url,
307
+ embedUrl: resolveEmbedUrl(result.url, result.embedUrl),
308
+ };
160
309
  },
161
310
  list,
162
311
  /** Follow cursors (optionally starting from one) and return every remaining item. */
@@ -173,8 +322,34 @@ export function createUploadsClient(config) {
173
322
  async delete(key) {
174
323
  return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
175
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
+ },
176
350
  async head(key) {
177
- 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) };
178
353
  },
179
354
  async createGallery(opts) {
180
355
  return request("POST", galleriesBase(config), {
@@ -13,26 +13,28 @@ bearer token, so only the token is needed.
13
13
  Usage:
14
14
  uploads install [skill|mcp|all] (default: all)
15
15
 
16
- What runs:
17
- skill npx -y skills add ${SKILL_SOURCE} --skill ${SKILL_NAME}
16
+ What it does:
17
+ skill Agent skill (via npx skills) when to host files / embed in PRs
18
+ mcp Hosted MCP server in Claude Code — put, list, attach, galleries
19
+
20
+ What runs under the hood:
21
+ skill npx -y skills add ${SKILL_SOURCE} --skill ${SKILL_NAME} -g -y -a '*'
18
22
  mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
19
23
  --header "Authorization: Bearer <token>"
20
24
 
21
25
  Options:
22
26
  --url <endpoint> Remote MCP endpoint (default: ${DEFAULT_MCP_URL})
23
27
  --name <name> MCP server name in the client (default: uploads)
24
- --dry-run Print the commands without running them
28
+ --dry-run Print the plan without running anything
29
+ --verbose Show underlying command output (default: errors only)
25
30
 
26
31
  Examples:
27
32
  uploads install
28
33
  uploads install skill
29
- uploads install mcp --dry-run
34
+ uploads install mcp
35
+ uploads install --dry-run
30
36
  `;
31
- /**
32
- * Masks the configured token (and any Bearer credential) in text destined
33
- * for stdout/stderr/JSON — command echoes, child-process output, and error
34
- * messages can all embed it.
35
- */
37
+ /** Mask Bearer credentials and the configured token in any printed text. */
36
38
  function redactor(token) {
37
39
  return (text) => {
38
40
  let out = text.replace(/Bearer \S+/g, "Bearer ***");
@@ -48,13 +50,76 @@ function runStep(run, command) {
48
50
  }
49
51
  catch (err) {
50
52
  const message = err instanceof Error ? err.message : String(err);
51
- // execFileSync's ENOENT means the binary itself is missing.
52
53
  const hint = err.code === "ENOENT"
53
- ? `${command[0]} not found on PATH — run manually: ${command.join(" ")}`
54
+ ? `${command[0]} not found on PATH — install it, or run manually: ${command.join(" ")}`
54
55
  : message;
55
56
  return { command, ok: false, error: hint };
56
57
  }
57
58
  }
59
+ function skillCommand() {
60
+ // -g global, -y non-interactive, -a '*' every agent (skips the multi-select TUI)
61
+ return ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", SKILL_NAME, "-g", "-y", "-a", "*"];
62
+ }
63
+ function mcpCommand(name, url, bearer) {
64
+ return [
65
+ "claude",
66
+ "mcp",
67
+ "add",
68
+ "--transport",
69
+ "http",
70
+ name,
71
+ url,
72
+ "--header",
73
+ `Authorization: Bearer ${bearer}`,
74
+ ];
75
+ }
76
+ function peekToken(globals) {
77
+ try {
78
+ const config = resolveConfig({
79
+ apiUrl: globals.apiUrl,
80
+ workspace: globals.workspace,
81
+ token: globals.token,
82
+ envFile: globals.envFile,
83
+ requireToken: false,
84
+ });
85
+ return config.token || undefined;
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
91
+ function printHumanSteps(results, redact, verbose) {
92
+ for (const [step, r] of Object.entries(results)) {
93
+ const cmd = redact(r.command.join(" "));
94
+ if (r.skipped === "dry-run") {
95
+ process.stdout.write(`${step}: would run — ${cmd}\n`);
96
+ }
97
+ else if (r.skipped === "sign-in") {
98
+ process.stdout.write(`${step}: skipped — ${redact(r.error ?? "needs sign-in")}\n`);
99
+ }
100
+ else if (r.ok) {
101
+ process.stdout.write(`${step}: ok\n`);
102
+ if (verbose && r.output) {
103
+ process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
104
+ }
105
+ }
106
+ else {
107
+ process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
108
+ if (verbose)
109
+ process.stderr.write(` command: ${cmd}\n`);
110
+ }
111
+ }
112
+ }
113
+ function printSuccessFooter(steps, signedIn) {
114
+ process.stdout.write(`\nDone — ${steps.join(" and ")} ready.\n` +
115
+ "Restart your agent session so it picks up the new skill/server.\n" +
116
+ "Then ask it to host a screenshot or attach images to a PR — for example:\n" +
117
+ ' "upload this screenshot and put it in the PR description"\n' +
118
+ ' "attach before.png and after.png to this PR"\n');
119
+ if (!signedIn) {
120
+ process.stdout.write("\nNot signed in yet? Run `uploads login` once so put/attach/MCP can authenticate.\n");
121
+ }
122
+ }
58
123
  export async function runInstall(args, opts, help = false) {
59
124
  const parsed = parseCommandArgs(args);
60
125
  if (help || parsed.help) {
@@ -68,66 +133,59 @@ export async function runInstall(args, opts, help = false) {
68
133
  const url = flagString(parsed.flags, "--url") ?? DEFAULT_MCP_URL;
69
134
  const name = flagString(parsed.flags, "--name") ?? "uploads";
70
135
  const dryRun = flagBool(parsed.flags, "--dry-run");
136
+ const verbose = flagBool(parsed.flags, "--verbose");
71
137
  const run = opts.runner ?? execRunner;
138
+ const human = !opts.json && !dryRun;
139
+ const token = peekToken(opts.globals);
140
+ const signedIn = Boolean(token);
141
+ const redact = redactor(token);
72
142
  const results = {};
73
- let redact = redactor(undefined);
74
143
  if (target === "skill" || target === "all") {
75
- const command = ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", SKILL_NAME];
144
+ const command = skillCommand();
145
+ if (human)
146
+ process.stdout.write("Installing skill…\n");
76
147
  results.skill = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
77
148
  }
78
149
  if (target === "mcp" || target === "all") {
79
- const config = resolveConfig({
80
- apiUrl: opts.globals.apiUrl,
81
- workspace: opts.globals.workspace,
82
- token: opts.globals.token,
83
- envFile: opts.globals.envFile,
84
- requireToken: !dryRun,
85
- });
86
- const bearer = config.token || "<token>";
87
- redact = redactor(config.token || undefined);
88
- const command = [
89
- "claude",
90
- "mcp",
91
- "add",
92
- "--transport",
93
- "http",
94
- name,
95
- url,
96
- "--header",
97
- `Authorization: Bearer ${bearer}`,
98
- ];
99
- results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
150
+ if (!dryRun && !token) {
151
+ results.mcp = {
152
+ command: mcpCommand(name, url, "<token>"),
153
+ ok: false,
154
+ skipped: "sign-in",
155
+ error: "needs sign-in — run `uploads login`, then `uploads install mcp`",
156
+ };
157
+ }
158
+ else {
159
+ const command = mcpCommand(name, url, token || "<token>");
160
+ if (human)
161
+ process.stdout.write("Installing MCP server…\n");
162
+ results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
163
+ }
100
164
  }
101
165
  const failed = Object.values(results).some((r) => !r.ok);
102
166
  if (opts.json) {
103
- // Never echo the token in structured output — commands, child output,
104
- // and error text can all embed it.
105
- const redacted = Object.fromEntries(Object.entries(results).map(([key, r]) => [
167
+ const steps = Object.fromEntries(Object.entries(results).map(([key, r]) => [
106
168
  key,
107
169
  {
108
- ...r,
109
170
  command: r.command.map(redact),
171
+ ok: r.ok,
172
+ skipped: r.skipped,
110
173
  output: r.output === undefined ? undefined : redact(r.output),
111
174
  error: r.error === undefined ? undefined : redact(r.error),
112
175
  },
113
176
  ]));
114
- process.stdout.write(JSON.stringify({ ok: !failed, steps: redacted }, null, 2) + "\n");
177
+ process.stdout.write(JSON.stringify({ ok: !failed, steps }, null, 2) + "\n");
115
178
  return failed ? 1 : 0;
116
179
  }
117
- for (const [step, r] of Object.entries(results)) {
118
- const shown = redact(r.command.join(" "));
119
- if (r.skipped)
120
- process.stdout.write(`${step}: would run — ${shown}\n`);
121
- else if (r.ok) {
122
- process.stdout.write(`${step}: ok — ${shown}\n`);
123
- if (r.output)
124
- process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
125
- }
126
- else
127
- process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
128
- }
180
+ printHumanSteps(results, redact, verbose);
129
181
  if (!failed && !dryRun) {
130
- process.stderr.write("hint: restart your agent session to pick up the new skill/server\n");
182
+ printSuccessFooter(Object.keys(results), signedIn);
183
+ }
184
+ else if (failed && !dryRun && results.skill?.ok && results.mcp && !results.mcp.ok) {
185
+ const next = results.mcp.skipped === "sign-in"
186
+ ? "Sign in with `uploads login`, then re-run `uploads install mcp`."
187
+ : "Fix the MCP step above, then re-run `uploads install mcp`.";
188
+ process.stdout.write(`\nSkill is installed. ${next}\n`);
131
189
  }
132
190
  return failed ? 1 : 0;
133
191
  }
@@ -0,0 +1,10 @@
1
+ import { type DeviceLoginIo } from "./login.js";
2
+ /** Exported for unit tests. */
3
+ export declare function resolveInviteWorkspace(workspaces: Array<{
4
+ workspace: string;
5
+ role: string;
6
+ }>, requested: string | undefined): string;
7
+ export declare function runInvite(args: string[], opts: {
8
+ json?: boolean;
9
+ apiUrl?: string;
10
+ }, help?: boolean, io?: DeviceLoginIo): Promise<number>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Workspace-admin email invite via ephemeral device session.
3
+ * Not ADMIN_TOKEN, not a workspace upload token.
4
+ */
5
+ import { createWorkspaceInvite, listMintWorkspaces } from "../client.js";
6
+ import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
7
+ import { defaultDeviceIo, obtainDeviceAccessToken, resolveAuthUrl, } from "./login.js";
8
+ const HELP = `uploads invite create [options]
9
+
10
+ Invite someone to a workspace by email. Opens a browser so you approve as
11
+ yourself (device login). You must be an admin or owner of that workspace.
12
+
13
+ The invitee gets email when Email Sending is configured; either way the
14
+ CLI prints an accept URL you can share. After accepting they run uploads login.
15
+
16
+ Options:
17
+ --email <address> Required
18
+ --workspace <name> Required if you admin more than one workspace
19
+ --role member|admin Invitee org role (default: member)
20
+ --auth-url <url> Auth origin (default: derived from --api-url)
21
+ --api-url <url> API origin (default: https://api.uploads.sh)
22
+ --no-open Print the approval URL only
23
+
24
+ Examples:
25
+ uploads invite create --email teammate@example.com
26
+ uploads invite create --workspace acme --email teammate@example.com
27
+ `;
28
+ /** Exported for unit tests. */
29
+ export function resolveInviteWorkspace(workspaces, requested) {
30
+ const adminable = workspaces.filter((w) => w.role === "admin" || w.role === "owner");
31
+ if (requested) {
32
+ const hit = adminable.find((w) => w.workspace === requested);
33
+ if (!hit) {
34
+ const roles = workspaces
35
+ .filter((w) => w.workspace === requested)
36
+ .map((w) => w.role)
37
+ .join(", ");
38
+ if (roles) {
39
+ throw new UsageError(`you are ${roles} on ${requested}, not admin/owner — only workspace admins can invite`);
40
+ }
41
+ throw new UsageError(`no admin access to workspace ${requested}`);
42
+ }
43
+ return hit.workspace;
44
+ }
45
+ if (adminable.length === 1)
46
+ return adminable[0].workspace;
47
+ if (adminable.length === 0) {
48
+ throw new UsageError("your account has no workspace admin access — ask a site operator or existing admin");
49
+ }
50
+ const names = adminable.map((w) => w.workspace).join(", ");
51
+ throw new UsageError(`multiple workspaces you admin (${names}); pass --workspace <name>`);
52
+ }
53
+ export async function runInvite(args, opts, help = false, io = defaultDeviceIo) {
54
+ const parsed = parseCommandArgs(args);
55
+ if (help || parsed.help) {
56
+ process.stderr.write(HELP);
57
+ return 0;
58
+ }
59
+ if (parsed.positionals[0] !== "create") {
60
+ throw new UsageError("expected: uploads invite create");
61
+ }
62
+ const email = flagString(parsed.flags, "--email");
63
+ if (!email)
64
+ throw new UsageError("--email is required");
65
+ const roleRaw = flagString(parsed.flags, "--role") ?? "member";
66
+ if (roleRaw !== "member" && roleRaw !== "admin") {
67
+ throw new UsageError("--role must be member or admin");
68
+ }
69
+ const role = roleRaw;
70
+ const requestedWorkspace = flagString(parsed.flags, "--workspace");
71
+ const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
72
+ const authUrl = resolveAuthUrl(parsed, apiUrl);
73
+ if (flagBool(parsed.flags, "--non-interactive")) {
74
+ throw new UsageError("invite requires a browser for device approval");
75
+ }
76
+ const accessToken = await obtainDeviceAccessToken(authUrl, {
77
+ noOpen: flagBool(parsed.flags, "--no-open"),
78
+ prompt: "To invite as your account, open:",
79
+ }, io);
80
+ const { workspaces } = await listMintWorkspaces(apiUrl, accessToken);
81
+ const workspace = resolveInviteWorkspace(workspaces, requestedWorkspace);
82
+ const result = await createWorkspaceInvite(apiUrl, accessToken, workspace, { email, role });
83
+ const payload = {
84
+ ok: true,
85
+ workspace,
86
+ email: result.invitation.email,
87
+ role: result.invitation.role,
88
+ invitationId: result.invitation.id,
89
+ status: result.invitation.status,
90
+ acceptUrl: result.acceptUrl ?? null,
91
+ };
92
+ if (opts.json)
93
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
94
+ else {
95
+ process.stdout.write(`Invited ${email} to ${workspace} as ${role} (${result.invitation.status}).\n`);
96
+ if (result.acceptUrl) {
97
+ process.stdout.write(`Accept link (share if email isn't configured):\n ${result.acceptUrl}\n`);
98
+ }
99
+ process.stdout.write("They accept, then run: uploads login\n");
100
+ }
101
+ return 0;
102
+ }
@@ -5,7 +5,35 @@ export declare function resolveEnrollmentCode(parsed: ReturnType<typeof parseCom
5
5
  readLine: () => Promise<string>;
6
6
  hiddenPrompt: () => Promise<string>;
7
7
  }): Promise<string>;
8
+ /**
9
+ * Auth worker base URL: explicit flag > UPLOADS_AUTH_URL > swap an `api.` host
10
+ * label for `auth.` > the production default. Local multi-worker dev (where
11
+ * auth runs on a different loopback port than the API) needs an explicit
12
+ * --auth-url / UPLOADS_AUTH_URL.
13
+ */
14
+ export declare function resolveAuthUrl(parsed: ReturnType<typeof parseCommandArgs>, apiUrl: string): string;
15
+ export interface DeviceLoginIo {
16
+ sleep: (ms: number) => Promise<void>;
17
+ now: () => number;
18
+ openUrl: (url: string) => void;
19
+ write: (text: string) => void;
20
+ }
21
+ export declare const defaultDeviceIo: DeviceLoginIo;
22
+ /**
23
+ * Browser device-authorization session only (no workspace token mint).
24
+ * Shared by `uploads login` and `uploads invite create`.
25
+ */
26
+ export declare function obtainDeviceAccessToken(authUrl: string, opts?: {
27
+ noOpen?: boolean;
28
+ prompt?: string;
29
+ }, io?: DeviceLoginIo): Promise<string>;
30
+ /** Poll device/token honoring interval / slow_down / pending until approved or expired. */
31
+ export declare function pollForDeviceToken(authUrl: string, code: {
32
+ device_code: string;
33
+ interval: number;
34
+ expires_in: number;
35
+ }, io: DeviceLoginIo): Promise<string>;
8
36
  export declare function runLogin(args: string[], opts: {
9
37
  json?: boolean;
10
38
  apiUrl?: string;
11
- }, help?: boolean): Promise<number>;
39
+ }, help?: boolean, deviceIo?: DeviceLoginIo): Promise<number>;