@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.
@@ -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
+ }
@@ -18,6 +18,21 @@ export interface DeviceLoginIo {
18
18
  openUrl: (url: string) => void;
19
19
  write: (text: string) => void;
20
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>;
21
36
  export declare function runLogin(args: string[], opts: {
22
37
  json?: boolean;
23
38
  apiUrl?: string;
@@ -157,7 +157,7 @@ function openUrl(url) {
157
157
  // ignore — the URL is printed for manual navigation.
158
158
  }
159
159
  }
160
- const defaultDeviceIo = {
160
+ export const defaultDeviceIo = {
161
161
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
162
162
  now: () => Date.now(),
163
163
  openUrl,
@@ -165,6 +165,20 @@ const defaultDeviceIo = {
165
165
  process.stderr.write(text);
166
166
  },
167
167
  };
168
+ /**
169
+ * Browser device-authorization session only (no workspace token mint).
170
+ * Shared by `uploads login` and `uploads invite create`.
171
+ */
172
+ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDeviceIo) {
173
+ const code = await requestDeviceCode(authUrl);
174
+ const verifyUrl = code.verification_uri_complete ?? code.verification_uri;
175
+ const prompt = opts.prompt ?? "To sign in, open:";
176
+ io.write(`${prompt}\n\n ${verifyUrl}\n\nand confirm this code:\n\n ${code.user_code}\n\n`);
177
+ if (!opts.noOpen)
178
+ io.openUrl(verifyUrl);
179
+ io.write("Waiting for approval…\n");
180
+ return pollForDeviceToken(authUrl, code, io);
181
+ }
168
182
  /**
169
183
  * Device-authorization login (RFC 8628): request a code, have the user approve
170
184
  * it in a browser, poll for the session token, then mint a workspace token.
@@ -173,13 +187,7 @@ async function runDeviceLogin(parsed, opts, io) {
173
187
  const scopes = parseScopes(flagString(parsed.flags, "--scopes"));
174
188
  const label = flagString(parsed.flags, "--label") ?? safeHostname();
175
189
  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);
190
+ const accessToken = await obtainDeviceAccessToken(opts.authUrl, { noOpen: opts.noOpen }, io);
183
191
  const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace);
184
192
  const minted = await mintWorkspaceToken(opts.apiUrl, accessToken, { workspace, scopes, label });
185
193
  return { workspace: minted.workspace, token: minted.token, apiUrl: opts.apiUrl };
@@ -193,7 +201,7 @@ function safeHostname() {
193
201
  }
194
202
  }
195
203
  /** Poll device/token honoring interval / slow_down / pending until approved or expired. */
196
- async function pollForDeviceToken(authUrl, code, io) {
204
+ export async function pollForDeviceToken(authUrl, code, io) {
197
205
  let intervalMs = Math.max(1, code.interval) * 1000;
198
206
  const deadline = io.now() + Math.max(1, code.expires_in) * 1000;
199
207
  while (io.now() < deadline) {
@@ -13,6 +13,8 @@ export interface CliContext {
13
13
  quiet: boolean;
14
14
  envFile?: string;
15
15
  }
16
+ /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
17
+ export declare function readFileArg(fileArg: string): Uint8Array;
16
18
  /**
17
19
  * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
18
20
  * neither is present. Shared by the CLI flags and the MCP tool arguments.
@@ -43,6 +45,8 @@ export declare function runAttach(ctx: CliContext, args: string[], help?: boolea
43
45
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
44
46
  export declare function runGallery(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
45
47
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
48
+ export declare function runFind(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
49
+ export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
46
50
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
47
51
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
48
52
  export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;