@buildinternet/uploads 0.5.0 → 0.7.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.
@@ -19,6 +19,11 @@ Options:
19
19
  --separate-code Two-channel output: non-secret page URL + separate code
20
20
  --api-url <url> Default: https://api.uploads.sh
21
21
  --web-url <url> Invite-page origin (defaults from --api-url)
22
+
23
+ Examples:
24
+ uploads admin invite create --workspace acme --email user@example.com
25
+ uploads admin invite create --workspace acme --separate-code --json
26
+ uploads admin invite create --admin-token $ADMIN_TOKEN --workspace acme --label "onboarding"
22
27
  `;
23
28
  const FILE_SCOPES = new Set(["files:read", "files:write", "files:delete"]);
24
29
  export function invitePageUrl(apiUrl, pageId, webUrl) {
@@ -5,7 +5,20 @@ 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
+ }
8
21
  export declare function runLogin(args: string[], opts: {
9
22
  json?: boolean;
10
23
  apiUrl?: string;
11
- }, help?: boolean): Promise<number>;
24
+ }, help?: boolean, deviceIo?: DeviceLoginIo): Promise<number>;
@@ -1,20 +1,37 @@
1
+ import { hostname } from "node:os";
2
+ import { spawn } from "node:child_process";
1
3
  import { stdin, stdout } from "node:process";
2
4
  import { loadConfigFile, redactToken, resolveConfigPath, writeConfigKeys, workspaceFromToken, } from "../config.js";
3
- import { exchangeEnrollment, createUploadsClient } from "../client.js";
5
+ import { createUploadsClient, exchangeEnrollment, listMintWorkspaces, mintWorkspaceToken, requestDeviceCode, requestDeviceToken, } from "../client.js";
4
6
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
7
+ import { parseScopes } from "./admin-enrollment.js";
5
8
  const HELP = `uploads login [options]
6
9
 
7
- Exchange a one-time enrollment code for workspace credentials, save them, and
8
- verify access. Ask your uploads.sh administrator for an enrollment code.
10
+ Sign in and save workspace credentials. With no flags, opens a browser to
11
+ authorize this device the recommended way to sign in. Pass an enrollment
12
+ code only if you were given one from before device login (fallback path).
9
13
 
10
14
  Options:
