@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/README.md +14 -3
- package/dist/cli-args.d.ts +17 -2
- package/dist/cli-args.js +50 -5
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +68 -25
- package/dist/client.d.ts +137 -0
- package/dist/client.js +178 -3
- package/dist/commands/install.js +110 -52
- package/dist/commands/invite.d.ts +10 -0
- package/dist/commands/invite.js +102 -0
- package/dist/commands/login.d.ts +29 -1
- package/dist/commands/login.js +172 -12
- package/dist/commands/setup.js +4 -2
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +211 -17
- package/dist/config.js +6 -3
- package/dist/errors.d.ts +1 -1
- package/dist/github.d.ts +17 -1
- package/dist/github.js +32 -7
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/mcp/args.d.ts +17 -0
- package/dist/mcp/args.js +42 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.js +121 -21
- package/dist/metadata.d.ts +32 -0
- package/dist/metadata.js +102 -0
- package/dist/public-urls.d.ts +18 -0
- package/dist/public-urls.js +68 -0
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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), {
|
package/dist/commands/install.js
CHANGED
|
@@ -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
|
|
17
|
-
skill npx
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
177
|
+
process.stdout.write(JSON.stringify({ ok: !failed, steps }, null, 2) + "\n");
|
|
115
178
|
return failed ? 1 : 0;
|
|
116
179
|
}
|
|
117
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -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>;
|