@buildinternet/uploads 0.27.0 → 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/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"], {
@@ -188,6 +188,11 @@ export const ROOT_COMMANDS = [
188
188
  { name: "all", summary: "Install skills and MCP (default)" },
189
189
  ],
190
190
  },
191
+ {
192
+ name: "update",
193
+ summary: "Update the CLI, then refresh the agent skills + MCP registration",
194
+ essential: true,
195
+ },
191
196
  {
192
197
  name: "login",
193
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 };
@@ -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,16 @@ 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>;
170
179
  /**
171
180
  * Lever 3 (issue #469): a nudge for when an image lands on a PR/issue with
172
181
  * no `path` metadata — `path` is one of the highest-value queryable tags
package/dist/commands.js CHANGED
@@ -450,13 +450,21 @@ export function commentViaSuffix(via) {
450
450
  */
451
451
  export class GithubCommentAuthorizationError extends Error {
452
452
  }
453
- export async function syncAttachmentsComment(client, target, run, workspace) {
453
+ /**
454
+ * `opts.resync` marks an explicit `uploads comment` invocation rather than a
455
+ * background sync (attach, screenshot, put --comment). It costs the server one
456
+ * extra comment listing and in exchange collapses any duplicate managed
457
+ * comment (issue #480) — worth it on the rare, explicitly-asked-for resync,
458
+ * not on every attach.
459
+ */
460
+ export async function syncAttachmentsComment(client, target, run, workspace, opts = {}) {
454
461
  let bot;
455
462
  try {
456
463
  bot = await client.upsertGithubComment({
457
464
  repo: target.repo,
458
465
  num: target.num,
459
466
  kind: target.kind,
467
+ ...(opts.resync ? { resync: true } : {}),
460
468
  });
461
469
  }
462
470
  catch {
@@ -2258,6 +2266,7 @@ async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2258
2266
  repo: target.repo,
2259
2267
  num: target.num,
2260
2268
  kind: target.kind,
2269
+ resync: true,
2261
2270
  });
2262
2271
  if (bot.posted) {
2263
2272
  if (!ctx.quiet && !ctx.json) {
@@ -2336,7 +2345,9 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2336
2345
  const target = ghTargetFromFlags(parsed.flags, run);
2337
2346
  if (!target)
2338
2347
  throw new UsageError("comment requires --pr or --issue");
2339
- const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
2348
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace, {
2349
+ resync: true,
2350
+ });
2340
2351
  if (ctx.json) {
2341
2352
  await writeJson({ ...target, ...result });
2342
2353
  }
@@ -55,7 +55,8 @@ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): s
55
55
  export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
56
56
  /**
57
57
  * Create the managed attachments comment, or edit it in place if it already
58
- * exists. Never touches any other comment. Body is passed via stdin
58
+ * exists. Never touches any other comment except best-effort deletes of
59
+ * duplicate marker comments (see below). Body is passed via stdin
59
60
  * (`-F body=@-`) so it is never shell-interpolated.
60
61
  *
61
62
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -63,6 +64,27 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
63
64
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
64
65
  * an adopted legacy comment migrates it to the namespaced marker in place.
65
66
  * Defaults to the shared legacy marker for backward compatibility.
67
+ *
68
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
69
+ * a create race (two concurrent `uploads attach` runs, neither finding an
70
+ * existing comment) can leave more than one marker comment on the thread.
71
+ * This path has no id cache, so unlike the bot path a duplicate here never
72
+ * heals on its own — every sync just patches the oldest and leaves the rest
73
+ * stale. After patching (or creating), any extra exact-`marker` hits are
74
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
75
+ * and never fails the caller's command, and the next sync retries anyway.
76
+ *
77
+ * On why this duplicates the bot path rather than deferring to it: the gh
78
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
79
+ * parity deliberately. This file already reimplements the hunt, the legacy
80
+ * adoption and the create-vs-patch gate against a different transport (the
81
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
82
+ * the two drifted. Treat any behavior change to `upsertBotComment`
83
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
84
+ * this is the one place the CLI deletes a GitHub resource under the
85
+ * invoking human's own credentials — bounded to comments carrying this
86
+ * workspace's exact namespaced marker, whose content is always
87
+ * regenerable.
66
88
  */
67
89
  export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string, opts?: {
68
90
  createIfMissing?: boolean;
package/dist/github-gh.js CHANGED
@@ -187,7 +187,9 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
187
187
  /**
188
188
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
189
189
  * `--paginate` follows Link headers and merges every page into one array, so the
190
- * marker comment is found even on threads past 100 comments.
190
+ * marker comment is found even on threads past 100 comments. GitHub returns
191
+ * comments oldest-first, so `hits[0]` (after merging paginated pages, which
192
+ * preserve that order) is the oldest exact-`marker` hit.
191
193
  *
192
194
  * Hunts for `marker` (the namespaced, per-workspace marker) first; when none
193
195
  * is found, falls back to a comment carrying the shared legacy
@@ -195,6 +197,12 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
195
197
  * migrated in place. When `marker` IS the legacy marker (no workspace to
196
198
  * namespace with) this collapses to a single hunt, unchanged from pre-4b
197
199
  * behavior.
200
+ *
201
+ * Collects EVERY comment carrying `marker` (a create race can leave more
202
+ * than one — issue #486, mirroring the bot path's #470 fix): the oldest is
203
+ * `comment`, the rest come back as `extras` for the caller to delete. Only
204
+ * exact-`marker` hits are ever extras — a legacy (unnamespaced) comment may
205
+ * belong to a different workspace, so it is adopted at most, never deleted.
198
206
  */
199
207
  function findManagedComment(target, run, marker) {
200
208
  const raw = run("gh", [
@@ -203,16 +211,25 @@ function findManagedComment(target, run, marker) {
203
211
  "--paginate",
204
212
  ]);
205
213
  const comments = JSON.parse(raw);
206
- const namespacedHit = comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
207
- if (namespacedHit)
208
- return namespacedHit;
214
+ const hits = comments.filter((c) => typeof c.body === "string" && c.body.includes(marker));
215
+ if (hits.length > 0) {
216
+ // In legacy mode (no workspace to namespace with) our "exact" marker IS
217
+ // the shared one, so a second hit is not our own duplicate — it may be
218
+ // another workspace's comment. Adopt the oldest and never delete: the
219
+ // adopt-only contract is about the marker being ambiguous, which is just
220
+ // as true when it is the marker we are hunting on.
221
+ const extras = marker === ATTACHMENTS_MARKER ? undefined : hits.slice(1);
222
+ return { comment: hits[0], extras };
223
+ }
209
224
  if (marker === ATTACHMENTS_MARKER)
210
- return undefined;
211
- return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
225
+ return {};
226
+ const legacyHit = comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
227
+ return { comment: legacyHit };
212
228
  }
213
229
  /**
214
230
  * Create the managed attachments comment, or edit it in place if it already
215
- * exists. Never touches any other comment. Body is passed via stdin
231
+ * exists. Never touches any other comment except best-effort deletes of
232
+ * duplicate marker comments (see below). Body is passed via stdin
216
233
  * (`-F body=@-`) so it is never shell-interpolated.
217
234
  *
218
235
  * `marker` identifies which comment to hunt for (see `findManagedComment`);
@@ -220,10 +237,41 @@ function findManagedComment(target, run, marker) {
220
237
  * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
221
238
  * an adopted legacy comment migrates it to the namespaced marker in place.
222
239
  * Defaults to the shared legacy marker for backward compatibility.
240
+ *
241
+ * Self-healing dedupe (issue #486, mirroring the bot path's #470/#484 fix):
242
+ * a create race (two concurrent `uploads attach` runs, neither finding an
243
+ * existing comment) can leave more than one marker comment on the thread.
244
+ * This path has no id cache, so unlike the bot path a duplicate here never
245
+ * heals on its own — every sync just patches the oldest and leaves the rest
246
+ * stale. After patching (or creating), any extra exact-`marker` hits are
247
+ * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
248
+ * and never fails the caller's command, and the next sync retries anyway.
249
+ *
250
+ * On why this duplicates the bot path rather than deferring to it: the gh
251
+ * fallback is a supported path, not a stopgap, so it is held at behavioral
252
+ * parity deliberately. This file already reimplements the hunt, the legacy
253
+ * adoption and the create-vs-patch gate against a different transport (the
254
+ * `gh` subprocess, not the App's token), and #486 existed precisely because
255
+ * the two drifted. Treat any behavior change to `upsertBotComment`
256
+ * (apps/api/src/github-comment.ts) as owing a matching change here. Note
257
+ * this is the one place the CLI deletes a GitHub resource under the
258
+ * invoking human's own credentials — bounded to comments carrying this
259
+ * workspace's exact namespaced marker, whose content is always
260
+ * regenerable.
223
261
  */
224
262
  export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
225
263
  const createIfMissing = opts.createIfMissing ?? true;
226
- const existing = findManagedComment(target, run, marker);
264
+ const { comment: existing, extras } = findManagedComment(target, run, marker);
265
+ const deleteExtras = () => {
266
+ for (const extra of extras ?? []) {
267
+ try {
268
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
269
+ }
270
+ catch {
271
+ // Best effort only — a failed delete must never fail the caller's command.
272
+ }
273
+ }
274
+ };
227
275
  if (existing) {
228
276
  run("gh", [
229
277
  "api",
@@ -233,12 +281,15 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
233
281
  "-F",
234
282
  "body=@-",
235
283
  ], body);
284
+ deleteExtras();
236
285
  return { action: "updated" };
237
286
  }
238
287
  // Patch-only (createIfMissing false, i.e. an empty body) with no existing
239
288
  // comment: nothing to do — never create one just to say it's empty.
240
289
  if (!createIfMissing)
241
290
  return { action: "skipped" };
291
+ // No existing marker hit means `extras` is necessarily empty here (see
292
+ // `findManagedComment`) — nothing to delete after a create.
242
293
  run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
243
294
  return { action: "created" };
244
295
  }
@@ -0,0 +1,14 @@
1
+ export type InstallKind = "global" | "workspace" | "npx" | "unknown";
2
+ export type PackageManager = "npm" | "pnpm" | "bun";
3
+ export interface InstallSource {
4
+ kind: InstallKind;
5
+ /** Falls back to npm for every non-global kind. */
6
+ manager: PackageManager;
7
+ /** Upgrades the global install. Only meaningful when kind is "global". */
8
+ upgradeCommand: string[];
9
+ }
10
+ /**
11
+ * @param modulePath Absolute path of a file inside the installed package,
12
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
13
+ */
14
+ export declare function detectInstallSource(modulePath: string): InstallSource;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Classify where the running CLI was installed from.
3
+ *
4
+ * `uploads update` upgrades the global npm package. That is only safe when the
5
+ * CLI actually came from a global install — upgrading a workspace checkout
6
+ * would overwrite a developer's build with the published version.
7
+ *
8
+ * Pure and path-only: no filesystem or process access, so it is fully testable.
9
+ */
10
+ import { PACKAGE_NAME } from "./update-check.js";
11
+ const UPGRADE_COMMANDS = {
12
+ npm: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
13
+ pnpm: ["pnpm", "add", "-g", `${PACKAGE_NAME}@latest`],
14
+ bun: ["bun", "add", "-g", `${PACKAGE_NAME}@latest`],
15
+ };
16
+ function classify(path) {
17
+ // npx is checked first: a cache entry can also contain a global-looking marker.
18
+ if (path.includes("/_npx/"))
19
+ return { kind: "npx", manager: "npm" };
20
+ if (path.includes("/.bun/install/global/"))
21
+ return { kind: "global", manager: "bun" };
22
+ if (path.includes("/pnpm/global/"))
23
+ return { kind: "global", manager: "pnpm" };
24
+ if (path.includes("/lib/node_modules/"))
25
+ return { kind: "global", manager: "npm" };
26
+ // Windows npm globals have no `lib` segment: `<prefix>\npm\node_modules\<pkg>`.
27
+ if (path.includes("/npm/node_modules/"))
28
+ return { kind: "global", manager: "npm" };
29
+ // No node_modules segment at all means we are running out of a source checkout.
30
+ if (!path.includes("/node_modules/"))
31
+ return { kind: "workspace", manager: "npm" };
32
+ return { kind: "unknown", manager: "npm" };
33
+ }
34
+ /**
35
+ * @param modulePath Absolute path of a file inside the installed package,
36
+ * normally `realpathSync(fileURLToPath(import.meta.url))`.
37
+ */
38
+ export function detectInstallSource(modulePath) {
39
+ const normalized = modulePath.split("\\").join("/");
40
+ const { kind, manager } = classify(normalized);
41
+ return { kind, manager, upgradeCommand: UPGRADE_COMMANDS[manager] };
42
+ }
package/dist/mcp/tools.js CHANGED
@@ -1158,7 +1158,10 @@ export function createUploadsMcpTools(opts) {
1158
1158
  if (!target)
1159
1159
  usage("comment requires pr or issue");
1160
1160
  const { config, client } = clientFor(args);
1161
- const result = await syncAttachmentsComment(client, target, run, config.workspace);
1161
+ // Explicit resync, same as `uploads comment` (issue #480).
1162
+ const result = await syncAttachmentsComment(client, target, run, config.workspace, {
1163
+ resync: true,
1164
+ });
1162
1165
  return { ...target, ...result };
1163
1166
  },
1164
1167
  },
@@ -116,7 +116,7 @@ export async function maybeHintUpdate(opts = {}) {
116
116
  if (!status.updateAvailable || !status.latest)
117
117
  return;
118
118
  const write = opts.write ?? ((text) => process.stderr.write(text));
119
- write(`hint: ${PACKAGE_NAME}@${status.latest} is available (you have ${status.current}). Update: npm i -g ${PACKAGE_NAME}\n`);
119
+ write(`hint: ${PACKAGE_NAME}@${status.latest} is available (you have ${status.current}). Update: uploads update\n`);
120
120
  }
121
121
  catch {
122
122
  // Never surface update-check failures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.27.0",
3
+ "version": "0.28.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,