@buildinternet/uploads 0.26.1 → 0.28.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 CHANGED
@@ -98,6 +98,11 @@ explicit deletes. Promotion (auto or `--promote`) does skip files staged
98
98
  more than 30 days before the PR opens, though; they're still there, just no
99
99
  longer auto-promoted.
100
100
 
101
+ **Metadata edits re-sync the comment too:** `uploads meta set` on a `gh/…`-keyed
102
+ object refreshes the managed comment automatically when it touches `path` or
103
+ `state` — best-effort, so backfilled metadata shows up without waiting on the
104
+ next `attach`.
105
+
101
106
  **Bare `put` stages too, by default (issue #403):** on a non-default git
102
107
  branch, a `put` with none of
103
108
  `--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` set
package/dist/cli-brand.js CHANGED
@@ -156,7 +156,10 @@ function boxLines(lines, options = {}) {
156
156
  ].join("\n");
157
157
  }
158
158
  export function formatUpdateBanner(options) {
159
- return boxLines([`Update available ${options.current} → ${options.latest}`, `npm i -g @buildinternet/uploads`], { color: options.color, tone: BRAND.accent });
159
+ return boxLines([`Update available ${options.current} → ${options.latest}`, `uploads update`], {
160
+ color: options.color,
161
+ tone: BRAND.accent,
162
+ });
160
163
  }
