@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/dist/github.js CHANGED
@@ -10,6 +10,47 @@ export function parseRepoFromRemoteUrl(url) {
10
10
  const repo = match?.[1];
11
11
  return repo && isValidRepo(repo) ? repo : undefined;
12
12
  }
13
+ /** Normalize a GitHub issue or pull-request coordinate for gallery linking. */
14
+ export function normalizeGithubCoordinate(value) {
15
+ const input = value.trim();
16
+ let match = /^([^/\s#]+)\/([^/\s#]+)#([1-9][0-9]*)$/.exec(input);
17
+ if (!match) {
18
+ try {
19
+ const url = new URL(input);
20
+ if (url.protocol !== "https:" ||
21
+ url.hostname.toLowerCase() !== "github.com" ||
22
+ url.port ||
23
+ url.username ||
24
+ url.password ||
25
+ url.search ||
26
+ url.hash)
27
+ return undefined;
28
+ match = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/([1-9][0-9]*)\/?$/.exec(url.pathname);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ if (!match)
35
+ return undefined;
36
+ const [, ownerRaw, repositoryRaw, numberRaw] = match;
37
+ const repo = ownerRaw + "/" + repositoryRaw;
38
+ const number = Number(numberRaw);
39
+ if (!isValidRepo(repo) || !Number.isSafeInteger(number))
40
+ return undefined;
41
+ const owner = ownerRaw.toLowerCase();
42
+ const repository = repositoryRaw.toLowerCase();
43
+ const coordinate = owner + "/" + repository + "#" + number;
44
+ return {
45
+ coordinate,
46
+ canonicalUrl: "https://github.com/" +
47
+ encodeURIComponent(owner) +
48
+ "/" +
49
+ encodeURIComponent(repository) +
50
+ "/issues/" +
51
+ number,
52
+ };
53
+ }
13
54
  export function ghKeyPrefix(target) {
14
55
  const [owner, name] = target.repo.split("/");
15
56
  return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
@@ -48,9 +89,31 @@ export function attachmentImageWidth(filename) {
48
89
  function escapeHtmlAttr(s) {
49
90
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
50
91
  }
51
- export function attachmentsCommentBody(items) {
92
+ function escapeHtmlText(s) {
93
+ return escapeHtmlAttr(s).replace(/'/g, "&#39;").replace(/>/g, "&gt;");
94
+ }
95
+ /**
96
+ * Render the one marker-owned GitHub comment. When there are no galleries this
97
+ * intentionally preserves the legacy attachment-only body byte-for-byte.
98
+ */
99
+ export function attachmentsCommentBody(items, galleries = []) {
52
100
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
53
- const lines = [ATTACHMENTS_MARKER, "### 📎 Attachments", ""];
101
+ const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
102
+ const lines = [ATTACHMENTS_MARKER];
103
+ if (sortedGalleries.length > 0) {
104
+ lines.push("### 🖼️ Galleries", "");
105
+ for (const gallery of sortedGalleries) {
106
+ const href = escapeHtmlAttr(gallery.url);
107
+ lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
108
+ for (const preview of gallery.previews ?? []) {
109
+ lines.push(`<a href="${href}"><img width="320" alt="${escapeHtmlAttr(preview.alt)}" src="${escapeHtmlAttr(preview.url)}"></a>`);
110
+ }
111
+ lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
112
+ }
113
+ lines.push("");
114
+ }
115
+ if (sorted.length > 0 || sortedGalleries.length === 0)
116
+ lines.push("### 📎 Attachments", "");
54
117
  for (const item of sorted) {
55
118
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
56
119
  if (item.url && inferContentType(name).startsWith("image/")) {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
3
3
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
4
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
5
5
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
6
- export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
7
7
  export { buildCliProvenance } from "./provenance.js";
8
8
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
9
9
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
package/dist/mcp/tools.js CHANGED
@@ -88,6 +88,23 @@ const frameProps = {
88
88
  function ghTargetFromArgs(args, run) {
89
89
  return makeGhTarget(optPosInt(args, "pr"), optPosInt(args, "issue"), optString(args, "repo"), run);
90
90
  }
91
+ function galleryId(args) {
92
+ const id = optString(args, "galleryId");
93
+ if (!id)
94
+ usage("galleryId is required");
95
+ return id;
96
+ }
97
+ function galleryReference(args) {
98
+ const provider = optString(args, "provider");
99
+ const coordinate = optString(args, "coordinate");
100
+ if (!provider)
101
+ usage("provider is required");
102
+ if (!coordinate)
103
+ usage("coordinate is required");
104
+ if (provider !== "github")
105
+ usage("provider must be github");
106
+ return { provider, coordinate };
107
+ }
91
108
  const workspaceProp = {
92
109
  type: "string",
93
110
  description: "Override the workspace for this call (like the CLI's --workspace flag).",
@@ -136,9 +153,130 @@ export function createUploadsMcpTools(opts) {
136
153
  return { comment, commentError };
137
154
  };
138
155
  return [
156
+ {
157
+ name: "gallery_create",
158
+ description: "Create a public ordered media gallery in the workspace. The returned canonical URL is safe to give users, but anyone who knows it can view the gallery and its media.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ title: { type: "string", description: "Gallery title (1–120 characters)." },
163
+ description: { type: "string", description: "Optional public gallery description." },
164
+ workspace: workspaceProp,
165
+ },
166
+ required: ["title"],
167
+ additionalProperties: false,
168
+ },
169
+ async handler(args) {
170
+ const title = optString(args, "title");
171
+ if (!title)
172
+ usage("title is required");
173
+ const { client } = clientFor(args);
174
+ return client.createGallery({ title, description: optString(args, "description") });
175
+ },
176
+ },
177
+ {
178
+ name: "gallery_get",
179
+ description: "Get a workspace-owned gallery, including ordered media and its canonical public URL. Gallery media is public to anyone with the URL.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ galleryId: { type: "string", description: "Opaque gallery ID." },
184
+ workspace: workspaceProp,
185
+ },
186
+ required: ["galleryId"],
187
+ additionalProperties: false,
188
+ },
189
+ async handler(args) {
190
+ const { client } = clientFor(args);
191
+ return client.getGallery(galleryId(args));
192
+ },
193
+ },
194
+ {
195
+ name: "gallery_add",
196
+ description: "Add one existing, publicly served workspace object to a gallery. Reads the latest gallery version before writing, so the optimistic API version is handled safely. Does not upload or delete the object.",
197
+ inputSchema: {
198
+ type: "object",
199
+ properties: {
200
+ galleryId: { type: "string", description: "Opaque gallery ID." },
201
+ objectKey: { type: "string", description: "Existing public object key to add." },
202
+ caption: { type: "string", description: "Optional public caption." },
203
+ altText: { type: "string", description: "Optional public alt text." },
204
+ workspace: workspaceProp,
205
+ },
206
+ required: ["galleryId", "objectKey"],
207
+ additionalProperties: false,
208
+ },
209
+ async handler(args) {
210
+ const objectKey = optString(args, "objectKey");
211
+ if (!objectKey)
212
+ usage("objectKey is required");
213
+ const { client } = clientFor(args);
214
+ const id = galleryId(args);
215
+ const current = await client.getGallery(id);
216
+ return client.addGalleryItem(id, objectKey, {
217
+ expectedVersion: current.version,
218
+ caption: optString(args, "caption"),
219
+ altText: optString(args, "altText"),
220
+ });
221
+ },
222
+ },
223
+ {
224
+ name: "gallery_link",
225
+ description: "Link a gallery to an external reference. References use provider-neutral fields; github currently accepts owner/repo#number or a strict GitHub issue/PR URL. No GitHub credentials or API calls are used.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ galleryId: { type: "string", description: "Opaque gallery ID." },
230
+ provider: { type: "string", description: "External provider (currently github)." },
231
+ coordinate: {
232
+ type: "string",
233
+ description: "Provider-native external reference coordinate.",
234
+ },
235
+ workspace: workspaceProp,
236
+ },
237
+ required: ["galleryId", "provider", "coordinate"],
238
+ additionalProperties: false,
239
+ },
240
+ async handler(args) {
241
+ const { client } = clientFor(args);
242
+ const id = galleryId(args);
243
+ const current = await client.getGallery(id);
244
+ return client.linkGalleryExternalReference(id, {
245
+ expectedVersion: current.version,
246
+ ...galleryReference(args),
247
+ });
248
+ },
249
+ },
250
+ {
251
+ name: "gallery_find_by_reference",
252
+ description: "Find workspace galleries linked to an external reference. Returns gallery summaries and canonical public URLs without contacting the provider.",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ provider: { type: "string", description: "External provider (currently github)." },
257
+ coordinate: {
258
+ type: "string",
259
+ description: "Provider-native external reference coordinate.",
260
+ },
261
+ limit: { type: "number", description: "Page size (default 50, max 100)." },
262
+ cursor: { type: "string", description: "Pagination cursor from a previous response." },
263
+ workspace: workspaceProp,
264
+ },
265
+ required: ["provider", "coordinate"],
266
+ additionalProperties: false,
267
+ },
268
+ async handler(args) {
269
+ const { client } = clientFor(args);
270
+ return client.findGalleriesByReference({
271
+ ...galleryReference(args),
272
+ limit: optPosInt(args, "limit"),
273
+ cursor: optString(args, "cursor"),
274
+ });
275
+ },
276
+ },
139
277
  {
140
278
  name: "put",
141
- description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment.",
279
+ description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Pass `file` (a local path) or `contentBase64` + `filename` for in-memory content; with `pr`/`issue` the key is stable (same filename → same URL) and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable and remain public for private/internal GitHub repositories, so upload only non-sensitive media.",
142
280
  inputSchema: {
143
281
  type: "object",
144
282
  properties: {
@@ -302,7 +440,7 @@ export function createUploadsMcpTools(opts) {
302
440
  },
303
441
  {
304
442
  name: "attach",
305
- description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch.",
443
+ description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them (each upload's `markdown` is ready to paste into GitHub). With no pr/issue, targets the pull request for the current branch. Attachments are public and their repo/number/filename keys are predictable even for private/internal GitHub repositories; upload only non-sensitive media.",
306
444
  inputSchema: {
307
445
  type: "object",
308
446
  properties: {
@@ -0,0 +1 @@
1
+ export declare function packageVersion(): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the published package version for CLI headers, --version, and
3
+ * update checks. Reads package.json once per process.
4
+ */
5
+ import { createRequire } from "node:module";
6
+ let cachedVersion;
7
+ export function packageVersion() {
8
+ if (cachedVersion)
9
+ return cachedVersion;
10
+ try {
11
+ const require = createRequire(import.meta.url);
12
+ const pkg = require("../package.json");
13
+ cachedVersion = pkg.version ?? "0.0.0";
14
+ }
15
+ catch {
16
+ cachedVersion = "0.0.0";
17
+ }
18
+ return cachedVersion;
19
+ }
@@ -1,18 +1,4 @@
1
- import { createRequire } from "node:module";
2
- let cachedVersion;
3
- function packageVersion() {
4
- if (cachedVersion)
5
- return cachedVersion;
6
- try {
7
- const require = createRequire(import.meta.url);
8
- const pkg = require("../package.json");
9
- cachedVersion = pkg.version ?? "0.0.0";
10
- }
11
- catch {
12
- cachedVersion = "0.0.0";
13
- }
14
- return cachedVersion;
15
- }
1
+ import { packageVersion } from "./package-version.js";
16
2
  export function buildCliProvenance(opts) {
17
3
  const provenance = {
18
4
  client: opts.client ?? "uploads-cli",
@@ -0,0 +1,30 @@
1
+ export declare const PACKAGE_NAME = "@buildinternet/uploads";
2
+ export interface UpdateCache {
3
+ checkedAt: number;
4
+ latest: string;
5
+ current: string;
6
+ }
7
+ export interface UpdateCheckOptions {
8
+ quiet?: boolean;
9
+ /** mcp is always skipped (stdio purity). */
10
+ command?: string;
11
+ currentVersion?: string;
12
+ cachePath?: string;
13
+ now?: number;
14
+ /** Default 24h. Pass 0 in tests to force a network check. */
15
+ ttlMs?: number;
16
+ timeoutMs?: number;
17
+ fetchImpl?: typeof fetch;
18
+ write?: (text: string) => void;
19
+ }
20
+ /** Parse major.minor.patch (ignores pre-release). */
21
+ export declare function parseSemver(version: string): [number, number, number] | null;
22
+ /** True when `latest` is strictly greater than `current`. */
23
+ export declare function isNewerVersion(latest: string, current: string): boolean;
24
+ export declare function readUpdateCache(path: string): UpdateCache | undefined;
25
+ export declare function writeUpdateCache(path: string, cache: UpdateCache): void;
26
+ /**
27
+ * If a newer published version is known (or can be fetched within the timeout),
28
+ * write a one-line stderr hint. Always resolves; never throws.
29
+ */
30
+ export declare function maybeHintUpdate(opts?: UpdateCheckOptions): Promise<void>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Optional npm update notifier for the CLI.
3
+ *
4
+ * Checks the registry at most once per day, never throws, never blocks longer
5
+ * than a short timeout, and writes only to stderr. Silence with --quiet,
6
+ * UPLOADS_NO_UPDATE=1, or NO_UPDATE_NOTIFIER=1.
7
+ */
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { packageVersion } from "./package-version.js";
12
+ export const PACKAGE_NAME = "@buildinternet/uploads";
13
+ const REGISTRY_LATEST = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
14
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
15
+ const DEFAULT_TIMEOUT_MS = 1500;
16
+ function truthyEnv(name) {
17
+ const v = process.env[name];
18
+ if (!v)
19
+ return false;
20
+ const lower = v.toLowerCase();
21
+ return lower !== "0" && lower !== "false" && lower !== "no";
22
+ }
23
+ function defaultCachePath() {
24
+ const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
25
+ return join(base, "uploads", "version-check.json");
26
+ }
27
+ /** Parse major.minor.patch (ignores pre-release). */
28
+ export function parseSemver(version) {
29
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
30
+ if (!m)
31
+ return null;
32
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
33
+ }
34
+ /** True when `latest` is strictly greater than `current`. */
35
+ export function isNewerVersion(latest, current) {
36
+ const a = parseSemver(latest);
37
+ const b = parseSemver(current);
38
+ if (!a || !b)
39
+ return false;
40
+ for (let i = 0; i < 3; i++) {
41
+ if (a[i] > b[i])
42
+ return true;
43
+ if (a[i] < b[i])
44
+ return false;
45
+ }
46
+ return false;
47
+ }
48
+ export function readUpdateCache(path) {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(path, "utf8"));
51
+ if (typeof raw.checkedAt !== "number" ||
52
+ typeof raw.latest !== "string" ||
53
+ typeof raw.current !== "string") {
54
+ return undefined;
55
+ }
56
+ return { checkedAt: raw.checkedAt, latest: raw.latest, current: raw.current };
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ export function writeUpdateCache(path, cache) {
63
+ try {
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ writeFileSync(path, JSON.stringify(cache) + "\n", { mode: 0o600 });
66
+ }
67
+ catch {
68
+ // Best-effort — never fail the CLI for a cache write error.
69
+ }
70
+ }
71
+ /**
72
+ * If a newer published version is known (or can be fetched within the timeout),
73
+ * write a one-line stderr hint. Always resolves; never throws.
74
+ */
75
+ export async function maybeHintUpdate(opts = {}) {
76
+ try {
77
+ if (opts.quiet || opts.command === "mcp")
78
+ return;
79
+ if (truthyEnv("UPLOADS_NO_UPDATE") || truthyEnv("NO_UPDATE_NOTIFIER"))
80
+ return;
81
+ const current = opts.currentVersion ?? packageVersion();
82
+ const cachePath = opts.cachePath ?? defaultCachePath();
83
+ const now = opts.now ?? Date.now();
84
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
85
+ const write = opts.write ?? ((text) => process.stderr.write(text));
86
+ const cached = readUpdateCache(cachePath);
87
+ let latest;
88
+ if (cached && now - cached.checkedAt < ttlMs && cached.current === current) {
89
+ latest = cached.latest;
90
+ }
91
+ else {
92
+ const fetched = await fetchLatestVersion(opts.fetchImpl, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
93
+ if (fetched) {
94
+ latest = fetched;
95
+ writeUpdateCache(cachePath, { checkedAt: now, latest: fetched, current });
96
+ }
97
+ else if (cached?.current === current) {
98
+ latest = cached.latest; // stale cache if network failed
99
+ }
100
+ }
101
+ if (latest && isNewerVersion(latest, current)) {
102
+ write(`hint: ${PACKAGE_NAME}@${latest} is available (you have ${current}). Update: npm i -g ${PACKAGE_NAME}\n`);
103
+ }
104
+ }
105
+ catch {
106
+ // Never surface update-check failures.
107
+ }
108
+ }
109
+ async function fetchLatestVersion(fetchImpl, timeoutMs) {
110
+ const fetchFn = fetchImpl ?? globalThis.fetch;
111
+ if (typeof fetchFn !== "function")
112
+ return undefined;
113
+ const controller = new AbortController();
114
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
115
+ try {
116
+ const res = await fetchFn(REGISTRY_LATEST, {
117
+ signal: controller.signal,
118
+ headers: {
119
+ accept: "application/json",
120
+ "user-agent": `${PACKAGE_NAME}/${packageVersion()} (update-check)`,
121
+ },
122
+ });
123
+ if (!res.ok)
124
+ return undefined;
125
+ const body = (await res.json());
126
+ return typeof body.version === "string" ? body.version : undefined;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,