@buildinternet/uploads 0.42.1 → 0.43.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.
@@ -2,12 +2,20 @@
2
2
  * Install thin user-global hook manifests for Grok and Cursor.
3
3
  *
4
4
  * Claude and Codex ship the same PreToolUse hook via their plugins
5
- * (`hooks/hooks.json` → `uploads hook pre-pr-screenshot`). Do not also write
5
+ * (`hooks/hooks.json` → HOOK_COMMAND). Do not also write
6
6
  * ~/.codex/hooks.json here, or the reminder would fire twice.
7
7
  *
8
8
  * Idempotent: skip when our command string is already present.
9
+ * An older bare `uploads hook …` entry is upgraded in place.
9
10
  */
10
- export declare const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
11
+ /** What the harness actually runs once `uploads` is on PATH. */
12
+ export declare const HOOK_INVOCATION = "uploads hook pre-pr-screenshot";
13
+ /**
14
+ * Fail-open shell used in every harness manifest.
15
+ * A missing `uploads` binary is a silent no-op (exit 0, no stderr) so plugin
16
+ * users without the CLI do not get a hook-failure annotation on every shell tool.
17
+ */
18
+ export declare const HOOK_COMMAND = "if command -v uploads >/dev/null 2>&1; then uploads hook pre-pr-screenshot; fi";
11
19
  export type HookWriteResult = {
12
20
  path: string;
13
21
  action: "wrote" | "merged" | "skipped" | "would-write" | "would-merge";
@@ -2,15 +2,23 @@
2
2
  * Install thin user-global hook manifests for Grok and Cursor.
3
3
  *
4
4
  * Claude and Codex ship the same PreToolUse hook via their plugins
5
- * (`hooks/hooks.json` → `uploads hook pre-pr-screenshot`). Do not also write
5
+ * (`hooks/hooks.json` → HOOK_COMMAND). Do not also write
6
6
  * ~/.codex/hooks.json here, or the reminder would fire twice.
7
7
  *
8
8
  * Idempotent: skip when our command string is already present.
9
+ * An older bare `uploads hook …` entry is upgraded in place.
9
10
  */
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
13
- export const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
14
+ /** What the harness actually runs once `uploads` is on PATH. */
15
+ export const HOOK_INVOCATION = "uploads hook pre-pr-screenshot";
16
+ /**
17
+ * Fail-open shell used in every harness manifest.
18
+ * A missing `uploads` binary is a silent no-op (exit 0, no stderr) so plugin
19
+ * users without the CLI do not get a hook-failure annotation on every shell tool.
20
+ */
21
+ export const HOOK_COMMAND = `if command -v uploads >/dev/null 2>&1; then ${HOOK_INVOCATION}; fi`;
14
22
  const TIMEOUT_SEC = 15;
15
23
  const GROK_PAYLOAD = {
16
24
  description: "Advisory reminder to stage screenshots on uploads.sh before opening a PR that touches UI files.",
@@ -38,6 +46,16 @@ function containsCommand(filePath) {
38
46
  return false;
39
47
  }
40
48
  }
49
+ function hookCommandOf(entry) {
50
+ if (!entry || typeof entry !== "object")
51
+ return undefined;
52
+ const command = entry.command;
53
+ return typeof command === "string" ? command : undefined;
54
+ }
55
+ function isOurHookEntry(entry) {
56
+ const command = hookCommandOf(entry);
57
+ return Boolean(command?.includes(HOOK_INVOCATION));
58
+ }
41
59
  function writeJson(filePath, value) {
42
60
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
43
61
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
@@ -79,7 +97,17 @@ function mergeCursor(home, dryRun) {
79
97
  ? { ...existing.hooks }
80
98
  : {};
81
99
  const list = Array.isArray(hooks.beforeShellExecution) ? [...hooks.beforeShellExecution] : [];
82
- list.push({ command: HOOK_COMMAND, timeout: TIMEOUT_SEC });
100
+ const ours = list.findIndex(isOurHookEntry);
101
+ const nextEntry = { command: HOOK_COMMAND, timeout: TIMEOUT_SEC };
102
+ if (ours >= 0) {
103
+ if (hookCommandOf(list[ours]) === HOOK_COMMAND) {
104
+ return { path: filePath, action: "skipped" };
105
+ }
106
+ list[ours] = { ...list[ours], ...nextEntry };
107
+ }
108
+ else {
109
+ list.push(nextEntry);
110
+ }
83
111
  hooks.beforeShellExecution = list;
84
112
  existing.hooks = hooks;
85
113
  if (existing.version === undefined)
package/dist/mcp/tools.js CHANGED
@@ -108,7 +108,7 @@ export function createUploadsMcpTools(opts) {
108
108
  const { globals } = opts;
109
109
  const run = opts.runner ?? execRunner;
110
110
  const clientFactory = opts.clientFactory ?? createUploadsClient;
111
- function clientFor(args, requireToken = true) {
111
+ async function clientFor(args, requireToken = true) {
112
112
  const config = resolveConfig({
113
113
  apiUrl: globals.apiUrl,
114
114
  token: globals.token,
@@ -151,7 +151,7 @@ export function createUploadsMcpTools(opts) {
151
151
  const title = optString(args, "title");
152
152
  if (!title)
153
153
  usage("title is required");
154
- const { client } = clientFor(args);
154
+ const { client } = await clientFor(args);
155
155
  return client.createGallery({ title, description: optString(args, "description") });
156
156
  },
157
157
  },
@@ -171,7 +171,7 @@ export function createUploadsMcpTools(opts) {
171
171
  additionalProperties: false,
172
172
  },
173
173
  async handler(args) {
174
- const { client } = clientFor(args);
174
+ const { client } = await clientFor(args);
175
175
  return client.getGallery(galleryId(args));
176
176
  },
177
177
  },
@@ -197,7 +197,7 @@ export function createUploadsMcpTools(opts) {
197
197
  const objectKey = optString(args, "objectKey");
198
198
  if (!objectKey)
199
199
  usage("objectKey is required");
200
- const { client } = clientFor(args);
200
+ const { client } = await clientFor(args);
201
201
  const id = galleryId(args);
202
202
  const current = await client.getGallery(id);
203
203
  return client.addGalleryItem(id, objectKey, {
@@ -228,7 +228,7 @@ export function createUploadsMcpTools(opts) {
228
228
  additionalProperties: false,
229
229
  },
230
230
  async handler(args) {
231
- const { client } = clientFor(args);
231
+ const { client } = await clientFor(args);
232
232
  const id = galleryId(args);
233
233
  const current = await client.getGallery(id);
234
234
  return client.linkGalleryExternalReference(id, {
@@ -259,7 +259,7 @@ export function createUploadsMcpTools(opts) {
259
259
  additionalProperties: false,
260
260
  },
261
261
  async handler(args) {
262
- const { client } = clientFor(args);
262
+ const { client } = await clientFor(args);
263
263
  return client.findGalleriesByReference({
264
264
  ...galleryReference(args),
265
265
  limit: optPosInt(args, "limit"),
@@ -426,7 +426,7 @@ export function createUploadsMcpTools(opts) {
426
426
  catch (err) {
427
427
  usage(err instanceof Error ? err.message : String(err));
428
428
  }
429
- const { config, client } = clientFor(args);
429
+ const { config, client } = await clientFor(args);
430
430
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
431
431
  const frameOpts = mcpFrameOptions(args);
432
432
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
@@ -736,7 +736,7 @@ export function createUploadsMcpTools(opts) {
736
736
  const metadata = metadataArgWithCanonical(args);
737
737
  if (metadata)
738
738
  validateMetaMap(metadata);
739
- const { config, client } = clientFor(args);
739
+ const { config, client } = await clientFor(args);
740
740
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
741
741
  const frameOpts = mcpFrameOptions(args);
742
742
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
@@ -969,7 +969,7 @@ export function createUploadsMcpTools(opts) {
969
969
  const explicitTarget = ghTargetFromArgs(args, run);
970
970
  const target = explicitTarget ??
971
971
  resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
972
- const { config, client } = clientFor(args);
972
+ const { config, client } = await clientFor(args);
973
973
  const contentType = optString(args, "contentType");
974
974
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
975
975
  const frameOpts = mcpFrameOptions(args);
@@ -1037,7 +1037,7 @@ export function createUploadsMcpTools(opts) {
1037
1037
  const prefixArg = optString(args, "prefix");
1038
1038
  let prefix = prefixArg ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
1039
1039
  const target = ghTargetFromArgs(args, run);
1040
- const { client } = clientFor(args);
1040
+ const { client } = await clientFor(args);
1041
1041
  // Also list every active private prefix, if any (issue #631) —
1042
1042
  // mirrors syncAttachmentsComment's gh-fallback gather: a repo's
1043
1043
  // attachment history can be split across the plain shape and
@@ -1093,7 +1093,7 @@ export function createUploadsMcpTools(opts) {
1093
1093
  additionalProperties: false,
1094
1094
  },
1095
1095
  async handler(args) {
1096
- const { client } = clientFor(args);
1096
+ const { client } = await clientFor(args);
1097
1097
  const repo = resolveRepo(optString(args, "repo"), run);
1098
1098
  const branch = optString(args, "branch") ?? resolveCurrentBranch(run);
1099
1099
  return resolveStaged({ client, repo, branch });
@@ -1124,7 +1124,7 @@ export function createUploadsMcpTools(opts) {
1124
1124
  usage("key is required");
1125
1125
  if (optBool(args, "dryRun"))
1126
1126
  return { key, deleted: false, dryRun: true };
1127
- const { client } = clientFor(args);
1127
+ const { client } = await clientFor(args);
1128
1128
  return client.delete(key);
1129
1129
  },
1130
1130
  },
@@ -1147,7 +1147,7 @@ export function createUploadsMcpTools(opts) {
1147
1147
  const key = optString(args, "key");
1148
1148
  if (!key)
1149
1149
  usage("key is required");
1150
- return clientFor(args).client.getMetadata(key);
1150
+ return (await clientFor(args)).client.getMetadata(key);
1151
1151
  },
1152
1152
  },
1153
1153
  {
@@ -1184,7 +1184,7 @@ export function createUploadsMcpTools(opts) {
1184
1184
  }
1185
1185
  if (set)
1186
1186
  validateMetaMap(set);
1187
- const { client } = clientFor(args);
1187
+ const { client } = await clientFor(args);
1188
1188
  return client.patchMetadata(key, { set, delete: del });
1189
1189
  },
1190
1190
  },
@@ -1223,7 +1223,7 @@ export function createUploadsMcpTools(opts) {
1223
1223
  }
1224
1224
  if (hasMeta)
1225
1225
  validateMetaMap(filters);
1226
- const { client } = clientFor(args);
1226
+ const { client } = await clientFor(args);
1227
1227
  return client.findFiles(filters, {
1228
1228
  name,
1229
1229
  prefix: optString(args, "prefix"),
@@ -1249,7 +1249,7 @@ export function createUploadsMcpTools(opts) {
1249
1249
  additionalProperties: false,
1250
1250
  },
1251
1251
  async handler(args) {
1252
- const { client } = clientFor(args);
1252
+ const { client } = await clientFor(args);
1253
1253
  const key = optString(args, "key");
1254
1254
  return key ? client.listMetadataValues(key) : client.listMetadataKeys();
1255
1255
  },
@@ -1266,7 +1266,7 @@ export function createUploadsMcpTools(opts) {
1266
1266
  additionalProperties: false,
1267
1267
  },
1268
1268
  async handler(args) {
1269
- const { client } = clientFor(args);
1269
+ const { client } = await clientFor(args);
1270
1270
  return client.usage();
1271
1271
  },
1272
1272
  },
@@ -1282,7 +1282,7 @@ export function createUploadsMcpTools(opts) {
1282
1282
  additionalProperties: false,
1283
1283
  },
1284
1284
  async handler(args) {
1285
- const { client } = clientFor(args);
1285
+ const { client } = await clientFor(args);
1286
1286
  return client.reconcile();
1287
1287
  },
1288
1288
  },
@@ -1298,7 +1298,7 @@ export function createUploadsMcpTools(opts) {
1298
1298
  additionalProperties: false,
1299
1299
  },
1300
1300
  async handler(args) {
1301
- const { client } = clientFor(args);
1301
+ const { client } = await clientFor(args);
1302
1302
  return client.purgeExpired();
1303
1303
  },
1304
1304
  },
@@ -1320,7 +1320,7 @@ export function createUploadsMcpTools(opts) {
1320
1320
  const target = ghTargetFromArgs(args, run);
1321
1321
  if (!target)
1322
1322
  usage("comment requires pr or issue");
1323
- const { config, client } = clientFor(args);
1323
+ const { config, client } = await clientFor(args);
1324
1324
  // Explicit resync, same as `uploads comment` (issue #480).
1325
1325
  const result = await syncAttachmentsComment(client, target, run, config.workspace, {
1326
1326
  resync: true,
@@ -1336,7 +1336,7 @@ export function createUploadsMcpTools(opts) {
1336
1336
  description: "Check uploads.sh API liveness. No auth or arguments required.",
1337
1337
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1338
1338
  async handler(args) {
1339
- const { config, client } = clientFor(args, false);
1339
+ const { config, client } = await clientFor(args, false);
1340
1340
  const result = await client.health();
1341
1341
  return { ...result, apiUrl: config.apiUrl };
1342
1342
  },
@@ -1353,7 +1353,7 @@ export function createUploadsMcpTools(opts) {
1353
1353
  additionalProperties: false,
1354
1354
  },
1355
1355
  async handler(args) {
1356
- const { config, client } = clientFor(args);
1356
+ const { config, client } = await clientFor(args);
1357
1357
  return buildDoctorReport(config, client);
1358
1358
  },
1359
1359
  },
@@ -0,0 +1,9 @@
1
+ /**
2
+ * No-project-context nudge (issue #692 follow-up): a path-tagged upload with
3
+ * no repo/gh.repo/app metadata and no real (non-local) origin lands in the
4
+ * screenshots page's "local dev" / "Other" fallback buckets. One advisory
5
+ * stderr line teaches the fix at the moment the context went missing. The
6
+ * predicate mirrors apps/api's projectLabelFromMeta fallback rules exactly —
7
+ * a real URL host is a meaningful group, so it never fires there.
8
+ */
9
+ export declare function noProjectContextNudge(meta: Record<string, string> | undefined): string | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * No-project-context nudge (issue #692 follow-up): a path-tagged upload with
3
+ * no repo/gh.repo/app metadata and no real (non-local) origin lands in the
4
+ * screenshots page's "local dev" / "Other" fallback buckets. One advisory
5
+ * stderr line teaches the fix at the moment the context went missing. The
6
+ * predicate mirrors apps/api's projectLabelFromMeta fallback rules exactly —
7
+ * a real URL host is a meaningful group, so it never fires there.
8
+ */
9
+ /** Hosts that identify a dev machine, not an app — same set as the API's
10
+ * isLocalHostname (apps/api/src/file-metadata.ts). */
11
+ function isLocalHostname(hostname) {
12
+ const bare = hostname.toLowerCase();
13
+ return (bare === "localhost" ||
14
+ bare.endsWith(".localhost") ||
15
+ bare === "127.0.0.1" ||
16
+ bare === "0.0.0.0" ||
17
+ bare === "[::1]");
18
+ }
19
+ export function noProjectContextNudge(meta) {
20
+ // Only `path`-tagged uploads appear on the screenshots page at all.
21
+ if (!meta?.path)
22
+ return undefined;
23
+ if (meta.repo || meta["gh.repo"] || meta.app)
24
+ return undefined;
25
+ let bucket = "Other";
26
+ if (meta.url) {
27
+ try {
28
+ const parsed = new URL(meta.url);
29
+ if (!isLocalHostname(parsed.hostname))
30
+ return undefined; // real host = real group
31
+ bucket = "local dev";
32
+ }
33
+ catch {
34
+ // unparseable url is just "no url"
35
+ }
36
+ }
37
+ return (`note: no repo detected — the screenshots page will group this under "${bucket}". ` +
38
+ `Run from inside your project repo, or pass --app <name>.`);
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.42.1",
3
+ "version": "0.43.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,