@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 +4 -1
- package/dist/cli-catalog.js +5 -0
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +4 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +7 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +125 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +13 -2
- package/dist/github-gh.d.ts +23 -1
- package/dist/github-gh.js +59 -8
- package/dist/install-source.d.ts +14 -0
- package/dist/install-source.js +42 -0
- package/dist/mcp/tools.js +4 -1
- package/dist/update-check.js +1 -1
- package/package.json +1 -1
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}`, `
|
|
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"], {
|
package/dist/cli-catalog.js
CHANGED
|
@@ -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
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;
|
package/dist/commands/install.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -166,7 +166,16 @@ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]):
|
|
|
166
166
|
*/
|
|
167
167
|
export declare class GithubCommentAuthorizationError extends Error {
|
|
168
168
|
}
|
|
169
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
|
|
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
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
},
|
package/dist/update-check.js
CHANGED
|
@@ -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:
|
|
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.
|