11
- --code <code> Code in argv (may be visible in shell history/process lists)
12
- --code-stdin Read one line from stdin
15
+ --workspace <name> Workspace to mint a token for (device flow; required if
16
+ your account can access more than one)
17
+ --scopes <list> Comma-separated scopes (default: files:read,files:write)
18
+ --label <text> Token label (default: this machine's hostname)
19
+ --auth-url <url> Auth base (default: https://auth.uploads.sh)
20
+ --no-open Don't try to open a browser automatically
21
+ --code <code> Fallback: use a pre-existing enrollment code instead of
22
+ device login (visible in shell history)
23
+ --code-stdin Fallback: read a pre-existing enrollment code from stdin
13
24
  --non-interactive Never prompt
14
25
  --api-url <url> API base (default: https://api.uploads.sh)
15
26
  --path <file> Config destination
16
27
  --force Replace existing saved credentials
17
28
  --no-check Skip doctor verification
29
+
30
+ Examples:
31
+ uploads login
32
+ uploads login --workspace acme
33
+ uploads login --code upe_… --force # fallback: pre-existing invite
34
+ printf '%s' upe_… | uploads login --code-stdin --non-interactive
18
35
  `;
19
36
  export function validateEnrollmentCode(raw) {
20
37
  const code = raw.trim();
@@ -98,7 +115,135 @@ export async function resolveEnrollmentCode(parsed, io = {
98
115
  return validateEnrollmentCode(await io.readLine());
99
116
  return validateEnrollmentCode(await io.hiddenPrompt());
100
117
  }
101
- export async function runLogin(args, opts, help = false) {
118
+ /** True when the caller supplied an enrollment code (via flag, stdin, or env). */
119
+ function hasEnrollmentSource(parsed) {
120
+ return (Boolean(flagString(parsed.flags, "--code")) ||
121
+ flagBool(parsed.flags, "--code-stdin") ||
122
+ Boolean(process.env.UPLOADS_ENROLLMENT_CODE));
123
+ }
124
+ /**
125
+ * Auth worker base URL: explicit flag > UPLOADS_AUTH_URL > swap an `api.` host
126
+ * label for `auth.` > the production default. Local multi-worker dev (where
127
+ * auth runs on a different loopback port than the API) needs an explicit
128
+ * --auth-url / UPLOADS_AUTH_URL.
129
+ */
130
+ export function resolveAuthUrl(parsed, apiUrl) {
131
+ const explicit = flagString(parsed.flags, "--auth-url") ?? process.env.UPLOADS_AUTH_URL;
132
+ if (explicit)
133
+ return explicit.replace(/\/$/, "");
134
+ try {
135
+ const url = new URL(apiUrl);
136
+ if (url.hostname.startsWith("api.")) {
137
+ url.hostname = `auth.${url.hostname.slice(4)}`;
138
+ return url.origin;
139
+ }
140
+ }
141
+ catch {
142
+ // fall through to the default
143
+ }
144
+ return "https://auth.uploads.sh";
145
+ }
146
+ /** Best-effort browser open. The URL is always printed too, so failures are silent. */
147
+ function openUrl(url) {
148
+ try {
149
+ const isWin = process.platform === "win32";
150
+ const command = process.platform === "darwin" ? "open" : isWin ? "cmd" : "xdg-open";
151
+ const args = isWin ? ["/c", "start", "", url] : [url];
152
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
153
+ child.on("error", () => { });
154
+ child.unref();
155
+ }
156
+ catch {
157
+ // ignore — the URL is printed for manual navigation.
158
+ }
159
+ }
160
+ const defaultDeviceIo = {
161
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
162
+ now: () => Date.now(),
163
+ openUrl,
164
+ write: (text) => {
165
+ process.stderr.write(text);
166
+ },
167
+ };
168
+ /**
169
+ * Device-authorization login (RFC 8628): request a code, have the user approve
170
+ * it in a browser, poll for the session token, then mint a workspace token.
171
+ */
172
+ async function runDeviceLogin(parsed, opts, io) {
173
+ const scopes = parseScopes(flagString(parsed.flags, "--scopes"));
174
+ const label = flagString(parsed.flags, "--label") ?? safeHostname();
175
+ const requestedWorkspace = flagString(parsed.flags, "--workspace");
176
+ const code = await requestDeviceCode(opts.authUrl);
177
+ const verifyUrl = code.verification_uri_complete ?? code.verification_uri;
178
+ io.write(`To sign in, open:\n\n ${verifyUrl}\n\nand confirm this code:\n\n ${code.user_code}\n\n`);
179
+ if (!opts.noOpen)
180
+ io.openUrl(verifyUrl);
181
+ io.write("Waiting for approval…\n");
182
+ const accessToken = await pollForDeviceToken(opts.authUrl, code, io);
183
+ const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace);
184
+ const minted = await mintWorkspaceToken(opts.apiUrl, accessToken, { workspace, scopes, label });
185
+ return { workspace: minted.workspace, token: minted.token, apiUrl: opts.apiUrl };
186
+ }
187
+ function safeHostname() {
188
+ try {
189
+ return hostname() || "cli";
190
+ }
191
+ catch {
192
+ return "cli";
193
+ }
194
+ }
195
+ /** Poll device/token honoring interval / slow_down / pending until approved or expired. */
196
+ async function pollForDeviceToken(authUrl, code, io) {
197
+ let intervalMs = Math.max(1, code.interval) * 1000;
198
+ const deadline = io.now() + Math.max(1, code.expires_in) * 1000;
199
+ while (io.now() < deadline) {
200
+ await io.sleep(intervalMs);
201
+ let result;
202
+ try {
203
+ result = await requestDeviceToken(authUrl, { deviceCode: code.device_code });
204
+ }
205
+ catch {
206
+ // A transient network blip mid-poll shouldn't abort a login the user may
207
+ // already have approved — keep polling until the device code's deadline.
208
+ continue;
209
+ }
210
+ switch (result.status) {
211
+ case "ok":
212
+ return result.accessToken;
213
+ case "pending":
214
+ continue;
215
+ case "slow_down":
216
+ // RFC 8628 §3.5: back off by 5s and keep polling.
217
+ intervalMs += 5000;
218
+ continue;
219
+ case "denied":
220
+ throw new UsageError("device authorization was denied");
221
+ case "expired":
222
+ throw new UsageError("the device code expired before it was approved");
223
+ default:
224
+ throw new UsageError(`device authorization failed: ${result.error}${result.description ? ` — ${result.description}` : ""}`);
225
+ }
226
+ }
227
+ throw new UsageError("timed out waiting for device authorization");
228
+ }
229
+ /**
230
+ * Pick the workspace to mint for. An explicit --workspace wins; otherwise, if
231
+ * the account can access exactly one workspace, use it — and if it can access
232
+ * several, require the flag rather than guessing.
233
+ */
234
+ async function resolveMintWorkspace(apiUrl, accessToken, requested) {
235
+ if (requested)
236
+ return requested;
237
+ const { workspaces } = await listMintWorkspaces(apiUrl, accessToken);
238
+ if (workspaces.length === 1)
239
+ return workspaces[0].workspace;
240
+ if (workspaces.length === 0) {
241
+ throw new UsageError("your account has no workspace access yet — ask an administrator for an invitation");
242
+ }
243
+ const names = workspaces.map((w) => w.workspace).join(", ");
244
+ throw new UsageError(`multiple workspaces available (${names}); pass --workspace <name>`);
245
+ }
246
+ export async function runLogin(args, opts, help = false, deviceIo = defaultDeviceIo) {
102
247
  const parsed = parseCommandArgs(args);
103
248
  if (help || parsed.help) {
104
249
  process.stderr.write(HELP);
@@ -112,11 +257,24 @@ export async function runLogin(args, opts, help = false) {
112
257
  throw new UsageError(`credentials already exist in ${path}; use --force to replace them`);
113
258
  if (process.env.UPLOADS_TOKEN && !force)
114
259
  throw new UsageError("UPLOADS_TOKEN is already set in the environment; unset it or use --force");
115
- const code = await resolveEnrollmentCode(parsed);
116
- const result = await exchangeEnrollment(apiUrl, code);
260
+ let result;
261
+ if (hasEnrollmentSource(parsed)) {
262
+ const code = await resolveEnrollmentCode(parsed);
263
+ result = await exchangeEnrollment(apiUrl, code);
264
+ }
265
+ else {
266
+ // The device flow is inherently interactive (browser approval, then a poll
267
+ // that runs to the device code's ~30-min deadline). Fail fast rather than
268
+ // hanging a non-interactive/CI invocation that has no code to fall back on.
269
+ if (flagBool(parsed.flags, "--non-interactive")) {
270
+ throw new UsageError("device login requires a browser; run interactively, or pass --code for the enrollment path");
271
+ }
272
+ const authUrl = resolveAuthUrl(parsed, apiUrl);
273
+ result = await runDeviceLogin(parsed, { apiUrl, authUrl, noOpen: flagBool(parsed.flags, "--no-open") }, deviceIo);
274
+ }
117
275
  const encoded = workspaceFromToken(result.token);
118
276
  if (!encoded || encoded !== result.workspace || /[\r\n]/.test(result.token))
119
- throw new UsageError("enrollment returned invalid credentials");
277
+ throw new UsageError("login returned invalid credentials");
120
278
  const savedApiUrl = result.apiUrl ?? apiUrl;
121
279
  const write = writeConfigKeys(path, {
122
280
  UPLOADS_API_URL: savedApiUrl,
@@ -1,8 +1,8 @@
1
- import { createRequire } from "node:module";
2
1
  import { parseCommandArgs } from "../cli-args.js";
3
2
  import { createMcpServer } from "../mcp/server.js";
4
3
  import { serveStdio } from "../mcp/stdio.js";
5
4
  import { createUploadsMcpTools } from "../mcp/tools.js";
5
+ import { packageVersion } from "../package-version.js";
6
6
  const MCP_HELP = `uploads [globals] mcp
7
7
 
8
8
  Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
@@ -22,16 +22,13 @@ Examples:
22
22
  uploads --env-file .env mcp
23
23
  uploads --token up_default_… mcp
24
24
  `;
25
- // Same relative depth from src/commands/ and dist/commands/, so this works
26
- // both under vitest (src) and at runtime (dist).
27
- const { version } = createRequire(import.meta.url)("../../package.json");
28
25
  export async function runMcp(args, opts, help = false) {
29
26
  if (help || parseCommandArgs(args).help) {
30
27
  process.stderr.write(MCP_HELP);
31
28
  return 0;
32
29
  }
33
30
  const server = createMcpServer({
34
- serverInfo: { name: "uploads", version },
31
+ serverInfo: { name: "uploads", version: packageVersion() },
35
32
  tools: createUploadsMcpTools({ globals: opts.globals }),
36
33
  });
37
34
  await serveStdio(server);
@@ -64,8 +64,10 @@ function formatWizard(status) {
64
64
  lines.push("");
65
65
  if (!status.token) {
66
66
  lines.push("Step 1 — Sign in");
67
- lines.push(" Ask your uploads.sh administrator for a one-time enrollment code, then run:");
67
+ lines.push(" Run:");
68
68
  lines.push(" uploads login");
69
+ lines.push(" This opens a browser to authorize this device.");
70
+ lines.push(" Have a pre-existing enrollment code instead? uploads login --code upe_…");
69
71
  lines.push(" If you already have a bearer token:");
70
72
  lines.push(" uploads setup --token up_<workspace>_…");
71
73
  lines.push("");
@@ -207,7 +209,7 @@ export async function runSetup(args, opts, help = false) {
207
209
  process.stderr.write("hint: run uploads doctor to verify\n");
208
210
  }
209
211
  else {
210
- process.stderr.write("hint: run uploads login with an admin-provided enrollment code\n");
212
+ process.stderr.write("hint: run uploads login (or uploads login --code <code> if you have one)\n");
211
213
  }
212
214
  return doctorOk === false ? 1 : 0;
213
215
  }
@@ -41,6 +41,7 @@ export declare function syncAttachmentsComment(client: UploadsClient, target: Gh
41
41
  }>;
42
42
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
43
43
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
44
+ export declare function runGallery(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
44
45
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
45
46
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
46
47
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
@@ -52,6 +53,8 @@ export declare function runHealth(ctx: Pick<CliContext, "json"> & {
52
53
  }, args: string[], help?: boolean): Promise<number>;
53
54
  export interface DoctorReport {
54
55
  ok: boolean;
56
+ /** Installed @buildinternet/uploads package version. */
57
+ cliVersion: string;
55
58
  apiUrl: string;
56
59
  workspace: string;
57
60
  workspaceSource: ResolvedConfig["workspaceSource"];