161
164
  export function formatAuthBanner(options = {}) {
162
165
  return boxLines(["Sign in via browser", "uploads login"], {
@@ -60,6 +60,7 @@ export const SCREENSHOT_FLAGS = [
60
60
  "--light",
61
61
  "--wait",
62
62
  "--out",
63
+ "--no-sidecar",
63
64
  "--no-upload",
64
65
  "--destination",
65
66
  "--prefix",
@@ -187,6 +188,11 @@ export const ROOT_COMMANDS = [
187
188
  { name: "all", summary: "Install skills and MCP (default)" },
188
189
  ],
189
190
  },
191
+ {
192
+ name: "update",
193
+ summary: "Update the CLI, then refresh the agent skills + MCP registration",
194
+ essential: true,
195
+ },
190
196
  {
191
197
  name: "login",
192
198
  summary: "Sign in via browser (or an enrollment code) and save credentials",
package/dist/cli-help.js CHANGED
@@ -16,6 +16,7 @@ const ESSENTIAL_ORDER = [
16
16
  "delete",
17
17
  "doctor",
18
18
  "install",
19
+ "update",
19
20
  ];
20
21
  /** Day-to-day commands shown on bare `uploads` / `uploads help`. */
21
22
  const ESSENTIALS = ESSENTIAL_ORDER.map((name) => {
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { runInvite } from "./commands/invite.js";
12
12
  import { runAdmin } from "./commands/admin-enrollment.js";
13
13
  import { runMcp } from "./commands/mcp.js";
14
14
  import { runInstall } from "./commands/install.js";
15
+ import { runUpdate } from "./commands/update.js";
15
16
  import { runCompletion } from "./commands/completion.js";
16
17
  import { runLogout, runWhoami } from "./commands/session.js";
17
18
  import { runTelemetry } from "./commands/telemetry.js";
@@ -268,6 +269,9 @@ export async function runCli(argv) {
268
269
  case "install":
269
270
  code = await runInstall(cmdArgs, { globals: parsed.globals, json }, showHelp);
270
271
  break;
272
+ case "update":
273
+ code = await runUpdate(cmdArgs, { globals: parsed.globals }, showHelp);
274
+ break;
271
275
  case "completion":
272
276
  case "completions":
273
277
  code = await runCompletion(cmdArgs, showHelp);
package/dist/client.d.ts CHANGED
@@ -519,10 +519,18 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
519
519
  id: string;
520
520
  }>;
521
521
  findGalleriesByReference(opts: FindGalleriesByReferenceOptions): Promise<GalleryListResult>;
522
+ /**
523
+ * Upsert the managed attachments comment. `resync: true` marks an
524
+ * explicit "make the comment state correct" call (`uploads comment`), so
525
+ * the server hunts for the marker — and collapses any duplicate — instead
526
+ * of patching its cached comment id (issue #480). Older servers ignore
527
+ * the field.
528
+ */
522
529
  upsertGithubComment(opts: {
523
530
  repo: string;
524
531
  num: number;
525
532
  kind: "pull" | "issues";
533
+ resync?: boolean;
526
534
  }): Promise<GithubCommentResult>;
527
535
  /**
528
536
  * Promote a workspace's branch-staged attachments into a PR's stable
package/dist/client.js CHANGED
@@ -479,6 +479,13 @@ export function createUploadsClient(config) {
479
479
  params.set("cursor", opts.cursor);
480
480
  return request("GET", galleriesBase(config) + "/by-reference?" + params);
481
481
  },
482
+ /**
483
+ * Upsert the managed attachments comment. `resync: true` marks an
484
+ * explicit "make the comment state correct" call (`uploads comment`), so
485
+ * the server hunts for the marker — and collapses any duplicate — instead
486
+ * of patching its cached comment id (issue #480). Older servers ignore
487
+ * the field.
488
+ */
482
489
  async upsertGithubComment(opts) {
483
490
  return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
484
491
  body: new TextEncoder().encode(JSON.stringify(opts)),
@@ -1,6 +1,14 @@
1
1
  import { type GlobalFlags } from "../cli-args.js";
2
2
  import { type CommandRunner } from "../github-gh.js";
3
3
  export declare const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
4
+ export interface StepResult {
5
+ command: string[];
6
+ ok: boolean;
7
+ skipped?: "dry-run" | "sign-in";
8
+ error?: string;
9
+ output?: string;
10
+ }
11
+ export declare function runStep(run: CommandRunner, command: string[]): StepResult;
4
12
  export declare function runInstall(args: string[], opts: {
5
13
  globals: GlobalFlags;
6
14
  json?: boolean;
@@ -46,7 +46,7 @@ function redactor(token) {
46
46
  return out;
47
47
  };
48
48
  }
49
- function runStep(run, command) {
49
+ export function runStep(run, command) {
50
50
  try {
51
51
  const output = run(command[0], command.slice(1)).trim();
52
52
  return { command, ok: true, output: output || undefined };
@@ -2,15 +2,16 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
9
9
  import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
- import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
10
+ import { ghBranchAttachmentKey } from "../github.js";
11
11
  import { safeCaptureFacts } from "../capture-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
14
+ import { writeSidecarMeta } from "../sidecar.js";
14
15
  import { writeJson, writeStdout } from "../io.js";
15
16
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
16
17
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
@@ -33,6 +34,15 @@ fail fast with a clear error instead of sending a doomed request.
33
34
  After capture, screenshots share the put upload pipeline: optional --frame,
34
35
  optimize-by-default, --pr/--issue attachment + --comment, --gallery, --meta.
35
36
 
37
+ Branch staging by default (pre-PR): with no --pr/--issue/--branch/--key/--ref/
38
+ --prefix/--destination, a screenshot taken on a non-default git branch stages
39
+ under gh/<owner>/<repo>/branch/<branch>/<name> instead of the dated
40
+ screenshots/<repo>/<date>/... layout — same key/metadata as an explicit
41
+ --branch, carrying every derived fact (path/url/env/viewport, --state) along.
42
+ Staged files auto-attach with full metadata the first time you attach to that
43
+ branch's PR once it opens (or run "uploads attach --promote"). Use --no-git,
44
+ or an explicit --ref/--prefix, to opt back into the dated layout.
45
+
36
46
  Options:
37
47
  --via auto|local|remote Capture backend (default: auto, or UPLOADS_SCREENSHOT_VIA)
38
48
  --browser <path> Explicit local browser executable (or UPLOADS_CHROME_PATH / CHROME_PATH)
@@ -51,7 +61,12 @@ Options:
51
61
  on --via remote — neutralizes animations via injected CSS)
52
62
  --eval <js> Run JS in the page after settle, before capture (--via local only)
53
63
  --init-script <file> Inject a JS file before navigation (--via local only)
54
- --out <file> Also write the PNG to a local file
64
+ --out <file> Also write the PNG to a local file. Also writes a sidecar manifest,
65
+ <file>.uploads.json, recording this capture's derived metadata
66
+ (path/url/env/viewport, plus --state if given) with a content hash; a
67
+ later \`put\`/\`attach\` of this exact file picks the metadata back up
68
+ automatically (explicit --meta/--state still win). See --no-sidecar.
69
+ --no-sidecar Don't write the <file>.uploads.json sidecar alongside --out
55
70
  --no-upload Skip hosting; requires --out (local file only)
56
71
  --destination <id> Typed root: screenshots | gh | f
57
72
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
@@ -171,6 +186,9 @@ captureImpl = captureScreenshot) {
171
186
  const noUpload = flagBool(parsed.flags, "--no-upload");
172
187
  if (noUpload && !outFile)
173
188
  throw new UsageError("--no-upload requires --out");
189
+ const noSidecar = flagBool(parsed.flags, "--no-sidecar");
190
+ if (noSidecar && !outFile)
191
+ throw new UsageError("--no-sidecar requires --out");
174
192
  const keyHint = flagString(parsed.flags, "--key");
175
193
  const destFlag = flagString(parsed.flags, "--destination");
176
194
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -210,14 +228,38 @@ captureImpl = captureScreenshot) {
210
228
  if (noUpload)
211
229
  throw new UsageError("--dry-run cannot be combined with --no-upload");
212
230
  }
231
+ const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
232
+ const noGit = flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true;
213
233
  const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
234
+ // Auto branch staging (issue #469 lever 1): mirrors bare `put`'s auto-staging
235
+ // (issue #403). When no --branch/--pr/--issue/--key/--ref/--prefix/--destination
236
+ // is given and git use isn't disabled, a screenshot taken on a non-default
237
+ // git branch stages the same way explicit `--branch`/bare `put` do — same
238
+ // key shape, same gh.* metadata — instead of landing on the dated
239
+ // `screenshots/<repo>/<date>/...` layout. This is what lets derived
240
+ // metadata (path/url/env/viewport, --state) ride through to PR-open
241
+ // promotion when the capture happens before the PR exists. Skipped
242
+ // entirely when --branch was given explicitly (already handled above).
243
+ const autoStagingTarget = branchArg === undefined
244
+ ? resolvePutStagingTarget({
245
+ ghTarget,
246
+ keyHint,
247
+ refArg: flagString(parsed.flags, "--ref"),
248
+ prefixArg: prefixFlag,
249
+ destinationArg: destFlag,
250
+ noGit,
251
+ repoArg: flagString(parsed.flags, "--repo") ?? putDefaults.repo,
252
+ run,
253
+ })
254
+ : undefined;
255
+ const stagingTarget = branchArg !== undefined ? { repo: branchRepo, branch: branchArg } : autoStagingTarget;
214
256
  let resolvedPrefix;
215
257
  try {
216
258
  resolvedPrefix = resolvePutPrefix({
217
259
  destination: destFlag,
218
260
  prefix: prefixFlag,
219
261
  key: keyHint,
220
- ghAttachment: Boolean(ghTarget) || branchArg !== undefined,
262
+ ghAttachment: Boolean(ghTarget) || stagingTarget !== undefined,
221
263
  });
222
264
  }
223
265
  catch (err) {
@@ -233,7 +275,6 @@ captureImpl = captureScreenshot) {
233
275
  return raw;
234
276
  throw new UsageError(`invalid --format: ${raw}`);
235
277
  })();
236
- const putDefaults = resolvePutDefaults({ envFile: ctx.envFile }, rawDefaults);
237
278
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, putDefaults);
238
279
  const frameOpts = frameOptionsFromFlags(parsed.flags);
239
280
  const altFlag = flagString(parsed.flags, "--alt");
@@ -248,9 +289,8 @@ captureImpl = captureScreenshot) {
248
289
  metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
249
290
  validateMetaMap(metadata);
250
291
  }
251
- else if (branchArg !== undefined) {
252
- metadata = { ...withFacts, ...ghMetadataForBranch(branchRepo, branchArg) };
253
- validateMetaMap(metadata);
292
+ else if (stagingTarget !== undefined) {
293
+ metadata = mergeStagingMeta(withFacts, stagingTarget);
254
294
  }
255
295
  else if (Object.keys(withFacts).length > 0) {
256
296
  validateMetaMap(withFacts);
@@ -282,6 +322,8 @@ captureImpl = captureScreenshot) {
282
322
  writeFileSync(outFile, captured.png);
283
323
  if (logHuman)
284
324
  process.stderr.write(`>> wrote ${outFile}\n`);
325
+ if (!noSidecar)
326
+ writeSidecarMeta(outFile, captured.png, withFacts);
285
327
  }
286
328
  if (noUpload) {
287
329
  if (ctx.json) {
@@ -294,8 +336,8 @@ captureImpl = captureScreenshot) {
294
336
  }
295
337
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
296
338
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
297
- const branchKey = branchArg !== undefined
298
- ? ghBranchAttachmentKey(branchRepo, branchArg, captured.filename)
339
+ const branchKey = stagingTarget !== undefined
340
+ ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
299
341
  : undefined;
300
342
  const alt = altFlag ?? basename(captured.filename);
301
343
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
@@ -306,13 +348,26 @@ captureImpl = captureScreenshot) {
306
348
  prefix: resolvedPrefix ?? putDefaults.prefix,
307
349
  repo,
308
350
  ref,
309
- deriveRepoFromGit: !(flagBool(parsed.flags, "--no-git") || putDefaults.noGit === true),
351
+ deriveRepoFromGit: !noGit,
310
352
  dryRun,
311
353
  metadata,
312
354
  provenanceClient: "uploads-cli-screenshot",
313
355
  alt: () => alt,
314
356
  width,
315
357
  });
358
+ // Staging note (issue #469 lever 1, mirrors #403's bare-put note): only for
359
+ // the auto-staged case — explicit `--branch` keeps its own "staged: these
360
+ // auto-attach..." wording below. Same suppression as put's note (--quiet,
361
+ // UPLOADS_NO_NUDGE=1).
362
+ const stagingNote = autoStagingTarget && !ctx.quiet && !putDefaults.noNudge
363
+ ? putStagingNoteText(autoStagingTarget.branch)
364
+ : undefined;
365
+ // Stage-time binding warning (issue #398), same check bare put/attach
366
+ // --branch run, now also reachable from screenshot's staging paths
367
+ // (explicit --branch and auto-staging alike).
368
+ const bindingWarning = stagingTarget !== undefined
369
+ ? await resolveStageBindingWarning({ ctx, defaults: putDefaults, repo: stagingTarget.repo })
370
+ : undefined;
316
371
  let gallery;
317
372
  if (galleryId) {
318
373
  try {
@@ -347,8 +402,25 @@ captureImpl = captureScreenshot) {
347
402
  if (prepared.optimized) {
348
403
  process.stderr.write(`>> optimized ${prepared.originalBytes} → ${prepared.outputBytes} bytes\n`);
349
404
  }
350
- process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
405
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
406
+ if (stagingTarget !== undefined) {
407
+ process.stderr.write(`>> find these later: uploads find gh.branch=${stagingTarget.branch.toLowerCase()}\n`);
408
+ if (autoStagingTarget) {
409
+ if (stagingNote)
410
+ process.stderr.write(`${stagingNote}\n`);
411
+ }
412
+ else {
413
+ process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
414
+ `(or run \`uploads attach --promote\` after opening)\n`);
415
+ }
416
+ }
417
+ if (bindingWarning)
418
+ process.stderr.write(`${bindingWarning}\n`);
419
+ process.stderr.write("\n");
351
420
  }
421
+ // One JSON `hint` slot (mirrors bare put): the binding warning is more
422
+ // actionable than the generic staging note, so it wins when both fire.
423
+ const jsonHint = bindingWarning ?? stagingNote;
352
424
  switch (format) {
353
425
  case "json":
354
426
  await writeJson({
@@ -363,6 +435,7 @@ captureImpl = captureScreenshot) {
363
435
  backend: captured.backend,
364
436
  gallery,
365
437
  ...(dryRun ? { dryRun: true } : {}),
438
+ ...(jsonHint ? { hint: jsonHint } : {}),
366
439
  });
367
440
  break;
368
441
  case "url":
@@ -0,0 +1,14 @@
1
+ import { type GlobalFlags } from "../cli-args.js";
2
+ import { type CommandRunner } from "../github-gh.js";
3
+ import { type InstallSource } from "../install-source.js";
4
+ import { type UpdateStatus } from "../update-check.js";
5
+ export interface RunUpdateOptions {
6
+ globals: GlobalFlags;
7
+ /** Injected in tests. */
8
+ runner?: CommandRunner;
9
+ /** Injected in tests; defaults to detection from this module's path. */
10
+ source?: InstallSource;
11
+ /** Injected in tests; defaults to a cache-bypassing registry check. */
12
+ check?: () => Promise<UpdateStatus>;
13
+ }
14
+ export declare function runUpdate(args: string[], opts: RunUpdateOptions, help?: boolean): Promise<number>;
@@ -0,0 +1,125 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { flagBool, parseCommandArgs, UsageError } from "../cli-args.js";
4
+ import { execRunner } from "../github-gh.js";
5
+ import { writeCommandHelp } from "../cli-style.js";
6
+ import { detectInstallSource } from "../install-source.js";
7
+ import { checkForUpdate } from "../update-check.js";
8
+ import { runInstall, runStep } from "./install.js";
9
+ const UPDATE_HELP = `uploads update — update the CLI and refresh agent integrations
10
+
11
+ Upgrades the globally installed npm package, then re-runs \`uploads install\` so
12
+ the agent skills and the MCP registration match the new version. Skills and the
13
+ MCP registration drift on their own, so this refreshes them even when the CLI is
14
+ already current.
15
+
16
+ Usage:
17
+ uploads update [options]
18
+
19
+ Options:
20
+ --dry-run Print the plan without running anything
21
+ --skip-install Upgrade the npm package only; leave skills and MCP alone
22
+ --verbose Show the output of the underlying commands
23
+
24
+ Examples:
25
+ uploads update
26
+ uploads update --dry-run
27
+ uploads update --skip-install
28
+ `;
29
+ /** Why an upgrade was skipped, phrased for the user. */
30
+ const SKIP_REASON = {
31
+ workspace: "this is a workspace checkout, not a global install",
32
+ npx: "this ran from an npx cache, which is discarded after the run",
33
+ unknown: "this is a local project dependency, not a global install",
34
+ };
35
+ function thisModulePath() {
36
+ const path = fileURLToPath(import.meta.url);
37
+ try {
38
+ return realpathSync(path);
39
+ }
40
+ catch {
41
+ return path;
42
+ }
43
+ }
44
+ function isPermissionError(message) {
45
+ return /EACCES|EPERM|permission denied/i.test(message);
46
+ }
47
+ export async function runUpdate(args, opts, help = false) {
48
+ const parsed = parseCommandArgs(args);
49
+ if (help || parsed.help) {
50
+ writeCommandHelp(UPDATE_HELP);
51
+ return 0;
52
+ }
53
+ if (parsed.positionals.length > 0) {
54
+ throw new UsageError(`update takes no arguments (got ${parsed.positionals[0]})`);
55
+ }
56
+ const dryRun = flagBool(parsed.flags, "--dry-run");
57
+ const verbose = flagBool(parsed.flags, "--verbose");
58
+ const skipInstall = flagBool(parsed.flags, "--skip-install");
59
+ const run = opts.runner ?? execRunner;
60
+ const source = opts.source ?? detectInstallSource(thisModulePath());
61
+ // ttlMs 0 bypasses the once-a-day cache: `update` must not trust yesterday's read.
62
+ const status = await (opts.check ?? (() => checkForUpdate({ ttlMs: 0 })))();
63
+ const willUpgrade = status.updateAvailable && source.kind === "global";
64
+ // Resolved through PATH: in the normal single-install case this is the
65
+ // just-upgraded binary, but with multiple `uploads` binaries on PATH it
66
+ // could resolve to a different, stale one. Accepted risk, not a guarantee.
67
+ const refreshCommand = ["uploads", "install"];
68
+ // --- plan ---
69
+ if (willUpgrade) {
70
+ process.stdout.write(`CLI ${status.current} → ${status.latest}\n`);
71
+ }
72
+ else if (status.updateAvailable) {
73
+ process.stdout.write(`CLI ${status.current} is behind ${status.latest}, but the upgrade is skipped — ` +
74
+ `${SKIP_REASON[source.kind] ?? "the install source is not a global install"}.\n` +
75
+ `Upgrade by hand with: ${source.upgradeCommand.join(" ")}\n`);
76
+ }
77
+ else {
78
+ process.stdout.write(`CLI already at ${status.current}\n`);
79
+ }
80
+ if (dryRun) {
81
+ if (willUpgrade) {
82
+ process.stdout.write(`upgrade: would run — ${source.upgradeCommand.join(" ")}\n`);
83
+ }
84
+ if (!skipInstall) {
85
+ process.stdout.write(`refresh: would run — ${refreshCommand.join(" ")}\n`);
86
+ }
87
+ return 0;
88
+ }
89
+ // --- upgrade ---
90
+ if (willUpgrade) {
91
+ process.stdout.write("Upgrading the CLI…\n");
92
+ const result = runStep(run, source.upgradeCommand);
93
+ if (!result.ok) {
94
+ const message = result.error ?? "";
95
+ process.stderr.write(`upgrade: failed — ${message}\n`);
96
+ if (isPermissionError(message)) {
97
+ process.stderr.write("The global install directory is not writable by your user. Fix the ownership " +
98
+ `of your npm prefix so global installs work without sudo, then re-run \`uploads update\`.\n`);
99
+ }
100
+ process.stderr.write(`Run it by hand: ${source.upgradeCommand.join(" ")}\n`);
101
+ return 1;
102
+ }
103
+ process.stdout.write("upgrade: ok\n");
104
+ if (verbose && result.output)
105
+ process.stdout.write(` ${result.output}\n`);
106
+ }
107
+ if (skipInstall)
108
+ return 0;
109
+ // --- refresh ---
110
+ // After an upgrade the in-process code is the OLD version, so spawn the newly
111
+ // installed binary. Its skill list is the one that should be installed.
112
+ if (willUpgrade) {
113
+ process.stdout.write("Refreshing skills and MCP…\n");
114
+ const result = runStep(run, refreshCommand);
115
+ if (!result.ok) {
116
+ process.stderr.write(`refresh: failed — ${result.error ?? ""}\n`);
117
+ process.stderr.write("Run it by hand: uploads install\n");
118
+ return 1;
119
+ }
120
+ process.stdout.write(result.output ? `${result.output}\n` : "refresh: ok\n");
121
+ return 0;
122
+ }
123
+ // Nothing changed, so the in-process install code is already current.
124
+ return runInstall(verbose ? ["--verbose"] : [], { globals: opts.globals, runner: run });
125
+ }
@@ -166,7 +166,31 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
166
166
  */
167
167
  export declare class GithubCommentAuthorizationError extends Error {
168
168
  }
169
- export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
169
+ /**
170
+ * `opts.resync` marks an explicit `uploads comment` invocation rather than a
171
+ * background sync (attach, screenshot, put --comment). It costs the server one
172
+ * extra comment listing and in exchange collapses any duplicate managed
173
+ * comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
174
+ * not on every attach.
175
+ */
176
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string, opts?: {
177
+ resync?: boolean;
178
+ }): Promise<AttachmentsCommentResult>;
179
+ /**
180
+ * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
181
+ * no `path` metadata — `path` is one of the highest-value queryable tags
182
+ * (same tier as `state=`), and unlike `uploads screenshot` (which derives it
183
+ * from the captured URL), a plain `attach`/`put --pr`/`put --issue` of an
184
+ * already-existing image has nothing to derive it from, so it's easy to
185
+ * forget. Fires once per batch (not per file) — checks the metadata the
186
+ * server actually stored (`PutResult.metadata`), not what was requested, so
187
+ * a merge/validation drop still surfaces the gap. Non-image uploads (zips,
188
+ * PDFs, etc.) are exempt — "findable by page" doesn't apply to them.
189
+ */
190
+ export declare function pathMetaHintFor(uploads: {
191
+ contentType: string;
192
+ metadata?: Record<string, string>;
193
+ }[]): string | undefined;
170
194
  export type AttachUploadItem = PutResult & {
171
195
  file: string;
172
196
  markdown: string;
@@ -355,6 +379,14 @@ export declare function resolvePutStagingTarget(opts: {
355
379
  repoArg: string | undefined;
356
380
  run: CommandRunner;
357
381
  }): BranchTarget | undefined;
382
+ /**
383
+ * Merges a staging target's `gh.*` branch metadata over `base` and validates
384
+ * the result (same builder, same contract as `attach --branch`) — the one
385
+ * merge+validate step shared by every staging call site: `runPut`,
386
+ * `runScreenshot`, and both the local stdio MCP `put` and `screenshot`
387
+ * tools.
388
+ */
389
+ export declare function mergeStagingMeta(base: Record<string, string> | undefined, target: BranchTarget): Record<string, string>;
358
390
  /**
359
391
  * The bare-put staging note's wording (issue #403): replaces the #393 nudge
360
392
  * for the (now default) case where a bare put on a non-default branch stages