@buildinternet/uploads 0.4.0 → 0.6.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 +49 -14
- package/dist/agent.js +1 -1
- package/dist/cli-args.d.ts +2 -0
- package/dist/cli-args.js +5 -0
- package/dist/cli.js +70 -20
- package/dist/client.d.ts +91 -0
- package/dist/client.js +93 -11
- package/dist/commands/admin-enrollment.d.ts +2 -0
- package/dist/commands/admin-enrollment.js +60 -8
- package/dist/commands/login.js +6 -0
- package/dist/commands/mcp.js +2 -5
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +299 -8
- package/dist/github.d.ts +23 -1
- package/dist/github.js +65 -2
- package/dist/index.d.ts +1 -1
- package/dist/mcp/tools.js +140 -2
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +19 -0
- package/dist/provenance.js +1 -15
- package/dist/update-check.d.ts +30 -0
- package/dist/update-check.js +134 -0
- package/package.json +1 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createEnrollment } from "../client.js";
|
|
2
|
-
import { flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
|
|
3
|
-
const HELP = `uploads admin
|
|
2
|
+
import { flagBool, flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
|
|
3
|
+
const HELP = `uploads admin invite create [options]
|
|
4
4
|
|
|
5
|
-
Admin-only: create a short-lived
|
|
5
|
+
Admin-only: create a short-lived invitation for an existing workspace.
|
|
6
|
+
Prints one magic link whose URL fragment carries the single-use code — treat the
|
|
7
|
+
link like a password. Pass --separate-code for the legacy two-channel output (a
|
|
8
|
+
non-secret page URL plus a code you share separately). The legacy
|
|
9
|
+
"admin enrollment create" spelling is accepted.
|
|
6
10
|
|
|
7
11
|
Options:
|
|
8
12
|
--admin-token <token> Or ADMIN_TOKEN (UPLOADS_ADMIN_TOKEN is a legacy alias)
|
|
@@ -11,9 +15,39 @@ Options:
|
|
|
11
15
|
--expires-in <seconds> Default: server policy
|
|
12
16
|
--token-expires-in <seconds> Upload token lifetime (default: server policy)
|
|
13
17
|
--scopes <list> Comma-separated files:read,files:write,files:delete
|
|
18
|
+
--email <address> Email the invite link to this recipient (from invites@uploads.sh)
|
|
19
|
+
--separate-code Two-channel output: non-secret page URL + separate code
|
|
14
20
|
--api-url <url> Default: https://api.uploads.sh
|
|
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"
|
|
15
27
|
`;
|
|
16
28
|
const FILE_SCOPES = new Set(["files:read", "files:write", "files:delete"]);
|
|
29
|
+
export function invitePageUrl(apiUrl, pageId, webUrl) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(webUrl ?? apiUrl);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new UsageError("invalid invite web URL");
|
|
36
|
+
}
|
|
37
|
+
if (!webUrl && url.hostname.startsWith("api."))
|
|
38
|
+
url.hostname = url.hostname.slice(4);
|
|
39
|
+
url.pathname = "/invite";
|
|
40
|
+
url.search = "";
|
|
41
|
+
url.hash = "";
|
|
42
|
+
url.searchParams.set("id", pageId);
|
|
43
|
+
return url.toString();
|
|
44
|
+
}
|
|
45
|
+
// Compose the self-contained magic link. The one-time code rides in the URL
|
|
46
|
+
// fragment (#code=…), which browsers never send to the server, so opening the
|
|
47
|
+
// page neither leaks nor consumes it — only the CLI's exchange call redeems it.
|
|
48
|
+
export function inviteMagicLink(pageUrl, code) {
|
|
49
|
+
return `${pageUrl}#code=${encodeURIComponent(code)}`;
|
|
50
|
+
}
|
|
17
51
|
export function parseScopes(raw) {
|
|
18
52
|
if (raw === undefined)
|
|
19
53
|
return undefined;
|
|
@@ -34,26 +68,44 @@ export async function runAdmin(args, opts, help = false) {
|
|
|
34
68
|
process.stderr.write(HELP);
|
|
35
69
|
return 0;
|
|
36
70
|
}
|
|
37
|
-
if (
|
|
38
|
-
|
|
71
|
+
if (!["invite", "enrollment"].includes(parsed.positionals[0] ?? "") ||
|
|
72
|
+
parsed.positionals[1] !== "create")
|
|
73
|
+
throw new UsageError("expected: uploads admin invite create");
|
|
39
74
|
const adminToken = flagString(parsed.flags, "--admin-token") ??
|
|
40
75
|
process.env.ADMIN_TOKEN ??
|
|
41
76
|
process.env.UPLOADS_ADMIN_TOKEN;
|
|
42
77
|
if (!adminToken)
|
|
43
78
|
throw new UsageError("ADMIN_TOKEN is required for admin enrollment creation");
|
|
44
79
|
const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
|
|
80
|
+
const webUrl = flagString(parsed.flags, "--web-url");
|
|
45
81
|
const workspace = flagString(parsed.flags, "--workspace") ?? "default";
|
|
46
82
|
const label = flagString(parsed.flags, "--label");
|
|
83
|
+
const email = flagString(parsed.flags, "--email");
|
|
47
84
|
const result = await createEnrollment(apiUrl, adminToken, {
|
|
48
85
|
workspace,
|
|
49
86
|
label,
|
|
87
|
+
email,
|
|
50
88
|
enrollmentSeconds: flagInt(parsed.flags, "--expires-in", "--expires-in"),
|
|
51
89
|
tokenExpiresInSeconds: flagInt(parsed.flags, "--token-expires-in", "--token-expires-in"),
|
|
52
90
|
scopes: parseScopes(flagString(parsed.flags, "--scopes")),
|
|
53
91
|
});
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
const separateCode = flagBool(parsed.flags, "--separate-code");
|
|
93
|
+
const pageUrl = invitePageUrl(apiUrl, result.pageId, webUrl);
|
|
94
|
+
const link = separateCode ? pageUrl : inviteMagicLink(pageUrl, result.code);
|
|
95
|
+
const footer = `workspace: ${workspace}\nexpires: ${result.expiresAt}\n`;
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
process.stdout.write(JSON.stringify({ workspace, label: label ?? null, url: link, ...result }, null, 2) + "\n");
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
if (email && result.emailed) {
|
|
101
|
+
process.stdout.write(`Invite emailed to ${email}\n${footer}`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (email && result.emailed === false)
|
|
105
|
+
process.stderr.write("warning: email delivery failed; share this link instead\n");
|
|
106
|
+
if (separateCode)
|
|
107
|
+
process.stdout.write(`Invite page: ${pageUrl}\nOne-time code (share separately): ${result.code}\n${footer}`);
|
|
56
108
|
else
|
|
57
|
-
process.stdout.write(`
|
|
109
|
+
process.stdout.write(`Invite link (contains the one-time code — treat like a password):\n${link}\n${footer}`);
|
|
58
110
|
return 0;
|
|
59
111
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -15,6 +15,12 @@ Options:
|
|
|
15
15
|
--path <file> Config destination
|
|
16
16
|
--force Replace existing saved credentials
|
|
17
17
|
--no-check Skip doctor verification
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
uploads login --code upe_…
|
|
21
|
+
uploads login --code-stdin --non-interactive < code.txt
|
|
22
|
+
printf '%s' upe_… | uploads login --code-stdin --non-interactive
|
|
23
|
+
uploads login --code upe_… --force --no-check
|
|
18
24
|
`;
|
|
19
25
|
export function validateEnrollmentCode(raw) {
|
|
20
26
|
const code = raw.trim();
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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);
|
package/dist/commands.d.ts
CHANGED
|
@@ -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"];
|
package/dist/commands.js
CHANGED
|
@@ -6,12 +6,13 @@ import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./co
|
|
|
6
6
|
import { buildMarkdown } from "./embed.js";
|
|
7
7
|
import { UploadsError } from "./errors.js";
|
|
8
8
|
import { writeJson, writeStdout } from "./io.js";
|
|
9
|
-
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
|
|
9
|
+
import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
|
|
10
10
|
import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
|
|
11
11
|
import { resolvePutPrefix } from "./destinations.js";
|
|
12
12
|
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
13
13
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
14
14
|
import { buildCliProvenance } from "./provenance.js";
|
|
15
|
+
import { packageVersion } from "./package-version.js";
|
|
15
16
|
// --- put ---
|
|
16
17
|
const PUT_HELP = `uploads put <file> [options]
|
|
17
18
|
|
|
@@ -25,6 +26,10 @@ upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
|
25
26
|
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
26
27
|
(default off). See: uploads put --help frames
|
|
27
28
|
|
|
29
|
+
Uploads are public. --pr/--issue keys include the repo, number, and filename and
|
|
30
|
+
remain public even for private/internal GitHub repositories. Upload only media
|
|
31
|
+
that is safe at a predictable public URL.
|
|
32
|
+
|
|
28
33
|
Options:
|
|
29
34
|
--key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
|
|
30
35
|
--destination <id> Typed root: screenshots | gh | f (sets --prefix)
|
|
@@ -46,13 +51,15 @@ Options:
|
|
|
46
51
|
--format human|url|markdown|json
|
|
47
52
|
--pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
|
|
48
53
|
--issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
|
|
49
|
-
--comment With --pr/--issue:
|
|
54
|
+
--comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
|
|
55
|
+
--gallery <id> Add the uploaded object to this public gallery
|
|
50
56
|
|
|
51
57
|
Examples:
|
|
52
58
|
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
|
|
53
59
|
uploads put ./mobile.png --frame phone
|
|
54
60
|
uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
|
|
55
61
|
uploads put ./shot.png --destination screenshots
|
|
62
|
+
uploads put ./after.png --gallery gal_example
|
|
56
63
|
`;
|
|
57
64
|
/**
|
|
58
65
|
* Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
|
|
@@ -154,11 +161,39 @@ function frameOptionsFromFlags(flags) {
|
|
|
154
161
|
*/
|
|
155
162
|
export async function syncAttachmentsComment(client, target, run) {
|
|
156
163
|
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
|
|
157
|
-
|
|
164
|
+
const galleries = [];
|
|
165
|
+
let cursor;
|
|
166
|
+
do {
|
|
167
|
+
const page = await client.findGalleriesByReference({
|
|
168
|
+
provider: "github",
|
|
169
|
+
// GitHub references intentionally do not distinguish PRs from issues.
|
|
170
|
+
coordinate: `${target.repo.toLowerCase()}#${target.num}`,
|
|
171
|
+
cursor,
|
|
172
|
+
});
|
|
173
|
+
galleries.push(...page.galleries.map(({ id, title, url }) => ({ title, url, id })));
|
|
174
|
+
cursor = page.nextCursor ?? undefined;
|
|
175
|
+
} while (cursor);
|
|
176
|
+
const previewGalleries = await Promise.all(galleries.map(async ({ id, ...gallery }) => {
|
|
177
|
+
try {
|
|
178
|
+
const detail = await client.getGallery(id);
|
|
179
|
+
return {
|
|
180
|
+
...gallery,
|
|
181
|
+
previews: detail.items
|
|
182
|
+
.filter((item) => item.status === "available" && item.url && item.contentType?.startsWith("image/"))
|
|
183
|
+
.slice(0, 3)
|
|
184
|
+
.map((item) => ({ url: item.url, alt: item.altText ?? item.objectKey })),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// A deleted or temporarily unavailable gallery still gets a safe title link.
|
|
189
|
+
return gallery;
|
|
190
|
+
}
|
|
191
|
+
}));
|
|
192
|
+
if (items.length === 0 && previewGalleries.length === 0)
|
|
158
193
|
return { action: "skipped", count: 0 };
|
|
159
|
-
const body = attachmentsCommentBody(items);
|
|
194
|
+
const body = attachmentsCommentBody(items, previewGalleries);
|
|
160
195
|
const { created } = upsertAttachmentsComment(target, body, run);
|
|
161
|
-
return { action: created ? "created" : "updated", count: items.length };
|
|
196
|
+
return { action: created ? "created" : "updated", count: items.length + previewGalleries.length };
|
|
162
197
|
}
|
|
163
198
|
// --- attach ---
|
|
164
199
|
const ATTACH_HELP = `uploads attach <file...> [options]
|
|
@@ -166,6 +201,10 @@ const ATTACH_HELP = `uploads attach <file...> [options]
|
|
|
166
201
|
Upload one or more stable PR/issue attachments and maintain a single GitHub
|
|
167
202
|
comment. With no target, uses the pull request for the current branch.
|
|
168
203
|
|
|
204
|
+
Attachments are public and their repo/number/filename keys are predictable.
|
|
205
|
+
Private/internal GitHub repository visibility does not restrict access; upload
|
|
206
|
+
only media that is safe at a public URL.
|
|
207
|
+
|
|
169
208
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
170
209
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
171
210
|
|
|
@@ -294,6 +333,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
294
333
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
295
334
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
296
335
|
const wantComment = parsed.flags.has("--comment");
|
|
336
|
+
const galleryId = flagString(parsed.flags, "--gallery");
|
|
297
337
|
if (wantComment && typeof parsed.flags.get("--comment") === "string") {
|
|
298
338
|
throw new UsageError("--comment takes no value — place it after the file argument");
|
|
299
339
|
}
|
|
@@ -378,6 +418,22 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
378
418
|
}),
|
|
379
419
|
});
|
|
380
420
|
const markdown = buildMarkdown(result.url, { alt, width });
|
|
421
|
+
let gallery;
|
|
422
|
+
if (galleryId) {
|
|
423
|
+
try {
|
|
424
|
+
// Gallery mutations use optimistic versions. Fetch immediately before this
|
|
425
|
+
// mutation so `put --gallery` composes safely with other CLI writers.
|
|
426
|
+
const current = await ctx.client.getGallery(galleryId);
|
|
427
|
+
const item = await ctx.client.addGalleryItem(galleryId, result.key, {
|
|
428
|
+
expectedVersion: current.version,
|
|
429
|
+
altText: alt,
|
|
430
|
+
});
|
|
431
|
+
gallery = { id: galleryId, url: current.url, item };
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
gallery = { id: galleryId, error: galleryError(err) };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
381
437
|
const optimizeMeta = {
|
|
382
438
|
optimized: prepared.optimized,
|
|
383
439
|
skippedReason: prepared.skippedReason,
|
|
@@ -390,7 +446,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
390
446
|
}
|
|
391
447
|
switch (format) {
|
|
392
448
|
case "json":
|
|
393
|
-
await writeJson({
|
|
449
|
+
await writeJson({
|
|
450
|
+
...result,
|
|
451
|
+
markdown,
|
|
452
|
+
optimize: optimizeMeta,
|
|
453
|
+
frame: prepared.frame,
|
|
454
|
+
gallery,
|
|
455
|
+
});
|
|
394
456
|
break;
|
|
395
457
|
case "url":
|
|
396
458
|
await writeStdout(`${result.url}\n`);
|
|
@@ -399,7 +461,13 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
399
461
|
await writeStdout(`${markdown}\n`);
|
|
400
462
|
break;
|
|
401
463
|
default:
|
|
402
|
-
await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}\n`);
|
|
464
|
+
await writeStdout(`URL: ${result.url}\nMARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
|
|
465
|
+
}
|
|
466
|
+
if (gallery?.url && format !== "human") {
|
|
467
|
+
process.stderr.write(`gallery: ${gallery.url}\n`);
|
|
468
|
+
}
|
|
469
|
+
if (gallery?.error) {
|
|
470
|
+
process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
|
|
403
471
|
}
|
|
404
472
|
if (wantComment && ghTarget) {
|
|
405
473
|
try {
|
|
@@ -413,7 +481,222 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
413
481
|
process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
414
482
|
}
|
|
415
483
|
}
|
|
416
|
-
return 0;
|
|
484
|
+
return gallery?.error ? 1 : 0;
|
|
485
|
+
}
|
|
486
|
+
function galleryError(err) {
|
|
487
|
+
if (err instanceof UploadsError)
|
|
488
|
+
return { message: err.message, code: err.code, status: err.status };
|
|
489
|
+
return { message: err instanceof Error ? err.message : String(err) };
|
|
490
|
+
}
|
|
491
|
+
// --- galleries ---
|
|
492
|
+
const GALLERY_HELP = `uploads gallery <command> [args]
|
|
493
|
+
|
|
494
|
+
Public galleries can be viewed by anyone who knows the URL. Do not add sensitive media.
|
|
495
|
+
Deleting a gallery only removes the gallery record; it never deletes its uploaded objects.
|
|
496
|
+
|
|
497
|
+
Commands:
|
|
498
|
+
create --title <title> [--description <text>]
|
|
499
|
+
show <gallery-id>
|
|
500
|
+
list [--limit <n>] [--cursor <c>] [--all]
|
|
501
|
+
delete <gallery-id>
|
|
502
|
+
add <gallery-id> <object-key...> [--caption <text>] [--alt <text>]
|
|
503
|
+
link <gallery-id> --github <owner/repo#number|github-url>
|
|
504
|
+
unlink <gallery-id> --github <owner/repo#number|github-url>
|
|
505
|
+
list --github <owner/repo#number|github-url> [--limit <n>] [--cursor <c>] [--all]
|
|
506
|
+
|
|
507
|
+
Examples:
|
|
508
|
+
uploads gallery create --title "Settings redesign"
|
|
509
|
+
uploads gallery add gal_example screenshots/app/after.webp --alt "Updated settings page"
|
|
510
|
+
uploads gallery show gal_example
|
|
511
|
+
uploads gallery link gal_example --github buildinternet/uploads#58
|
|
512
|
+
uploads gallery list --github https://github.com/buildinternet/uploads/pull/58
|
|
513
|
+
`;
|
|
514
|
+
function githubCoordinateFromFlags(flags) {
|
|
515
|
+
const value = flagString(flags, "--github");
|
|
516
|
+
if (!value)
|
|
517
|
+
throw new UsageError("--github requires an owner/repo#number coordinate or GitHub issue/PR URL");
|
|
518
|
+
const normalized = normalizeGithubCoordinate(value);
|
|
519
|
+
if (!normalized)
|
|
520
|
+
throw new UsageError("--github must be owner/repo#number or an https://github.com/.../issues|pull/number URL");
|
|
521
|
+
return normalized.coordinate;
|
|
522
|
+
}
|
|
523
|
+
export async function runGallery(ctx, args, help = false) {
|
|
524
|
+
const parsed = parseCommandArgs(args);
|
|
525
|
+
const action = parsed.positionals[0];
|
|
526
|
+
if (help || parsed.help || !action) {
|
|
527
|
+
process.stderr.write(GALLERY_HELP);
|
|
528
|
+
return help || parsed.help ? 0 : 2;
|
|
529
|
+
}
|
|
530
|
+
switch (action) {
|
|
531
|
+
case "create": {
|
|
532
|
+
const title = flagString(parsed.flags, "--title");
|
|
533
|
+
if (!title)
|
|
534
|
+
throw new UsageError("gallery create requires --title");
|
|
535
|
+
const gallery = await ctx.client.createGallery({
|
|
536
|
+
title,
|
|
537
|
+
description: flagString(parsed.flags, "--description"),
|
|
538
|
+
});
|
|
539
|
+
if (ctx.json)
|
|
540
|
+
await writeJson(gallery);
|
|
541
|
+
else
|
|
542
|
+
await writeStdout(`${gallery.url}\n`);
|
|
543
|
+
if (!ctx.quiet && !ctx.json)
|
|
544
|
+
process.stderr.write("warning: galleries are public to anyone with the URL\n");
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
547
|
+
case "show": {
|
|
548
|
+
const id = parsed.positionals[1];
|
|
549
|
+
if (!id)
|
|
550
|
+
throw new UsageError("gallery show requires a gallery ID");
|
|
551
|
+
const gallery = await ctx.client.getGallery(id);
|
|
552
|
+
if (ctx.json)
|
|
553
|
+
await writeJson(gallery);
|
|
554
|
+
else
|
|
555
|
+
await writeStdout(`${gallery.url}\n`);
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
case "list": {
|
|
559
|
+
const limit = flagInt(parsed.flags, "--limit", "--limit");
|
|
560
|
+
const cursor = flagString(parsed.flags, "--cursor");
|
|
561
|
+
const github = parsed.flags.has("--github")
|
|
562
|
+
? githubCoordinateFromFlags(parsed.flags)
|
|
563
|
+
: undefined;
|
|
564
|
+
if (flagBool(parsed.flags, "--all")) {
|
|
565
|
+
const galleries = [];
|
|
566
|
+
let nextCursor = cursor;
|
|
567
|
+
do {
|
|
568
|
+
const page = github
|
|
569
|
+
? await ctx.client.findGalleriesByReference({
|
|
570
|
+
provider: "github",
|
|
571
|
+
coordinate: github,
|
|
572
|
+
limit,
|
|
573
|
+
cursor: nextCursor,
|
|
574
|
+
})
|
|
575
|
+
: await ctx.client.listGalleries({ limit, cursor: nextCursor });
|
|
576
|
+
galleries.push(...page.galleries);
|
|
577
|
+
nextCursor = page.nextCursor ?? undefined;
|
|
578
|
+
} while (nextCursor);
|
|
579
|
+
if (ctx.json)
|
|
580
|
+
await writeJson({ galleries, nextCursor: null });
|
|
581
|
+
else
|
|
582
|
+
for (const gallery of galleries)
|
|
583
|
+
await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
const page = github
|
|
587
|
+
? await ctx.client.findGalleriesByReference({
|
|
588
|
+
provider: "github",
|
|
589
|
+
coordinate: github,
|
|
590
|
+
limit,
|
|
591
|
+
cursor,
|
|
592
|
+
})
|
|
593
|
+
: await ctx.client.listGalleries({ limit, cursor });
|
|
594
|
+
if (ctx.json)
|
|
595
|
+
await writeJson(page);
|
|
596
|
+
else {
|
|
597
|
+
for (const gallery of page.galleries)
|
|
598
|
+
await writeStdout(`${gallery.id} ${gallery.url} ${gallery.title}\n`);
|
|
599
|
+
if (page.nextCursor)
|
|
600
|
+
process.stderr.write(`cursor: ${page.nextCursor}\n`);
|
|
601
|
+
}
|
|
602
|
+
return 0;
|
|
603
|
+
}
|
|
604
|
+
case "link": {
|
|
605
|
+
const id = parsed.positionals[1];
|
|
606
|
+
if (!id)
|
|
607
|
+
throw new UsageError("gallery link requires a gallery ID");
|
|
608
|
+
const coordinate = githubCoordinateFromFlags(parsed.flags);
|
|
609
|
+
const current = await ctx.client.getGallery(id);
|
|
610
|
+
const reference = await ctx.client.linkGalleryExternalReference(id, {
|
|
611
|
+
expectedVersion: current.version,
|
|
612
|
+
provider: "github",
|
|
613
|
+
coordinate,
|
|
614
|
+
});
|
|
615
|
+
if (ctx.json)
|
|
616
|
+
await writeJson({ galleryId: id, reference });
|
|
617
|
+
else
|
|
618
|
+
await writeStdout((reference.canonicalUrl ?? reference.coordinate) + "\n");
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
case "unlink": {
|
|
622
|
+
const id = parsed.positionals[1];
|
|
623
|
+
if (!id)
|
|
624
|
+
throw new UsageError("gallery unlink requires a gallery ID");
|
|
625
|
+
const coordinate = githubCoordinateFromFlags(parsed.flags);
|
|
626
|
+
const references = await ctx.client.listGalleryExternalReferences(id);
|
|
627
|
+
const reference = references.references.find((entry) => entry.provider === "github" && entry.coordinate === coordinate);
|
|
628
|
+
if (!reference) {
|
|
629
|
+
const output = { galleryId: id, coordinate, deleted: false };
|
|
630
|
+
if (ctx.json)
|
|
631
|
+
await writeJson(output);
|
|
632
|
+
else if (!ctx.quiet)
|
|
633
|
+
process.stderr.write("GitHub reference was already absent\n");
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
const current = await ctx.client.getGallery(id);
|
|
637
|
+
const result = await ctx.client.unlinkGalleryExternalReference(id, reference.id, {
|
|
638
|
+
expectedVersion: current.version,
|
|
639
|
+
});
|
|
640
|
+
if (ctx.json)
|
|
641
|
+
await writeJson({ galleryId: id, coordinate, ...result });
|
|
642
|
+
else if (!ctx.quiet)
|
|
643
|
+
process.stderr.write("unlinked " + coordinate + "\n");
|
|
644
|
+
return 0;
|
|
645
|
+
}
|
|
646
|
+
case "delete": {
|
|
647
|
+
const id = parsed.positionals[1];
|
|
648
|
+
if (!id)
|
|
649
|
+
throw new UsageError("gallery delete requires a gallery ID");
|
|
650
|
+
const current = await ctx.client.getGallery(id);
|
|
651
|
+
const result = await ctx.client.deleteGallery(id, { expectedVersion: current.version });
|
|
652
|
+
if (ctx.json)
|
|
653
|
+
await writeJson(result);
|
|
654
|
+
else if (!ctx.quiet)
|
|
655
|
+
process.stderr.write(`deleted gallery ${result.id} (objects kept)\n`);
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
case "add": {
|
|
659
|
+
const id = parsed.positionals[1];
|
|
660
|
+
const keys = parsed.positionals.slice(2);
|
|
661
|
+
if (!id || keys.length === 0)
|
|
662
|
+
throw new UsageError("gallery add requires a gallery ID and one or more object keys");
|
|
663
|
+
const caption = flagString(parsed.flags, "--caption");
|
|
664
|
+
const altText = flagString(parsed.flags, "--alt");
|
|
665
|
+
const added = [];
|
|
666
|
+
let galleryUrl;
|
|
667
|
+
const failures = [];
|
|
668
|
+
for (const objectKey of keys) {
|
|
669
|
+
try {
|
|
670
|
+
// Always re-read before the next write: each add increments the version,
|
|
671
|
+
// and this also avoids stale versions after an independent writer.
|
|
672
|
+
const current = await ctx.client.getGallery(id);
|
|
673
|
+
galleryUrl = current.url;
|
|
674
|
+
added.push(await ctx.client.addGalleryItem(id, objectKey, {
|
|
675
|
+
expectedVersion: current.version,
|
|
676
|
+
caption,
|
|
677
|
+
altText,
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
failures.push({ objectKey, error: galleryError(err) });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
|
|
685
|
+
if (ctx.json)
|
|
686
|
+
await writeJson(output);
|
|
687
|
+
else {
|
|
688
|
+
if (galleryUrl)
|
|
689
|
+
await writeStdout(`GALLERY: ${galleryUrl}\n`);
|
|
690
|
+
for (const item of added)
|
|
691
|
+
await writeStdout(`${item.objectKey}\n`);
|
|
692
|
+
for (const failure of failures)
|
|
693
|
+
process.stderr.write(`warning: could not add ${failure.objectKey}: ${failure.error.message}\n`);
|
|
694
|
+
}
|
|
695
|
+
return failures.length === 0 ? 0 : 1;
|
|
696
|
+
}
|
|
697
|
+
default:
|
|
698
|
+
throw new UsageError(`unknown gallery command: ${action}`);
|
|
699
|
+
}
|
|
417
700
|
}
|
|
418
701
|
// --- list ---
|
|
419
702
|
const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
|
|
@@ -466,8 +749,13 @@ export async function runList(ctx, args, help = false, run = execRunner) {
|
|
|
466
749
|
// --- delete ---
|
|
467
750
|
const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
|
|
468
751
|
|
|
752
|
+
Options:
|
|
753
|
+
--dry-run Preview without deleting
|
|
754
|
+
--workspace, -w <name>
|
|
755
|
+
|
|
469
756
|
Examples:
|
|
470
757
|
uploads delete screenshots/myapp/42/shot-a1b2c3.png
|
|
758
|
+
uploads delete screenshots/myapp/42/shot-a1b2c3.png --dry-run
|
|
471
759
|
`;
|
|
472
760
|
export async function runDelete(ctx, args, help = false) {
|
|
473
761
|
const parsed = parseCommandArgs(args);
|
|
@@ -635,6 +923,7 @@ Checks API health, token auth, and workspace/token alignment.
|
|
|
635
923
|
Examples:
|
|
636
924
|
uploads --env-file .env doctor
|
|
637
925
|
uploads --workspace acme --env-file .env doctor
|
|
926
|
+
uploads doctor --json
|
|
638
927
|
`;
|
|
639
928
|
/** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
|
|
640
929
|
export async function buildDoctorReport(config, client) {
|
|
@@ -681,6 +970,7 @@ export async function buildDoctorReport(config, client) {
|
|
|
681
970
|
}
|
|
682
971
|
return {
|
|
683
972
|
ok: health.ok && authOk,
|
|
973
|
+
cliVersion: packageVersion(),
|
|
684
974
|
apiUrl: config.apiUrl,
|
|
685
975
|
workspace: config.workspace,
|
|
686
976
|
workspaceSource: config.workspaceSource,
|
|
@@ -705,6 +995,7 @@ export async function runDoctor(ctx, args, help = false) {
|
|
|
705
995
|
return report.ok ? 0 : 1;
|
|
706
996
|
}
|
|
707
997
|
const lines = [
|
|
998
|
+
`cli: @buildinternet/uploads@${report.cliVersion}`,
|
|
708
999
|
`config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
|
|
709
1000
|
`api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
|
|
710
1001
|
`workspace: ${report.workspace}`,
|
package/dist/github.d.ts
CHANGED
|
@@ -5,9 +5,16 @@ export interface GhTarget {
|
|
|
5
5
|
kind: GhTargetKind;
|
|
6
6
|
num: number;
|
|
7
7
|
}
|
|
8
|
+
/** A normalized GitHub issue/PR coordinate used for gallery references. */
|
|
9
|
+
export interface GithubCoordinate {
|
|
10
|
+
coordinate: string;
|
|
11
|
+
canonicalUrl: string;
|
|
12
|
+
}
|
|
8
13
|
export declare function isValidRepo(repo: string): boolean;
|
|
9
14
|
/** Parse "owner/name" from a git remote URL (SSH or HTTPS), else undefined. */
|
|
10
15
|
export declare function parseRepoFromRemoteUrl(url: string): string | undefined;
|
|
16
|
+
/** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
|
|
17
|
+
export declare function normalizeGithubCoordinate(value: string): GithubCoordinate | undefined;
|
|
11
18
|
export declare function ghKeyPrefix(target: GhTarget): string;
|
|
12
19
|
/**
|
|
13
20
|
* Stable attachment key: same filename → same key → same public URL, so
|
|
@@ -21,6 +28,17 @@ export interface AttachmentItem {
|
|
|
21
28
|
key: string;
|
|
22
29
|
url: string | null;
|
|
23
30
|
}
|
|
31
|
+
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
32
|
+
export interface GalleryCommentItem {
|
|
33
|
+
title: string;
|
|
34
|
+
/** Canonical URL returned by the API; callers must not synthesize it. */
|
|
35
|
+
url: string;
|
|
36
|
+
/** A bounded set of available images, all of which link back to the gallery. */
|
|
37
|
+
previews?: {
|
|
38
|
+
url: string;
|
|
39
|
+
alt: string;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
24
42
|
/** Default max width for images in the managed attachments comment (HTML img). */
|
|
25
43
|
export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
|
|
26
44
|
/** Portrait / device mockups — keep phones readable, not full-column. */
|
|
@@ -32,4 +50,8 @@ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
|
|
|
32
50
|
* practical signal (we don't re-fetch dimensions when rebuilding the comment).
|
|
33
51
|
*/
|
|
34
52
|
export declare function attachmentImageWidth(filename: string): number;
|
|
35
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
55
|
+
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
56
|
+
*/
|
|
57
|
+
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[]): string;
|